Whyx-PROmpTea / app.py
ArtShumov's picture
feat(smart): accurate pose + intelligent whole-image tags
716aebb
Raw
History Blame Contribute Delete
144 kB
import gradio as gr
import re as _re
_GRADIO_MAJOR = int(_re.match(r"(\d+)", gr.__version__).group(1))
import html
from src.i18n import t
from src.prompt_parser import parse_prompt
from src.tag_warehouse import TagWarehouse, STYLE_ICONS
from src.variation_engine import generate_variations, generate_negative_prompt
from src.presets import PRESETS, PRESET_GROUPS, get_preset_categories, get_user_preset_names, save_user_preset, delete_user_preset
from src.prompt_analyzer import analyze_prompt, format_analysis_html
from src.session_history import HistoryEntry, add_to_history, get_history
from src.synonym_data import reload_synonym_groups
from src.tag_format import to_booru_tag, to_prompt_tag
from theme.whyx_theme import WhyxTheme
from src.ensemble_tagger import get_ensemble_tagger
from src.image_tagger import _TAGGER_DEPS_OK
from PIL import Image, ImageOps
MAX_OUTPUTS = 10
warehouse = TagWarehouse()
ALL_CATEGORIES = [
"quality", "lighting", "composition", "effects", "atmosphere",
"framing", "style", "colors", "background", "pose", "expression",
"clothing", "special_fx", "year_meta", "nsfw",
"animal", "furry", "object", "food",
"vehicle", "weapon", "architecture",
"demon", "angelic",
"hair", "body", "accessory", "season",
]
CATEGORY_ICONS = {
"quality": "⭐", "lighting": "💡", "composition": "📐",
"effects": "🌀", "atmosphere": "🌙", "framing": "🖼️",
"style": "🎨", "colors": "🌈", "background": "🏞️",
"pose": "🧍", "expression": "😊", "clothing": "👗",
"special_fx": "✨", "year_meta": "📅", "nsfw": "🔞",
"animal": "🐾", "furry": "🐺", "object": "🏺", "food": "🍕",
"vehicle": "🚗", "weapon": "⚔️", "architecture": "🏛️",
"demon": "👿", "angelic": "👼",
"hair": "💇", "body": "💪", "accessory": "💍", "season": "🌸",
}
DEFAULT_CHECKED = {"quality", "lighting", "expression"}
def _build_artist_choice(a):
icon = STYLE_ICONS.get(a.get('style', ''), '')
style = a.get('style', '')
return f"{icon} {a['tag']} [{style}]" if style else f"{icon} {a['tag']}"
artist_styles = [""] + warehouse.get_artist_styles()
all_artists = warehouse.get_all_artists()
all_tandems = warehouse.get_all_tandems()
STYLECHOICES = [(s.title() if s else t("artist_all", "en"), s) for s in artist_styles]
ARTISTCHOICES = [
(_build_artist_choice(a), a["tag"])
for a in sorted(all_artists, key=lambda a: a.get("popularity", 0), reverse=True)[:50]
]
TANDEMCHOICES = [(t("tandem_placeholder", "en"), None)] + [
(
f"🤝 {tand['artists'][0]} × {tand['artists'][1]} [{tand.get('style_blend','')}] ({tand.get('compatibility',0):.0%})",
i,
)
for i, tand in enumerate(all_tandems)
]
COPY_JS = """(text) => {
if (!text || !text.trim()) return text;
try {
navigator.clipboard.writeText(text);
} catch(e) {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
} catch(e2) {}
}
const btn = document.activeElement;
if (btn && btn.classList.contains('whyx-copy-btn')) {
const origHTML = btn.innerHTML;
btn.innerHTML = '<span style="color:#34D399;font-weight:700;font-size:16px;">&#10003;</span>';
btn.style.borderColor = '#34D399';
btn.style.background = 'rgba(52,211,153,0.15)';
btn.style.transform = 'scale(1.1)';
setTimeout(() => {
btn.innerHTML = origHTML;
btn.style.borderColor = '';
btn.style.background = '';
btn.style.transform = '';
}, 1400);
}
return text;
}"""
def on_generate(prompt, model, rating, num_variations, creativity, weight_mode, mode, lang, artist_style, selected_artists, use_tandems, selected_tandem_idx, web_enrich, seed, current_preset_state, fx_count, blacklist_text, mirror_blacklist, strip_quality, strip_artist, strip_lora, strip_meta, min_tags, user_preset_state, output_format, *checks) -> tuple:
def _card_updates(positives):
return tuple(
gr.update(visible=bool((positives[i] or "").strip()))
for i in range(MAX_OUTPUTS)
)
try:
if not prompt or not prompt.strip():
return tuple([""] * (MAX_OUTPUTS * 2)) + _card_updates([""] * MAX_OUTPUTS)
parsed = parse_prompt(prompt)
if parsed is None:
msg = t("error_parse", lang)
outs = [msg] + [""] * (MAX_OUTPUTS - 1)
return tuple(outs) + tuple([""] * MAX_OUTPUTS) + _card_updates(outs)
active = [c for c, on in zip(ALL_CATEGORIES, checks) if on]
if not active:
active = ["quality", "lighting", "expression"]
if rating in ("pg", "pg13") and "nsfw" in active:
active.remove("nsfw")
selected_tandem = None
if use_tandems and selected_tandem_idx is not None:
try:
idx = int(selected_tandem_idx)
if 0 <= idx < len(all_tandems):
selected_tandem = all_tandems[idx]
except (ValueError, TypeError):
pass
selected_presets = [p.strip() for p in (current_preset_state or "").split(",") if p.strip()]
if user_preset_state:
selected_presets.extend(
p.strip() for p in str(user_preset_state).split(",") if p.strip()
)
fx_count = {"off": 0, "light": 2, "rich": 4}.get(fx_count, 0)
# Blacklist / Tags-to-remove (A): comma- or newline-separated.
exclude_tags = [
t.strip().lower()
for t in str(blacklist_text or "").replace("\n", ",").split(",")
if t.strip()
]
mirror = bool(mirror_blacklist)
results = generate_variations(
parsed=parsed,
selected_categories=active,
num_variations=int(num_variations),
creativity=creativity,
model=model.lower(),
rating=rating,
warehouse=warehouse,
artist_style=artist_style or "",
selected_artists=selected_artists or [],
use_tandems=bool(use_tandems),
selected_tandem=selected_tandem,
weight_mode=weight_mode,
mode=mode,
web_enrich=web_enrich == "1",
selected_presets=selected_presets,
fx_count=fx_count,
seed=int(seed) if seed is not None and str(seed).strip() else None,
exclude_tags=exclude_tags,
strip_quality=bool(strip_quality),
strip_artist=bool(strip_artist),
strip_lora=bool(strip_lora),
strip_meta=bool(strip_meta),
min_tags=int(min_tags or 0),
output_format=output_format or "prompt",
)
out = list(results)
while len(out) < MAX_OUTPUTS:
out.append("")
neg_results = generate_negative_prompt(
parsed=parsed,
selected_categories=active,
num_variations=int(num_variations),
rating=rating,
warehouse=warehouse,
model=model.lower(),
positive_tags=out[:MAX_OUTPUTS],
extra_negative=exclude_tags if mirror else None,
output_format=output_format or "prompt",
)
neg_out = list(neg_results)
while len(neg_out) < MAX_OUTPUTS:
neg_out.append("")
add_to_history(
prompt, out[:MAX_OUTPUTS], model=model, rating=rating,
num_variations=int(num_variations), creativity=creativity,
weight_mode=weight_mode,
neg_results=neg_out[:MAX_OUTPUTS],
categories=active,
seed=int(seed) if seed is not None and str(seed).strip() else None,
)
return tuple(out[:MAX_OUTPUTS]) + tuple(neg_out[:MAX_OUTPUTS]) + _card_updates(out[:MAX_OUTPUTS])
except Exception as exc:
import traceback
traceback.print_exc()
err_msg = t("generation_error", lang).format(exc=html.escape(str(exc)))
outs = [err_msg] + [""] * (MAX_OUTPUTS - 1)
return tuple(outs) + tuple([""] * MAX_OUTPUTS) + _card_updates(outs)
def on_suggest(prompt, model, rating, lang) -> str:
"""Suggest improvement tags for the given prompt (read-only analysis)."""
try:
if not prompt or not prompt.strip():
return f'<div class="whyx-info-text">{t("input_placeholder", "en" if lang != "RU" else "ru")}</div>'
parsed = parse_prompt(prompt)
if parsed is None:
return f'<div class="whyx-info-text">{t("error_parse", lang)}</div>'
from src.suggestion_engine import suggest_prompt_improvements
suggestions = suggest_prompt_improvements(
parsed, warehouse, model=model.lower(), rating=rating, max_suggestions=6
)
lc = "ru" if lang == "RU" else "en"
if not suggestions:
return f'<div class="whyx-info-text">{t("suggest_empty", lc)}</div>'
lines = "".join(
f'<li class="whyx-suggest-item">{html.escape(s)}</li>' for s in suggestions
)
return (
f'<div class="whyx-suggest-box">'
f'<div class="whyx-suggest-title">{t("suggest_header", lc)}</div>'
f'<ul>{lines}</ul></div>'
)
except Exception as exc:
import traceback
traceback.print_exc()
return f'<div class="whyx-info-text">Error: {html.escape(str(exc))}</div>'
def _preset_status(key: str, lang: str, **kwargs) -> str:
return f'<div class="whyx-info-text">{t(key, lang).format(**kwargs) if kwargs else t(key, lang)}</div>'
def on_user_preset_save(name: str, tags: str, lang: str):
name = (name or "").strip()
tags_list = [t.strip() for t in str(tags or "").replace("\n", ",").split(",") if t.strip()]
if not name:
return gr.update(choices=_user_preset_choices(lang), value=""), "", _preset_status("user_preset_need_name", lang)
save_user_preset(name, tags_list)
return gr.update(choices=_user_preset_choices(lang), value=name), "", _preset_status("user_preset_saved", lang, name=name)
def on_user_preset_apply(name: str, current_state: str, lang: str):
if not name:
return current_state, _preset_status("user_preset_pick", lang)
state = (current_state or "").strip()
keys = [k for k in state.split(",") if k.strip()] if state else []
if name not in keys:
keys.append(name)
return ",".join(keys), _preset_status("user_preset_applied", lang, name=name)
def on_user_preset_delete(name: str, lang: str):
if not name:
return gr.update(choices=_user_preset_choices(lang), value=""), _preset_status("user_preset_pick", lang)
delete_user_preset(name)
return gr.update(choices=_user_preset_choices(lang), value=""), _preset_status("user_preset_deleted", lang, name=name)
def on_web_search(query: str, lang: str) -> str:
from src.tag_searcher import search_tags
lc = "ru" if lang == "RU" else "en"
if not query or not query.strip():
return f'<div class="whyx-info-text" style="color:var(--text-faint);font-size:12px;">{t("web_search_empty", lc)}</div>'
try:
results = search_tags(query, warehouse)
parts = []
if results["local"]:
parts.append(f'<div style="margin-top:6px;font-size:13px;font-weight:600;color:var(--text);">🔍 {t("web_search_offline", lc)} ({len(results["local"])})</div>')
for r in results["local"][:15]:
safe_tag = html.escape(r["tag"])
safe_cat = html.escape(r["category"])
parts.append(f'<span style="display:inline-block;background:var(--accent-glow);border:1px solid var(--accent-border);border-radius:4px;padding:1px 6px;margin:2px;font-size:12px;color:var(--text);">{safe_tag} <span style="color:var(--text-faint);font-size:10px;">[{safe_cat}]</span></span>')
if results["web"]:
parts.append(f'<div style="margin-top:8px;font-size:13px;font-weight:600;color:var(--text);">🌐 {t("web_search_online", lc)} ({len(results["web"])})</div>')
for r in results["web"][:MAX_OUTPUTS]:
safe_tag = html.escape(r.get("tag", ""))
count = r.get("post_count", 0)
parts.append(f'<span style="display:inline-block;background:var(--accent-glow);border:1px solid var(--accent-border);border-radius:4px;padding:1px 6px;margin:2px;font-size:12px;color:var(--text);">{safe_tag} <span style="color:var(--text-faint);font-size:10px;">({count} posts)</span></span>')
if not parts:
return f'<div class="whyx-info-text" style="font-size:12px;">{t("web_enrich_no_results", lc)}</div>'
return '<div style="line-height:1.8;">' + "".join(parts) + '</div>'
except Exception as exc:
return f'<div style="color:#ef4444;font-size:12px;">{t("web_search_error", lc).format(exc=html.escape(str(exc)))}</div>'
def _format_history_html(lang: str) -> str:
lc = "ru" if lang == "RU" else "en"
history = get_history()
lines = []
lines.append("<div style='background:var(--input-bg);border:1px solid rgba(124,58,237,0.12);border-radius:10px;padding:10px 14px;margin-top:4px;'>")
entries = history.get_all()[:5]
if not entries:
lines.append(f"<div style='color:var(--text-dim);font-size:12px;'>{t('history_empty', lc)}</div>")
else:
for entry in entries:
liked = entry.get_liked_results()
lines.append("<div style='border-bottom:1px solid rgba(79,70,229,0.06);padding:6px 0;'>")
lines.append("<div style='font-size:11px;color:var(--text-dim);'>")
lines.append(f"<span style='color:var(--text);'>{t('history_prompt', lc)}:</span> <span style='color:var(--text-faint);'>")
p = entry.prompt[:80] + "..." if len(entry.prompt) > 80 else entry.prompt
lines.append(f"{html.escape(p)}</span></div>")
if liked:
lines.append(f"<div style='margin-top:3px;font-size:11px;color:#F43F5E;'>♥ {len(liked)} {t('history_liked', lc).lower()}</div>")
lines.append("</div>")
favs = history.get_favorites()
if favs:
lines.append("<div style='border-top:1px solid rgba(79,70,229,0.08);margin-top:8px;padding-top:8px;'>")
lines.append(f"<div style='font-size:12px;color:var(--text);font-weight:600;margin-bottom:4px;'>♥ {t('favorites_label', lc)}</div>")
for entry, idx, text in favs[:5]:
t_text = text[:60] + "..." if len(text) > 60 else text
lines.append(f"<div style='font-size:11px;color:var(--text-dim);padding:2px 0;'><span style='color:#F43F5E;'>♥</span> {html.escape(t_text)}</div>")
lines.append("</div>")
else:
lines.append("<div style='border-top:1px solid rgba(79,70,229,0.08);margin-top:8px;padding-top:8px;'>")
lines.append(f"<div style='font-size:11px;color:var(--text-faint);'>{t('history_no_likes', lc)}</div>")
lines.append("</div>")
lines.append("</div>")
return "\n".join(lines)
def _heart_click(idx: int, lang, *results) -> tuple:
lc = "ru" if lang == "RU" else "en"
history = get_history()
entries = history.get_all()
heart_updates = []
for i in range(MAX_OUTPUTS):
if entries and i < len(entries[0].results):
heart_updates.append("♥" if entries[0].is_liked(i) else "♡")
else:
heart_updates.append("♡")
if entries and idx < len(results) and results[idx]:
liked = history.toggle_like(0, idx)
heart_updates[idx] = "♥" if liked else "♡"
return (gr.update(value=_format_history_html(lc)),) + tuple(gr.update(value=h) for h in heart_updates)
def on_preset_change(prompt, *group_and_state):
all_groups = list(group_and_state[:-1])
current_active = group_and_state[-1]
active = None
for vals in all_groups:
if vals and isinstance(vals, list) and len(vals) > 0:
active = vals[0]
break
if active == current_active:
resets = [gr.update(value=[]) for _ in PRESET_GROUPS]
return (*[c in DEFAULT_CHECKED for c in ALL_CATEGORIES], prompt, "", *resets)
if active:
cats = get_preset_categories(active)
resets = []
for group in PRESET_GROUPS:
if active in group["presets"]:
resets.append(gr.update(value=[active]))
else:
resets.append(gr.update(value=[]))
# Presets are now layered inside the engine (protected additions),
# so the prompt text itself is left untouched.
return (*[c in cats for c in ALL_CATEGORIES], prompt, active, *resets)
else:
resets = [gr.update(value=[]) for _ in PRESET_GROUPS]
return (*[c in DEFAULT_CHECKED for c in ALL_CATEGORIES], prompt, "", *resets)
def on_rating_change(rating: str, *checks) -> list[bool]:
checks = list(checks)
if rating in ("pg", "pg13"):
idx = ALL_CATEGORIES.index("nsfw")
if idx < len(checks):
checks[idx] = False
return (*checks,)
def on_artist_filter_change(style: str, query: str):
q = (query or "").lower().strip()
filtered = all_artists
if style:
filtered = [a for a in filtered if a.get("style") == style]
if q:
filtered = [
a for a in filtered
if q in a["tag"].lower()
or q in a.get("style", "").lower()
or any(q in t.lower() for t in a.get("signature_tags", []))
]
# Lazy-load: limit to top 50 by popularity unless searching
if not q and len(filtered) > 50:
filtered = sorted(filtered, key=lambda a: a.get("popularity", 0), reverse=True)[:50]
choices = [(_build_artist_choice(a), a["tag"]) for a in filtered]
return gr.update(choices=choices, value=[])
def _format_artist_info(artist_names: list[str], lang: str = "en") -> str:
title = t("artist_preview_title", lang)
if not artist_names:
empty = t("artist_preview_empty", lang)
return f"""
<div class="whyx-artist-preview">
<div class="whyx-artist-preview-title">{title}</div>
<div class="whyx-artist-preview-empty">{empty}</div>
</div>
"""
lines = []
for name in artist_names:
safe_name = html.escape(name)
desc = warehouse.get_artist_description(name, lang)
url = warehouse.get_artist_danbooru_url(name)
if desc:
safe_url = html.escape(url) if url else ""
safe_desc = html.escape(desc)
link = f"<a class='whyx-artist-link' href='{safe_url}' target='_blank'>{t('danbooru_link', lang)}</a>" if url else ""
lines.append(f"""
<div style='margin:8px 0;padding:8px 0;border-bottom:1px solid rgba(79,70,229,0.08);'>
<div style='display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;'>
<strong style='color:var(--text);font-size:14px;'>{safe_name}</strong>
{link}
</div>
<div style='color:var(--text-dim);font-size:12px;margin-top:4px;line-height:1.4;'>{safe_desc}</div>
</div>
""")
# Similar artists from the first selected one
if len(artist_names) == 1:
similar = warehouse.get_similar_artists(artist_names[0], limit=3)
if similar:
names = ", ".join(html.escape(a["tag"]) for a in similar)
label = t("similar_artists", lang)
lines.append(f"<div style='margin-top:8px;font-size:12px;color:#7C3AED;'>✨ {label}: {names}</div>")
return "<div class='whyx-artist-preview'><div class='whyx-artist-preview-title'>" + title + "</div>" + "".join(lines) + "</div>"
def on_artist_selection_change(selected_artists, lang):
return gr.update(value=_format_artist_info(selected_artists or [], lang))
def on_prompt_analyze(prompt: str, lang: str):
lc = "ru" if lang == "RU" else "en"
data = analyze_prompt(prompt, warehouse)
return gr.update(value=format_analysis_html(data, lc))
def _reload_data(lang):
warehouse.reload_pools()
warehouse.reload_artists()
reload_synonym_groups()
from src.prompt_rewriter import reload_map
from src.tag_searcher import reload_cooccurrence
from src.dedup_engine import reload_groups
reload_map()
reload_cooccurrence()
reload_groups()
def on_artist_style_recommendations(style: str, lang: str):
title = t("artist_preview_title", lang)
if not style:
empty = t("artist_preview_empty", lang)
return gr.update(value=f"""
<div class="whyx-artist-preview">
<div class="whyx-artist-preview-title">{title}</div>
<div class="whyx-artist-preview-empty">{empty}</div>
</div>
""")
artists = warehouse.get_artists_by_style(style)
artists = sorted(artists, key=lambda a: a.get("popularity", 0), reverse=True)[:3]
if not artists:
empty = t("artist_preview_empty", lang)
return gr.update(value=f"""
<div class="whyx-artist-preview">
<div class="whyx-artist-preview-title">{title}</div>
<div class="whyx-artist-preview-empty">{empty}</div>
</div>
""")
names = ", ".join(html.escape(a["tag"]) for a in artists)
label = t("top_artists_in_style", lang)
return gr.update(value=f"<div class='whyx-artist-preview'><div class='whyx-artist-preview-title'>{title}</div><div style='font-size:12px;color:#7C3AED;'>✨ {label}: {names}</div></div>")
def _build_tagger_html(result: dict, lc: str) -> str:
lines = []
lines.append("<div class='whyx-tagger-panel'>")
# ---- Natural-language caption (Florence) --------------------------------
nl_caption = (result.get("nl_caption") or "").strip()
if nl_caption:
lines.append(
f"<div class='whyx-tagger-panel-title'>{t('tagger_nl_caption', lc)}</div>"
f"<div class='whyx-tagger-nl-caption'>{html.escape(nl_caption)}</div>"
)
# ---- Pose summary ---------------------------------------------------------
pose_tags = result.get("pose_tags") or []
people = int(result.get("people_count") or 0)
if pose_tags or people:
pose_label = t("tagger_pose_single" if people == 1 else "tagger_pose_many", lc).format(n=people)
lines.append(f"<div class='whyx-tagger-panel-title' style='margin-top:10px;'>🧍 {t('tagger_pose_label', lc)}</div>")
if people:
lines.append(f"<div class='whyx-tagger-pose-people'>{html.escape(pose_label)}</div>")
if pose_tags:
lines.append(
"<div class='whyx-tagger-pose-tags'>"
+ ", ".join(html.escape(p) for p in pose_tags)
+ "</div>"
)
# ---- Smart analysis tags (anatomy / expression / background / scene / depth)
for key, label_key, emoji, css_class in [
("anatomy_tags", "tagger_anatomy_label", "🏃", "whyx-tagger-anatomy-tags"),
("expression_tags", "tagger_expression_label", "😊", "whyx-tagger-expr-tags"),
("background_tags", "tagger_background_label", "🏞️", "whyx-tagger-bg-tags"),
("segmentation_tags", "tagger_segmentation_label", "🌆", "whyx-tagger-seg-tags"),
("depth_tags", "tagger_depth_label", "📐", "whyx-tagger-depth-tags"),
]:
smart_tags = result.get(key) or []
if smart_tags:
lines.append(
f"<div class='whyx-tagger-panel-title' style='margin-top:10px;'>{emoji} {t(label_key, lc)}</div>"
)
lines.append(
f"<div class='{css_class}'>"
+ ", ".join(html.escape(p) for p in smart_tags)
+ "</div>"
)
# ---- Categorized tags (emotion / lighting / style / concept / palette) ----
for key, label_key, emoji, css_class in [
("emotion_tags", "tagger_emotion_label", "🎭", "whyx-tagger-emotion-tags"),
("lighting_tags", "tagger_lighting_label", "💡", "whyx-tagger-lighting-tags"),
("style_tags", "tagger_style_label", "🎨", "whyx-tagger-style-tags"),
("concept_tags", "tagger_concept_label", "💭", "whyx-tagger-concept-tags"),
("palette_tags", "tagger_palette_label", "🌈", "whyx-tagger-palette-tags"),
]:
cat_tags = result.get(key) or []
if cat_tags:
lines.append(
f"<div class='whyx-tagger-panel-title' style='margin-top:10px;'>{emoji} {t(label_key, lc)}</div>"
)
lines.append(
f"<div class='{css_class}'>"
+ ", ".join(html.escape(p) for p in cat_tags)
+ "</div>"
)
# ---- Rating distribution ---------------------------------------------------
lines.append(f"<div class='whyx-tagger-panel-title' style='margin-top:10px;'>{t('tagger_ratings', lc)}</div>")
rating_colors = {"general": "#34D399", "sensitive": "#FBBF24", "questionable": "#F97316", "explicit": "#EF4444"}
for name, score in result["ratings"].items():
color = rating_colors.get(name, "var(--text-dim)")
pct = int(score * 100)
lines.append(
f"<div class='whyx-tagger-rating-row'>"
f"<span class='whyx-tagger-rating-name' style='color:{color};'>{name}</span>"
f"<span class='whyx-tagger-rating-track'><span class='whyx-tagger-rating-fill' style='width:{pct}%;background:{color};'></span></span>"
f"<span class='whyx-tagger-rating-pct'>({score:.1%})</span></div>"
)
if result["characters"]:
lines.append(f"<div class='whyx-tagger-panel-title' style='margin-top:10px;'>{t('tagger_characters', lc)}</div>")
lines.append("<div class='whyx-tagger-badges'>")
for name, score in list(result["characters"].items())[:6]:
safe = html.escape(name.replace("_", " ").replace("(", "\\(").replace(")", "\\)"))
lines.append(f"<span class='whyx-tagger-char-badge'>{safe} <span class='whyx-tagger-badge-pct'>{score:.0%}</span></span>")
lines.append("</div>")
lines.append(f"<div class='whyx-tagger-panel-title' style='margin-top:10px;'>{t('tagger_top_general', lc)}</div>")
lines.append("<div class='whyx-tagger-gen-list'>")
for name, score in list(result["general"].items())[:14]:
safe = html.escape(name.replace("_", " ").replace("(", "\\(").replace(")", "\\)"))
pct = int(score * 100)
lines.append(
f"<div class='whyx-tagger-gen-row'>"
f"<span class='whyx-tagger-gen-track'><span class='whyx-tagger-gen-fill' style='width:{pct}%;'></span></span>"
f"<span class='whyx-tagger-gen-name'>{safe}</span>"
f"<span class='whyx-tagger-badge-pct'>{score:.0%}</span></div>"
)
lines.append("</div>")
lines.append("</div>")
return "\n".join(lines)
def on_tag_image(image, gen_threshold, char_threshold, fmt, lang, progress=gr.Progress(), skip_pose=False, with_caption=False, caption_kind="florence", control_kind="skeleton_black", pose_mode="yolo", smart_analysis=True):
lc = "ru" if lang == "RU" else "en"
# fmt carries "prompt|<mode>" from the tagger-mode dropdown: "prompt|wd:eva02", "prompt|ensemble".
if "|" in fmt:
fmt, mode = fmt.split("|", 1)
fmt = fmt.strip() or "prompt"
mode = mode.strip() or "ensemble"
else:
mode = "ensemble"
empty_chips = gr.update(choices=[], value=[], visible=False)
hidden_control = (gr.update(visible=False), gr.update(visible=False))
if image is None:
return (
gr.update(value=f'<div style="color:#F87171;font-size:12px;">{t("tagger_no_image", lc)}</div>'),
gr.update(visible=False),
empty_chips, empty_chips,
gr.update(visible=False),
gr.update(visible=False),
"", "",
) + hidden_control
try:
progress(0, desc=t("tagger_processing", lc))
if not _TAGGER_DEPS_OK:
msg = f'<div style="color:#FBBF24;font-size:13px;line-height:1.5;">{t("tagger_unavailable", lc)}</div>'
return (
gr.update(value=msg),
gr.update(visible=False),
empty_chips, empty_chips,
gr.update(visible=False),
gr.update(visible=False),
"", "",
) + hidden_control
progress(0.35, desc=t("tagger_ratings_label", lc))
result = get_ensemble_tagger().tag_image(
image, gen_threshold, char_threshold,
mode=mode, skip_pose=skip_pose, with_caption=with_caption,
caption_kind=caption_kind, pose_mode=pose_mode,
smart_analysis=smart_analysis,
)
progress(0.9, desc=t("tagger_results", lc))
if not result["general"] and not result["characters"] and not result.get("nl_caption"):
html_out = f'<div style="color:#FBBF24;font-size:12px;">{t("tagger_no_tags", lc)}</div>'
return (
gr.update(value=html_out),
gr.update(visible=False),
empty_chips, empty_chips,
gr.update(visible=False),
gr.update(visible=False),
"", "",
) + hidden_control
html_out = _build_tagger_html(result, lc)
general_names = list(result["general"].keys())[:60]
char_names = list(result["characters"].keys())
# Merge the NL caption into the tag chips so it can be picked up by
# the same apply/copy pipeline as booru tags. Pose tags are already
# folded into `general`/`caption`/`taglist` by the ensemble tagger,
# so they must NOT be added here again.
extra_names: list[str] = []
if result.get("nl_caption"):
extra_names.append(result["nl_caption"].strip())
all_names = extra_names + general_names
general_choices = [(n, n) for n in all_names]
char_choices = [(n.replace("_", " "), n) for n in char_names]
raw_caption = result["caption"]
esc_taglist = result["taglist"]
tags_box = esc_taglist if fmt == "prompt" else raw_caption
# ControlNet-style preview built from the same keypoints the pose tags
# came from (pure numpy / PIL, no extra models). Hidden when the pose
# pass was skipped or the preview kind has nothing to show.
control_outputs = _render_control_outputs(
ImageOps.exif_transpose(Image.fromarray(image)),
result, control_kind,
)
return (
gr.update(value=html_out),
gr.update(visible=True),
gr.update(choices=general_choices, value=all_names, visible=True),
gr.update(choices=char_choices, value=char_names, visible=True),
gr.update(value=tags_box, visible=True),
gr.update(visible=True),
raw_caption,
esc_taglist,
) + control_outputs
except Exception as exc:
import traceback
traceback.print_exc()
err = t("tagger_error", lc).format(exc=html.escape(str(exc)))
return (
gr.update(value=f'<div style="color:#F87171;font-size:12px;">{err}</div>'),
gr.update(visible=False),
empty_chips, empty_chips,
gr.update(visible=False),
gr.update(visible=False),
"", "",
) + hidden_control
def _render_control_outputs(orig_pil, result, control_kind):
"""Build the (control image, download file) updates for the preview panel.
``orig_pil`` must be the EXIF-transposed input image so the overlay
matches the canvas the user uploaded. Returns two ``gr.update`` — both
hidden when the preview has nothing to render (e.g. skeleton kind with no
pose run).
"""
try:
from src.control_preview import render_control, save_control_png
pil_out = render_control(
orig_pil, result.get("keypoints") or [], control_kind or "skeleton_black",
depth_map=result.get("depth_map"), seg_map=result.get("segmentation_map"),
)
if pil_out is None:
return gr.update(visible=False), gr.update(visible=False)
path = save_control_png(pil_out)
return gr.update(value=pil_out, visible=True), gr.update(value=path, visible=True)
except Exception:
return gr.update(visible=False), gr.update(visible=False)
def on_tag_nl_only(image, fmt, lang, caption_kind="florence", progress=gr.Progress()):
"""Tagger mode "nl"/"qwen": caption only, no WD14 / pose / rating passes.
The caption is the sole output — it lands in the chips (single choice),
the tags box, the Apply/Copy pipeline and the raw/esc states, so the rest
of the Tagger UI keeps working unchanged. Character/control panels stay
hidden; empty or failed captions surface a warning instead.
"""
lc = "ru" if lang == "RU" else "en"
empty_chips = gr.update(choices=[], value=[], visible=False)
hidden_control = (gr.update(visible=False), gr.update(visible=False))
if image is None:
return (
gr.update(value=f'<div style="color:#F87171;font-size:12px;">{t("tagger_no_image", lc)}</div>'),
gr.update(visible=False),
empty_chips, empty_chips,
gr.update(visible=False),
gr.update(visible=False),
"", "",
) + hidden_control
try:
progress(0, desc=t("tagger_captioning", lc))
if not _TAGGER_DEPS_OK:
msg = f'<div style="color:#FBBF24;font-size:13px;line-height:1.5;">{t("tagger_unavailable", lc)}</div>'
return (
gr.update(value=msg),
gr.update(visible=False),
empty_chips, empty_chips,
gr.update(visible=False),
gr.update(visible=False),
"", "",
) + hidden_control
from src.vlm_caption import get_captioner
pil_img = ImageOps.exif_transpose(Image.fromarray(image))
caption = get_captioner().caption(pil_img, kind=caption_kind) or ""
progress(0.9, desc=t("tagger_results", lc))
if not caption:
html_out = f'<div style="color:#FBBF24;font-size:12px;">{t("tagger_caption_empty", lc)}</div>'
return (
gr.update(value=html_out),
gr.update(visible=False),
empty_chips, empty_chips,
gr.update(visible=False),
gr.update(visible=False),
"", "",
) + hidden_control
nl_result = {
"general": {}, "characters": {}, "ratings": {},
"pose_tags": [], "people_count": 0, "nl_caption": caption,
}
return (
gr.update(value=_build_tagger_html(nl_result, lc)),
gr.update(visible=True),
gr.update(choices=[(caption, caption)], value=[caption], visible=True),
empty_chips,
gr.update(value=caption, visible=True),
gr.update(visible=True),
caption,
caption,
) + hidden_control
except Exception as exc:
import traceback
traceback.print_exc()
err = t("tagger_error", lc).format(exc=html.escape(str(exc)))
return (
gr.update(value=f'<div style="color:#F87171;font-size:12px;">{err}</div>'),
gr.update(visible=False),
empty_chips, empty_chips,
gr.update(visible=False),
gr.update(visible=False),
"", "",
) + hidden_control
def _join_selected(chips, chars, fmt):
selected = (list(chips or []) + list(chars or []))
if fmt == "raw":
return ", ".join(to_booru_tag(t) for t in selected)
return ", ".join(to_prompt_tag(t) for t in selected)
def on_tagger_selection_change(chips, chars, fmt, raw_state, esc_state):
return _join_selected(chips, chars, fmt)
def on_tagger_format_change(fmt, chips, chars):
return _join_selected(chips, chars, fmt)
def on_tagger_apply_tags(current_prompt, tags):
if tags and isinstance(tags, str) and tags.strip():
if current_prompt and current_prompt.strip():
return f"{current_prompt.strip()}, {tags.strip()}"
return tags.strip()
return current_prompt if current_prompt else ""
HOTKEYS_JS = """
window.addEventListener('load', function() {
setTimeout(function() { window.dispatchEvent(new Event('resize')); }, 500);
setTimeout(function() { window.dispatchEvent(new Event('resize')); }, 1500);
});
document.addEventListener('keydown', function(e) {
const tag = document.activeElement ? document.activeElement.tagName.toLowerCase() : '';
const isTyping = tag === 'input' || tag === 'textarea' || document.activeElement.isContentEditable;
if (e.ctrlKey && e.shiftKey && e.key === 'Enter') {
e.preventDefault();
const btn = document.querySelector('.whyx-generate-btn button');
if (btn && !btn.disabled) btn.click();
return;
}
if (e.ctrlKey && e.shiftKey && (e.key === 'x' || e.key === 'X')) {
if (isTyping) {
e.preventDefault();
const ta = document.querySelector('textarea[data-testid="textbox"]');
if (ta) { ta.value = ''; ta.dispatchEvent(new Event('input', {bubbles:true})); }
}
return;
}
});
// Dropdown styling is done via CSS — no JS, zero flicker
// Slide-down panel toggle (collapse/expand inline panels)
(function() {
function togglePanel(triggerBtn) {
let panelId = triggerBtn.getAttribute('data-panel-target');
if (!panelId && triggerBtn.id && triggerBtn.id.startsWith('whyx-trig-')) {
panelId = 'whyx-panel-' + triggerBtn.id.slice('whyx-trig-'.length);
}
if (!panelId) return;
const panel = document.getElementById(panelId);
if (!panel) return;
const isOpen = panel.classList.contains('whyx-slide-open');
if (isOpen) {
panel.classList.remove('whyx-slide-open');
panel.classList.add('whyx-slide-closed');
triggerBtn.classList.remove('whyx-slide-active');
} else {
panel.classList.add('whyx-slide-open');
panel.classList.remove('whyx-slide-closed');
triggerBtn.classList.add('whyx-slide-active');
}
}
document.addEventListener('click', function(e) {
let el = e.target;
while (el && el !== document.body) {
if (el.classList && el.classList.contains('whyx-slide-trigger')) {
e.preventDefault();
togglePanel(el);
return;
}
el = el.parentElement;
}
});
})();
"""
WHYX_CSS = """
:root {
--c0: #7C3AED; --c1: #4F46E5; --c2: #2563EB; --c3: #10B981;
--accent: linear-gradient(135deg, #7C3AED, #4F46E5 38%, #2563EB 70%, #10B981);
--accent-text: linear-gradient(135deg, #7C3AED, #4F46E5 38%, #2563EB 70%, #10B981);
--accent-soft: linear-gradient(135deg, rgba(124,58,237,0.18), rgba(79,70,229,0.16) 38%, rgba(37,99,235,0.16) 70%, rgba(16,185,129,0.18));
--accent-glow: rgba(79,70,229,0.18); --accent-border: rgba(124,58,237,0.35);
--bg: #070A12; --surface: rgba(14,19,32,0.88); --surface-hover: rgba(20,28,46,0.92);
--glass: rgba(14,19,32,0.65); --glass-border: rgba(255,255,255,0.07); --glass-border-hover: rgba(255,255,255,0.14);
--text: #E2E8F0; --text-dim: rgba(148,163,184,0.95); --text-faint: rgba(148,163,184,0.55); --text-accent: #7DEBD6;
--input-bg: rgba(10,14,26,0.55); --input-border: rgba(255,255,255,0.08); --input-border-focus: rgba(79,70,229,0.5); --input-text: rgba(226,232,240,0.92);
--chip-bg: rgba(255,255,255,0.045); --chip-border: rgba(255,255,255,0.09); --chip-text: #CBD5E1;
--radius-sm: 6px; --radius-md: 10px; --radius-lg: 16px; --radius-xl: 20px; --radius-pill: 999px;
--shadow-sm: 0 1px 4px rgba(0,0,0,0.22); --shadow-md: 0 4px 16px rgba(0,0,0,0.28); --shadow-lg: 0 8px 32px rgba(0,0,0,0.35); --shadow-glow: 0 0 20px rgba(79,70,229,0.12); --shadow-soft: 0 2px 8px rgba(0,0,0,0.15), 0 8px 24px rgba(79,70,229,0.06);
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); --transition-fast: 0.12s ease;
}
body.whyx-light {
--bg: #EAEFF7; --surface: rgba(255,255,255,0.92); --surface-hover: rgba(255,255,255,0.96);
--glass: rgba(255,255,255,0.72); --glass-border: rgba(15,23,42,0.08); --glass-border-hover: rgba(15,23,42,0.15);
--text: #0F172A; --text-dim: rgba(71,85,105,0.95); --text-faint: rgba(100,116,139,0.65); --text-accent: #0EA5E9;
--input-bg: #FFFFFF; --input-border: rgba(15,23,42,0.14); --input-border-focus: rgba(14,165,233,0.5); --input-text: #0F172A;
--chip-bg: rgba(15,23,42,0.035); --chip-border: rgba(15,23,42,0.1); --chip-text: #334155;
--accent-glow: rgba(79,70,229,0.15); --accent-border: rgba(124,58,237,0.3);
}
body.whyx-light, body.whyx-light .gradio-container, body.whyx-light .dark {
--body-background-fill: #EAEFF7 !important; --body-background-fill-dark: #EAEFF7 !important;
--body-text-color: #0F172A !important; --body-text-color-dark: #0F172A !important;
--block-background-fill: #FFFFFF !important; --block-background-fill-dark: #FFFFFF !important;
--block-border-color: rgba(15,23,42,0.1) !important; --block-border-color-dark: rgba(15,23,42,0.1) !important;
--input-background-fill: #FFFFFF !important; --input-background-fill-dark: #FFFFFF !important;
--input-border-color: rgba(15,23,42,0.14) !important; --input-border-color-dark: rgba(15,23,42,0.14) !important;
--input-text-color: #0F172A !important; --input-text-color-dark: #0F172A !important;
--block-label-text-color: #475569 !important; --block-label-text-color-dark: #475569 !important;
--block-title-text-color: #1E293B !important; --block-title-text-color-dark: #1E293B !important;
--panel-background-fill: #FFFFFF !important; --panel-background-fill-dark: #FFFFFF !important;
--table-even-background-fill: #F1F5F9 !important; --table-even-background-fill-dark: #F1F5F9 !important;
--table-odd-background-fill: #FFFFFF !important; --table-odd-background-fill-dark: #FFFFFF !important;
--checkbox-background-color: #FFFFFF !important; --checkbox-background-color-dark: #FFFFFF !important;
--checkbox-border-color: rgba(15,23,42,0.18) !important; --checkbox-border-color-dark: rgba(15,23,42,0.18) !important;
--button-secondary-text-color: #1E293B !important; --button-secondary-text-color-dark: #1E293B !important;
--button-secondary-background-fill: transparent !important; --button-secondary-background-fill-hover: rgba(15,23,42,0.05) !important;
--button-secondary-border-color: rgba(15,23,42,0.16) !important;
}
/* --- Light theme component overrides --- */
body.whyx-light {
--tree-node-bg: rgba(255,255,255,0.92);
--tree-node-border: rgba(15,23,42,0.08);
--tree-node-border-hover: rgba(15,23,42,0.16);
--tree-branch-hover: rgba(79,70,229,0.06);
--tree-leaf-hover: rgba(241,245,249,0.9);
}
body.whyx-light::before { opacity: 0.45 !important; }
body.whyx-light [role="listbox"], body.whyx-light ul.options, body.whyx-light ul.dropdown-options, body.whyx-light [data-testid="dropdown-options"] {
background: rgba(255,255,255,0.98) !important;
border: 1px solid rgba(15,23,42,0.12) !important;
box-shadow: 0 8px 32px rgba(15,23,42,0.16), 0 0 0 1px rgba(79,70,229,0.05) !important;
}
body.whyx-light .gradio-dropdown { background: #FFFFFF !important; }
body.whyx-light .whyx-tagger-char-badge { background: linear-gradient(135deg, rgba(129,140,248,0.14), rgba(99,102,241,0.1)) !important; border-color: rgba(99,102,241,0.35) !important; color: #3730A3 !important; }
body.whyx-light .whyx-tagger-badge-pct { color: #6366F1 !important; }
body.whyx-light .progress-level { background: rgba(255,255,255,0.85) !important; border-color: rgba(124,58,237,0.18) !important; }
body.whyx-light .whyx-tagger-chips label:has(input:checked) { color: #0F766E !important; }
body.whyx-light .whyx-slide-trigger { background: linear-gradient(135deg, rgba(255,255,255,0.85), rgba(241,245,249,0.65)) !important; border-color: rgba(15,23,42,0.12) !important; }
body.whyx-light *::-webkit-scrollbar-track { background: rgba(15,23,42,0.05) !important; }
body.whyx-light *::-webkit-scrollbar-thumb { background: rgba(15,23,42,0.18) !important; }
body.whyx-light *::-webkit-scrollbar-thumb:hover { background: rgba(15,23,42,0.3) !important; }
@keyframes whyxBreath { 0%, 100% { opacity: 0.65; } 50% { opacity: 1; } }
@keyframes whyxFadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes progressShimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
@media (prefers-reduced-motion: reduce) { body::before { animation: none !important; opacity: 1 !important; } * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
html, body, #root, .gradio-container { background: var(--bg) !important; font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif !important; }
body::before { content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none; background: radial-gradient(1200px 620px at 10% -10%, var(--accent-glow), transparent 58%), radial-gradient(1000px 520px at 100% 0%, rgba(37,99,235,0.12), transparent 55%), radial-gradient(1100px 700px at 50% 120%, rgba(16,185,129,0.10), transparent 60%); animation: whyxBreath 14s ease-in-out infinite; }
:focus-visible { outline: 2px solid rgba(90,200,245,0.6) !important; outline-offset: 2px !important; }
*::-webkit-scrollbar { width: 5px; height: 5px; } *::-webkit-scrollbar-track { background: rgba(15,23,42,0.25); } *::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; } *::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.14); }
.whyx-title { background: linear-gradient(135deg, #7C3AED, #4F46E5 38%, #2563EB 70%, #10B981) !important; -webkit-background-clip: text !important; -webkit-text-fill-color: transparent !important; background-clip: text !important; color: transparent !important; display: inline-block !important; font-weight: 800 !important; font-size: 32px !important; letter-spacing: -0.6px !important; line-height: 1.2 !important; }
.whyx-subtitle { font-size: 12px !important; color: var(--text-dim) !important; margin-top: 2px !important; border-left: 2px solid rgba(110,231,210,0.45) !important; padding-left: 8px !important; }
.whyx-divider { height: 1px !important; background: linear-gradient(90deg, transparent, rgba(110,231,210,0.2), rgba(176,124,240,0.2), transparent) !important; margin: 10px 0 !important; border: none !important; }
.whyx-field { border: 1px solid var(--input-border) !important; border-radius: var(--radius-md) !important; box-shadow: var(--shadow-sm) !important; transition: border-color var(--transition), box-shadow var(--transition), transform var(--transition) !important; }
.whyx-field:hover { border-color: var(--accent-border) !important; box-shadow: 0 0 0 3px var(--accent-glow), var(--shadow-sm) !important; transform: translateY(-1px); }
.whyx-field textarea, .whyx-field input[type="text"] { border: 1px solid var(--input-border) !important; border-radius: var(--radius-md) !important; box-shadow: var(--shadow-sm) !important; transition: border-color var(--transition), box-shadow var(--transition) !important; }
.whyx-field textarea:hover, .whyx-field input[type="text"]:hover { border-color: var(--accent-border) !important; }
.whyx-field:focus-within { border-color: var(--input-border-focus) !important; box-shadow: 0 0 0 3px var(--accent-glow), var(--shadow-md) !important; }
#root input[type="text"], #root textarea, .gradio-container .wrap input, .gradio-dropdown input, .gradio-textbox textarea, .gradio-container textarea { border-radius: var(--radius-md) !important; transition: border-color var(--transition), box-shadow var(--transition), background var(--transition) !important; }
#root textarea:focus, #root input[type="text"]:focus, .gradio-dropdown input:focus { border-color: var(--input-border-focus) !important; box-shadow: 0 0 0 3px var(--accent-glow), var(--shadow-md) !important; }
.gradio-dropdown, .gradio-slider, .gradio-radio, .gradio-checkboxgroup, .gradio-accordion { border-radius: var(--radius-md) !important; }
input[type="radio"], input[type="checkbox"] { accent-color: #5AC8F5 !important; }
.whyx-radio-group label { background: var(--chip-bg) !important; border: 1px solid var(--chip-border) !important; border-radius: var(--radius-sm) !important; padding: 5px 12px !important; transition: all var(--transition-fast) !important; cursor: pointer !important; box-shadow: none !important; }
.whyx-radio-group label:hover { background: rgba(255,255,255,0.06) !important; border-color: var(--glass-border-hover) !important; }
.whyx-radio-group label.selected { background: var(--accent-glow) !important; border-color: var(--accent-border) !important; color: var(--text-accent) !important; }
input[type="range"]::-webkit-slider-runnable-track { height: 4px !important; background: var(--glass-border) !important; border-radius: 2px !important; border: none !important; }
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none !important; appearance: none !important; width: 16px !important; height: 16px !important; border-radius: 50% !important; background: linear-gradient(135deg, var(--c0), var(--c2)) !important; border: 2px solid var(--bg) !important; cursor: pointer !important; box-shadow: 0 1px 4px rgba(0,0,0,0.3) !important; margin-top: -6px !important; transition: all var(--transition-fast) !important; }
input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.15) !important; box-shadow: 0 2px 8px rgba(79,70,229,0.3) !important; }
input[type="range"]::-moz-range-track { height: 4px !important; background: rgba(255,255,255,0.1) !important; border-radius: 2px !important; border: none !important; }
input[type="range"]::-moz-range-thumb { width: 16px !important; height: 16px !important; border-radius: 50% !important; background: linear-gradient(135deg, var(--c0), var(--c2)) !important; border: 2px solid var(--bg) !important; cursor: pointer !important; box-shadow: 0 1px 4px rgba(0,0,0,0.3) !important; }
#root button { border-radius: var(--radius-pill) !important; box-shadow: var(--shadow-sm) !important; transition: transform var(--transition), box-shadow var(--transition), background var(--transition), filter var(--transition) !important; }
#root button:hover:not(:disabled) { transform: translateY(-2px) !important; box-shadow: 0 6px 20px rgba(79,70,229,0.18) !important; }
#root button:active:not(:disabled) { transform: translateY(0) scale(0.97) !important; box-shadow: var(--shadow-sm) !important; }
.whyx-generate-btn button { background: linear-gradient(135deg, var(--c0), var(--c1) 50%, var(--c2)) !important; background-image: none !important; border: none !important; color: #06121A !important; font-weight: 700 !important; letter-spacing: 0.3px !important; box-shadow: 0 4px 16px rgba(79,70,229,0.25) !important; text-shadow: 0 1px 2px rgba(255,255,255,0.15); }
.whyx-generate-btn button:hover:not(:disabled) { background: linear-gradient(135deg, #9D7CFA, #5B6BFA 50%, #4F8BFA) !important; box-shadow: 0 8px 28px rgba(79,70,229,0.32) !important; }
.whyx-copy-btn { min-width: 40px !important; width: 40px !important; height: 34px !important; border-radius: var(--radius-sm) !important; border: 1px solid var(--chip-border) !important; background: var(--chip-bg) !important; transition: all var(--transition-fast) !important; display: flex !important; align-items: center !important; justify-content: center !important; }
.whyx-copy-btn:hover { border-color: var(--accent-border) !important; background: var(--accent-glow) !important; }
.whyx-heart-btn { min-width: 34px !important; width: 34px !important; height: 34px !important; border-radius: var(--radius-sm) !important; border: 1px solid rgba(244,63,94,0.12) !important; background: var(--chip-bg) !important; transition: all var(--transition-fast) !important; display: flex !important; align-items: center !important; justify-content: center !important; font-size: 14px !important; color: #F43F5E !important; }
.whyx-heart-btn:hover { background: rgba(244,63,94,0.08) !important; border-color: rgba(244,63,94,0.25) !important; }
.whyx-cat-btn { flex: 1 !important; min-width: 0 !important; margin: 0 !important; }
.whyx-cat-btn label { display: flex !important; align-items: center !important; justify-content: center !important; gap: 4px !important; background: var(--chip-bg) !important; border: 1px solid var(--chip-border) !important; border-radius: var(--radius-pill) !important; padding: 8px 12px !important; cursor: pointer !important; transition: all var(--transition-fast) !important; font-size: 11px !important; font-weight: 500 !important; color: var(--text-dim) !important; text-align: center !important; user-select: none !important; min-height: 34px !important; height: auto !important; width: 100% !important; box-shadow: var(--shadow-sm) !important; }
.whyx-cat-btn input[type="checkbox"] { display: none !important; width: 0 !important; height: 0 !important; margin: 0 !important; padding: 0 !important; opacity: 0 !important; position: absolute !important; pointer-events: none !important; }
.whyx-cat-btn label > span:first-child { display: none !important; }
.whyx-cat-btn label:hover { background: rgba(255,255,255,0.06) !important; border-color: var(--glass-border-hover) !important; }
.whyx-cat-btn:has(input:checked) label { background: var(--accent-glow) !important; border-color: var(--accent-border) !important; color: var(--text-accent) !important; box-shadow: 0 0 14px rgba(79,70,229,0.25) !important; }
.whyx-cat-btn:has(input:checked) label:hover { border-color: rgba(110,231,210,0.55) !important; }
.whyx-preset-group-label { font-size: 12px !important; font-weight: 600 !important; color: var(--text) !important; margin-top: 10px !important; margin-bottom: 6px !important; }
.whyx-preset-toggle .gr-block { display: flex !important; flex-wrap: wrap !important; gap: 6px !important; }
.whyx-preset-toggle label { display: inline-flex !important; align-items: center !important; border: 1px solid var(--chip-border) !important; background: var(--chip-bg) !important; border-radius: var(--radius-pill) !important; padding: 5px 14px !important; font-size: 11px !important; cursor: pointer !important; transition: all var(--transition-fast) !important; user-select: none !important; white-space: nowrap !important; }
.whyx-preset-toggle label:hover { background: rgba(255,255,255,0.06) !important; border-color: var(--glass-border-hover) !important; }
.whyx-preset-toggle input[type="checkbox"]:checked + span { color: var(--text-accent) !important; }
.whyx-preset-toggle label:has(input:checked) { border-color: var(--accent-border) !important; background: var(--accent-glow) !important; box-shadow: 0 0 10px rgba(79,70,229,0.18) !important; }
.whyx-result-card { background: var(--surface) !important; border: 1px solid var(--glass-border) !important; border-radius: var(--radius-lg) !important; padding: 10px 14px !important; margin: 5px 0 !important; transition: all var(--transition) !important; box-shadow: var(--shadow-sm) !important; animation: whyxFadeIn 0.3s ease both !important; }
.whyx-result-card:hover { border-color: var(--glass-border-hover) !important; box-shadow: var(--shadow-md), var(--shadow-glow) !important; }
.whyx-result-card.empty { opacity: 0.5 !important; border-style: dashed !important; }
.whyx-result-num { background: var(--chip-bg) !important; color: var(--text-accent) !important; font-weight: 600 !important; font-size: 11px !important; padding: 4px 8px !important; border-radius: var(--radius-sm) !important; text-align: center !important; min-width: 28px !important; border: 1px solid var(--chip-border) !important; }
.whyx-result-text { background: transparent !important; border: none !important; font-family: 'JetBrains Mono', ui-monospace, monospace !important; font-size: 12px !important; line-height: 1.55 !important; color: var(--text) !important; padding: 4px 0 !important; }
.whyx-result-text::placeholder { color: var(--text-faint) !important; font-style: italic !important; font-family: 'Inter', ui-sans-serif, system-ui, sans-serif !important; }
.whyx-neg-text { background: transparent !important; border: none !important; font-family: 'JetBrains Mono', ui-monospace, monospace !important; font-size: 11px !important; line-height: 1.45 !important; color: var(--text-dim) !important; padding: 4px 0 !important; }
.tabitem { border-color: var(--glass-border) !important; border-radius: 0 var(--radius-lg) var(--radius-lg) var(--radius-lg) !important; }
.tabs { border-bottom: 1px solid var(--glass-border) !important; }
.tabs button { border-radius: 0 !important; border: none !important; border-bottom: 2px solid transparent !important; background: transparent !important; transition: all var(--transition-fast) !important; font-weight: 500 !important; font-size: 12px !important; }
.tabs button:hover { background: var(--chip-bg) !important; }
.tabs button.selected { background: transparent !important; color: var(--text-accent) !important; border-bottom: 2px solid transparent !important; box-shadow: inset 0 -2px 0 rgba(110,231,210,0.65) !important; }
.whyx-lang-btn { gap: 0 !important; justify-content: flex-end !important; margin-top: 0 !important; min-width: 100px !important; }
.whyx-lang-btn label { border: 1px solid var(--chip-border) !important; background: var(--chip-bg) !important; border-radius: var(--radius-sm) !important; padding: 3px 10px !important; font-size: 11px !important; font-weight: 600 !important; line-height: 1 !important; min-height: 0 !important; margin: 0 !important; transition: all var(--transition-fast) !important; cursor: pointer !important; }
.whyx-lang-btn label:hover { background: var(--accent-glow) !important; border-color: rgba(124,58,237,0.25) !important; }
.whyx-lang-btn label.selected { background: var(--accent-glow) !important; border-color: var(--accent-border) !important; color: var(--text-accent) !important; }
.whyx-theme-btn { gap: 0 !important; justify-content: center !important; margin-top: 0 !important; min-width: 110px !important; }
.whyx-theme-btn label { border: 1px solid var(--chip-border) !important; background: var(--chip-bg) !important; border-radius: var(--radius-sm) !important; padding: 3px 8px !important; font-size: 11px !important; font-weight: 600 !important; line-height: 1 !important; min-height: 0 !important; margin: 0 !important; transition: all var(--transition-fast) !important; cursor: pointer !important; }
.whyx-theme-btn label:hover { background: var(--accent-glow) !important; border-color: rgba(124,58,237,0.25) !important; }
.whyx-theme-btn label.selected { background: var(--accent-glow) !important; border-color: var(--accent-border) !important; color: var(--text-accent) !important; }
.whyx-info-text, .gradio-container .info { color: var(--text-dim) !important; font-size: 11px !important; line-height: 1.45 !important; border-left: 2px solid rgba(110,231,210,0.35) !important; background: var(--chip-bg) !important; padding: 6px 10px !important; border-radius: 0 var(--radius-sm) var(--radius-sm) 0 !important; margin-top: 4px !important; }
.whyx-tooltip { position: relative !important; cursor: pointer !important; }
.whyx-tooltip:hover .whyx-tooltip-text, .whyx-tooltip:focus-within .whyx-tooltip-text { visibility: visible !important; opacity: 1 !important; }
.whyx-tooltip-text { visibility: hidden !important; opacity: 0 !important; position: absolute !important; z-index: 9999 !important; padding: 4px 8px !important; background: var(--surface) !important; color: var(--text) !important; font-size: 10px !important; font-weight: 600 !important; border-radius: var(--radius-sm) !important; border: 1px solid var(--glass-border) !important; white-space: nowrap !important; pointer-events: none !important; transition: opacity 0.15s ease !important; box-shadow: var(--shadow-md) !important; border-left: 2px solid rgba(110,231,210,0.35) !important; bottom: 110% !important; left: 50% !important; transform: translateX(-50%) !important; }
.whyx-tooltip-text::after { content: "" !important; position: absolute !important; top: 100% !important; left: 50% !important; margin-left: -4px !important; border-width: 4px !important; border-style: solid !important; border-color: var(--surface) transparent transparent transparent !important; }
.whyx-tagger-image .upload, .whyx-tagger-image { border-radius: var(--radius-lg) !important; overflow: hidden !important; border: 1px solid rgba(90,200,245,0.14) !important; box-shadow: var(--shadow-md) !important; }
.whyx-tagger-run-btn button { background: var(--accent) !important; background-image: none !important; color: #06121A !important; font-weight: 700 !important; letter-spacing: 0.3px !important; border: none !important; box-shadow: 0 4px 16px rgba(79,70,229,0.22) !important; }
.whyx-tagger-run-btn button:hover:not(:disabled) { background: linear-gradient(135deg, #9D7CFA, #5B6BFA, #3B8BE0) !important; box-shadow: 0 8px 24px rgba(37,99,235,0.3) !important; }
.whyx-tagger-apply-btn button { background: var(--accent) !important; border: none !important; color: #06121A !important; font-weight: 700 !important; font-size: 13px !important; padding: 8px 20px !important; border-radius: var(--radius-pill) !important; transition: all var(--transition) !important; }
.whyx-tagger-apply-btn button:hover { background: linear-gradient(135deg, #9D7CFA, #5B6BFA, #3B8BE0) !important; box-shadow: 0 6px 20px rgba(79,70,229,0.35) !important; transform: translateY(-1px) !important; }
.whyx-tagger-copy-btn button { background: var(--chip-bg) !important; border: 1px solid var(--chip-border) !important; color: var(--chip-text) !important; font-weight: 600 !important; box-shadow: none !important; }
.whyx-tagger-copy-btn button:hover:not(:disabled) { background: var(--accent-glow) !important; border-color: var(--accent-border) !important; color: var(--text-accent) !important; box-shadow: 0 4px 14px rgba(79,70,229,0.15) !important; }
.whyx-tagger-tags-box textarea { font-family: 'JetBrains Mono', ui-monospace, monospace !important; font-size: 12px !important; line-height: 1.5 !important; color: var(--input-text) !important; background: var(--input-bg) !important; border-radius: var(--radius-md) !important; }
.whyx-tagger-chips .wrap, .whyx-tagger-chips .grid { display: flex !important; flex-wrap: wrap !important; gap: 6px !important; max-height: 260px !important; overflow-y: auto !important; padding: 4px 2px 8px !important; }
.whyx-tagger-chips label { display: inline-flex !important; align-items: center !important; gap: 6px !important; border: 1px solid var(--chip-border) !important; background: var(--chip-bg) !important; border-radius: var(--radius-pill) !important; padding: 5px 12px !important; font-size: 12px !important; font-weight: 500 !important; color: var(--chip-text) !important; cursor: pointer !important; transition: all var(--transition-fast) !important; user-select: none !important; }
.whyx-tagger-chips label:hover { background: var(--accent-glow) !important; border-color: var(--accent-border) !important; transform: translateY(-1px) !important; }
.whyx-tagger-chips input[type="checkbox"] { width: 14px !important; height: 14px !important; accent-color: #5AC8F5 !important; margin: 0 2px 0 0 !important; }
.whyx-tagger-chips label:has(input:checked) { background: var(--accent-soft) !important; border-color: rgba(110,231,210,0.55) !important; color: #E6FBF5 !important; box-shadow: 0 0 10px rgba(79,70,229,0.18) !important; }
.whyx-tagger-panel { background: var(--input-bg) !important; border: 1px solid rgba(79,70,229,0.1) !important; border-radius: var(--radius-md) !important; padding: 12px 16px !important; margin-top: 4px !important; animation: whyxFadeIn 0.3s ease both !important; }
.whyx-tagger-panel-title { font-size: 12px !important; font-weight: 600 !important; color: var(--text) !important; margin-bottom: 6px !important; }
.whyx-tagger-rating-row { font-size: 11px !important; color: var(--text-dim) !important; margin-bottom: 4px !important; display: flex !important; align-items: center !important; gap: 8px !important; }
.whyx-tagger-rating-name { width: 92px !important; display: inline-block !important; font-weight: 600 !important; flex-shrink: 0 !important; }
.whyx-tagger-rating-track { flex: 1 !important; height: 6px !important; background: var(--chip-bg) !important; border: 1px solid var(--chip-border) !important; border-radius: var(--radius-pill) !important; overflow: hidden !important; }
.whyx-tagger-rating-fill { display: block !important; height: 100% !important; border-radius: var(--radius-pill) !important; transition: width 0.4s ease !important; }
.whyx-tagger-rating-pct { color: var(--text-faint) !important; flex-shrink: 0 !important; }
.whyx-tagger-badges { display: flex !important; flex-wrap: wrap !important; gap: 6px !important; }
.whyx-tagger-char-badge { display: inline-block !important; background: linear-gradient(135deg, rgba(129,140,248,0.18), rgba(99,102,241,0.14)) !important; border: 1px solid rgba(129,140,248,0.3) !important; border-radius: var(--radius-pill) !important; padding: 3px 10px !important; font-size: 12px !important; color: #E0E7FF !important; }
.whyx-tagger-badge-pct { color: #A5B4FC !important; font-size: 10px !important; margin-left: 2px !important; }
.whyx-tagger-gen-list { display: flex !important; flex-direction: column !important; gap: 1px !important; }
.whyx-tagger-gen-row { display: flex !important; align-items: center !important; gap: 8px !important; font-size: 11px !important; color: var(--text-dim) !important; white-space: nowrap !important; }
.whyx-tagger-gen-track { width: 90px !important; height: 5px !important; background: var(--chip-bg) !important; border: 1px solid var(--chip-border) !important; border-radius: var(--radius-pill) !important; overflow: hidden !important; flex-shrink: 0 !important; }
.whyx-tagger-gen-fill { display: block !important; height: 100% !important; background: linear-gradient(90deg, #5AC8F5, #38BDF8) !important; border-radius: var(--radius-pill) !important; transition: width 0.4s ease !important; }
.whyx-tagger-gen-name { overflow: hidden !important; text-overflow: ellipsis !important; }
.whyx-tagger-pose-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(79,70,229,0.1) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
.whyx-tagger-pose-people { font-size: 11px !important; color: var(--text) !important; font-weight: 600 !important; }
.whyx-tagger-nl-caption { font-size: 12px !important; color: var(--text) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(79,70,229,0.1) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; }
.whyx-tagger-anatomy-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(79,70,229,0.1) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
.whyx-tagger-expr-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(79,70,229,0.1) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
.whyx-tagger-bg-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(79,70,229,0.1) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
.whyx-tagger-seg-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(16,185,129,0.16) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
.whyx-tagger-depth-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(37,99,235,0.18) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
.whyx-tagger-emotion-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(244,63,94,0.18) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
.whyx-tagger-lighting-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(251,191,36,0.18) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
.whyx-tagger-style-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(168,85,247,0.18) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
.whyx-tagger-concept-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(59,130,246,0.18) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
.whyx-tagger-palette-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(16,185,129,0.18) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
.whyx-tagger-control-img { border-radius: var(--radius-md) !important; overflow: hidden !important; border: 1px solid var(--glass-border) !important; }
.whyx-tagger-control-img img { border-radius: var(--radius-md) !important; }
.whyx-suggest-box { background: var(--input-bg) !important; border: 1px solid rgba(124,58,237,0.14) !important; border-radius: var(--radius-md) !important; padding: 10px 14px !important; margin-top: 6px !important; animation: whyxFadeIn 0.3s ease both !important; }
.whyx-suggest-title { font-size: 12px !important; font-weight: 600 !important; color: var(--text) !important; margin-bottom: 6px !important; }
.whyx-suggest-box ul { margin: 0 !important; padding-left: 18px !important; }
.whyx-suggest-item { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.5 !important; padding: 1px 0 !important; }
.whyx-chip-group { display: flex !important; flex-wrap: wrap !important; gap: 6px !important; }
.whyx-chip-group label { display: inline-flex !important; align-items: center !important; border: 1px solid var(--chip-border) !important; background: var(--chip-bg) !important; border-radius: var(--radius-pill) !important; padding: 5px 12px !important; font-size: 11px !important; cursor: pointer !important; transition: all var(--transition-fast) !important; user-select: none !important; }
.whyx-chip-group label:hover { background: var(--accent-glow) !important; border-color: var(--accent-border) !important; }
.whyx-chip-group label:has(input:checked) { background: var(--accent-glow) !important; border-color: var(--accent-border) !important; color: var(--text-accent) !important; }
.whyx-checkbox label { font-size: 12px !important; color: var(--text-dim) !important; }
.whyx-strip-row { gap: 10px !important; flex-wrap: wrap !important; }
.whyx-strip-row .gradio-checkbox { min-width: 0 !important; }
.progress-level .progress-bar { background: var(--accent) !important; background-size: 200% auto !important; animation: progressShimmer 2s linear infinite !important; }
.progress-level .progress-text { color: var(--text) !important; font-size: 12px !important; }
.progress-level { background: rgba(15,23,42,0.5) !important; border: 1px solid rgba(124,58,237,0.12) !important; border-radius: var(--radius-md) !important; padding: 8px 12px !important; }
.gradio-dropdown, .gradio-dropdown > div, .tabs, .tabitem, [data-testid="tab-item"] { overflow: visible !important; }
/* --- Dropdown menu styling — opaque panel, themed --- */
[role="listbox"], ul.options, ul.dropdown-options, [data-testid="dropdown-options"] {
z-index: 999999 !important;
background: rgba(10,14,26,0.98) !important;
border: 1px solid rgba(79,70,229,0.15) !important;
border-radius: 10px !important;
box-shadow: 0 8px 32px rgba(0,0,0,0.6), 0 0 0 1px rgba(79,70,229,0.06) !important;
padding: 4px 0 !important;
max-height: 320px !important;
overflow-y: auto !important;
animation: whyxDropdownIn 0.12s ease-out !important;
transform-origin: top center !important;
}
@keyframes whyxDropdownIn {
from { opacity: 0; transform: scaleY(0.92) translateY(-4px); }
to { opacity: 1; transform: scaleY(1) translateY(0); }
}
/* Custom scrollbar for dropdown */
[role="listbox"]::-webkit-scrollbar,
ul.options::-webkit-scrollbar { width: 4px !important; }
[role="listbox"]::-webkit-scrollbar-track,
ul.options::-webkit-scrollbar-track { background: transparent !important; }
[role="listbox"]::-webkit-scrollbar-thumb,
ul.options::-webkit-scrollbar-thumb { background: rgba(79,70,229,0.25) !important; border-radius: 2px !important; }
[role="listbox"]::-webkit-scrollbar-thumb:hover,
ul.options::-webkit-scrollbar-thumb:hover { background: rgba(79,70,229,0.4) !important; }
/* Dropdown items */
[role="option"], .gradio-dropdown li, .gradio-dropdown .item {
padding: 8px 14px !important;
font-size: 13px !important;
color: var(--text) !important;
cursor: pointer !important;
transition: background 0.1s ease, color 0.1s ease !important;
border-left: 2px solid transparent !important;
}
[role="option"]:hover, [role="option"].active,
.gradio-dropdown li:hover, .gradio-dropdown .item:hover, .gradio-dropdown .active {
background: linear-gradient(90deg, rgba(79,70,229,0.1), transparent) !important;
color: var(--text-accent) !important;
border-left-color: rgba(79,70,229,0.4) !important;
}
/* Dropdown trigger (the visible field) */
.gradio-dropdown {
background: rgba(10,14,26,0.85) !important;
border: 1px solid var(--input-border) !important;
box-shadow: var(--shadow-sm) !important;
transition: border-color var(--transition), box-shadow var(--transition) !important;
}
.gradio-dropdown:hover {
border-color: rgba(79,70,229,0.25) !important;
}
.gradio-dropdown:focus-within,
.gradio-dropdown:focus {
border-color: var(--input-border-focus) !important;
box-shadow: 0 0 0 3px var(--accent-glow), var(--shadow-md) !important;
}
/* Multi-select token chips inside dropdown */
.gradio-dropdown .token {
background: var(--chip-bg) !important;
border: 1px solid var(--chip-border) !important;
border-radius: var(--radius-pill) !important;
color: var(--chip-text) !important;
font-size: 11px !important;
padding: 2px 8px !important;
}
.whyx-artist-preview { background: var(--surface) !important; border: 1px solid var(--glass-border) !important; border-radius: var(--radius-md) !important; padding: 12px 14px !important; margin-top: 12px !important; box-shadow: var(--shadow-sm) !important; }
.whyx-artist-preview-title { color: var(--text) !important; background: none !important; -webkit-background-clip: unset !important; -webkit-text-fill-color: unset !important; background-clip: unset !important; font-weight: 600 !important; font-size: 13px !important; margin-bottom: 6px !important; }
.whyx-artist-preview-empty { color: var(--text-dim) !important; font-size: 12px !important; font-style: italic !important; }
.whyx-artist-link { display: inline-flex !important; align-items: center !important; gap: 4px !important; border: 1px solid var(--chip-border) !important; border-radius: var(--radius-sm) !important; padding: 4px 10px !important; background: var(--chip-bg) !important; color: var(--chip-text) !important; text-decoration: none !important; font-size: 11px !important; font-weight: 600 !important; transition: all var(--transition-fast) !important; }
.whyx-artist-link:hover { background: var(--accent-glow) !important; border-color: rgba(124,58,237,0.25) !important; box-shadow: 0 2px 8px rgba(79,70,229,0.08) !important; color: var(--text-accent) !important; }
@media (max-width: 767px) {
.whyx-title { font-size: 24px !important; } .whyx-section-title { font-size: 11px !important; }
#root .gradio-row { flex-direction: column !important; } #root .gradio-column { width: 100% !important; min-width: 0 !important; }
.whyx-generate-btn button { font-size: 14px !important; padding: 12px 20px !important; min-height: 48px !important; }
.whyx-cat-row { flex-direction: row !important; flex-wrap: wrap !important; gap: 6px !important; }
.whyx-cat-btn { flex: 0 0 calc(33.33% - 5px) !important; min-width: 0 !important; }
.whyx-cat-btn label { font-size: 9px !important; padding: 6px 2px !important; min-height: 34px !important; white-space: normal !important; line-height: 1.1 !important; }
.whyx-cat-btn .info, .whyx-cat-btn [class*="info"] { display: none !important; }
.whyx-cat-btn label > span:first-child { display: none !important; }
.whyx-copy-btn { min-width: 44px !important; width: 44px !important; height: 38px !important; }
.whyx-result-text { font-size: 11px !important; }
.tabs { flex-wrap: nowrap !important; overflow-x: auto !important; scrollbar-width: none !important; }
.tabs::-webkit-scrollbar { display: none !important; }
.tabs button { flex-shrink: 0 !important; font-size: 11px !important; padding: 6px 10px !important; }
.whyx-tooltip-text { bottom: auto !important; top: 110% !important; }
.whyx-tooltip-text::after { top: -8px !important; border-color: transparent transparent var(--surface) transparent !important; }
/* Result cards: compact wrapped layout instead of a tall vertical stack */
#root .whyx-result-card { flex-direction: row !important; flex-wrap: wrap !important; align-items: flex-start !important; gap: 6px !important; }
#root .whyx-result-card > .whyx-rc-num { width: auto !important; flex: 0 0 auto !important; }
#root .whyx-result-card > .whyx-rc-pos { width: auto !important; flex: 1 1 60% !important; min-width: 0 !important; }
#root .whyx-result-card > .whyx-rc-copypos,
#root .whyx-result-card > .whyx-rc-heart { width: auto !important; flex: 0 0 auto !important; }
#root .whyx-result-card > .whyx-rc-neg { width: auto !important; flex: 1 1 70% !important; min-width: 0 !important; }
#root .whyx-result-card > .whyx-rc-copyneg { width: auto !important; flex: 0 0 auto !important; }
}
@media (min-width: 768px) and (max-width: 1023px) {
.whyx-title { font-size: 26px !important; }
.whyx-cat-row { flex-direction: row !important; flex-wrap: wrap !important; gap: 8px !important; }
.whyx-cat-btn { flex: 0 0 calc(20% - 7px) !important; min-width: 0 !important; }
.whyx-cat-btn label { font-size: 10px !important; padding: 6px 3px !important; min-height: 36px !important; white-space: normal !important; line-height: 1.15 !important; }
.whyx-cat-btn .info, .whyx-cat-btn [class*="info"] { display: none !important; }
.whyx-cat-btn label > span:first-child { display: none !important; }
}
/* ===== Tree View UI ===== */
:root {
--tree-node-bg: rgba(14,19,32,0.88);
--tree-node-border: rgba(255,255,255,0.07);
--tree-node-border-hover: rgba(255,255,255,0.14);
--tree-connector: rgba(110,231,210,0.15);
--tree-connector-hover: rgba(110,231,210,0.35);
--tree-branch-hover: rgba(79,70,229,0.08);
--tree-leaf-hover: rgba(14,19,32,0.92);
--tree-indent: 16px;
--tree-transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.whyx-tree {
display: flex !important;
flex-direction: column !important;
gap: 8px !important;
width: 100% !important;
}
.whyx-tree-node {
background: var(--tree-node-bg) !important;
border: 1px solid var(--tree-node-border) !important;
border-radius: var(--radius-md) !important;
box-shadow: var(--shadow-sm) !important;
transition: border-color var(--tree-transition), box-shadow var(--tree-transition), transform var(--tree-transition) !important;
overflow: hidden !important;
position: relative !important;
}
.whyx-tree-node:hover {
border-color: rgba(124,58,237,0.22) !important;
box-shadow: 0 6px 24px rgba(0,0,0,0.25), 0 0 0 1px rgba(124,58,237,0.08), 0 0 24px rgba(124,58,237,0.05) !important;
transform: translateY(-1px) !important;
}
.whyx-tree-branch {
border-radius: var(--radius-md) !important;
}
.whyx-tree-branch > .gradio-accordion label[class*="accordion"],
.whyx-tree-branch > .gradio-accordion button,
.whyx-tree-branch > .gradio-accordion summary {
background: var(--tree-node-bg) !important;
border-bottom: 1px solid var(--tree-node-border) !important;
color: var(--text) !important;
font-weight: 600 !important;
font-size: 12px !important;
padding: 10px 14px !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
gap: 6px !important;
transition: background var(--tree-transition), border-color var(--tree-transition) !important;
}
.whyx-tree-branch > .gradio-accordion label[class*="accordion"]:hover,
.whyx-tree-branch > .gradio-accordion button:hover,
.whyx-tree-branch > .gradio-accordion summary:hover {
background: var(--tree-branch-hover) !important;
border-color: var(--tree-connector-hover) !important;
}
.whyx-tree-branch > .gradio-accordion > div:not(label) {
padding: 10px 14px !important;
animation: whyxFadeIn 0.25s ease both !important;
}
.whyx-tree-branch > .gradio-accordion[open] > div:not(label) {
animation: whyxFadeInDown 0.25s ease both !important;
}
@keyframes whyxFadeInDown {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Tree node header (replaces _section for tree) */
.whyx-tree-header {
display: flex !important;
align-items: center !important;
gap: 10px !important;
padding: 10px 14px !important;
border-bottom: 1px solid var(--tree-node-border) !important;
animation: whyxFadeIn 0.3s ease both !important;
}
.whyx-tree-header-icon {
font-size: 18px !important;
line-height: 1.4 !important;
flex-shrink: 0 !important;
}
.whyx-tree-header-title {
color: var(--text) !important;
font-weight: 700 !important;
font-size: 11px !important;
letter-spacing: 1px !important;
text-transform: uppercase !important;
line-height: 1.4 !important;
}
.whyx-tree-header-desc {
color: var(--text-dim) !important;
font-size: 11px !important;
line-height: 1.45 !important;
margin-top: 2px !important;
max-width: 640px !important;
}
/* Tree branch expand/collapse icon */
.whyx-tree-branch > .gradio-accordion label[class*="accordion"]::before,
.whyx-tree-branch > .gradio-accordion button::before,
.whyx-tree-branch > .gradio-accordion summary::before {
content: "▸" !important;
display: inline-block !important;
transition: transform var(--tree-transition) !important;
font-size: 11px !important;
margin-right: 4px !important;
color: var(--text-accent) !important;
}
.whyx-tree-branch > .gradio-accordion[open] > label[class*="accordion"]::before,
.whyx-tree-branch > .gradio-accordion[open] > button::before,
.whyx-tree-branch > .gradio-accordion[open] > summary::before {
transform: rotate(90deg) !important;
}
/* === Slide-down panels (inline expand/collapse — DOM persists, animates max-height) === */
.whyx-slide-panel {
overflow: hidden !important;
max-height: 0 !important;
opacity: 0 !important;
transform: translateY(-4px) !important;
padding: 0 0 0 14px !important;
margin: 0 !important;
border-left: 2px solid rgba(124,58,237,0.12) !important;
transition: max-height .25s ease-out, opacity .2s ease-out .05s, transform .25s ease-out, padding .2s, margin .2s !important;
background: linear-gradient(180deg, rgba(124,58,237,0.05), rgba(79,70,229,0.02) 60%, transparent) !important;
border-radius: 0 8px 8px 0 !important;
}
.whyx-slide-open {
max-height: 2200px !important;
opacity: 1 !important;
transform: translateY(0) !important;
padding: 4px 0 8px 14px !important;
margin-top: 4px !important;
}
/* Soft trigger button — pill, glass surface, glow on hover */
.whyx-slide-trigger {
display: inline-flex !important;
align-items: center !important;
gap: 6px !important;
padding: 7px 16px !important;
border: 1px solid rgba(255,255,255,0.08) !important;
background: linear-gradient(135deg, rgba(20,28,46,0.6), rgba(15,23,42,0.4)) !important;
border-radius: 999px !important;
color: var(--text-dim, #94A3B8) !important;
font-size: 12px !important;
font-weight: 600 !important;
cursor: pointer !important;
user-select: none !important;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(0,0,0,0.15)), inset 0 1px 0 rgba(255,255,255,0.03) !important;
transition: all var(--transition-fast, 0.15s) !important;
position: relative !important;
}
.whyx-slide-trigger:hover {
border-color: rgba(124,58,237,0.35) !important;
color: var(--text, #E2E8F0) !important;
box-shadow: 0 4px 18px rgba(124,58,237,0.15), inset 0 1px 0 rgba(255,255,255,0.05) !important;
transform: translateY(-1px) !important;
}
.whyx-slide-trigger.whyx-slide-active {
color: var(--text, #E2E8F0) !important;
border-color: rgba(124,58,237,0.3) !important;
background: linear-gradient(135deg, rgba(124,58,237,0.12), rgba(79,70,229,0.08)) !important;
box-shadow: 0 4px 16px rgba(124,58,237,0.18) !important;
}
/* Soft glow accent ring for tree nodes */
.whyx-tree-node {
box-shadow: var(--shadow-soft, 0 2px 8px rgba(0,0,0,0.15), 0 8px 24px rgba(79,70,229,0.06)), inset 0 1px 0 rgba(255,255,255,0.03) !important;
}
"""
def _tree_header(icon: str, title_key: str, lang: str = "en", desc_key: str | None = None) -> str:
desc = ""
if desc_key:
desc = f'<div class="whyx-tree-header-desc">{t(desc_key, lang)}</div>'
return f"""
<div class="whyx-tree-header">
<span class="whyx-tree-header-icon">{icon}</span>
<div>
<span class="whyx-tree-header-title">{t(title_key, lang)}</span>
{desc}
</div>
</div>
"""
def _user_preset_choices(lang: str = "en"):
names = get_user_preset_names()
return [(t("user_preset_pick", lang), "")] + [(n, n) for n in names]
_blocks_kw = {"title": "Whyx-PROmpTea"}
if _GRADIO_MAJOR < 6:
_blocks_kw["theme"] = WhyxTheme()
_blocks_kw["css"] = WHYX_CSS
_blocks_kw["head"] = f"<script>{HOTKEYS_JS}</script>"
with gr.Blocks(**_blocks_kw) as demo:
lang_state = gr.State("EN")
with gr.Row():
with gr.Column(scale=1, min_width=0):
title_html = gr.HTML(f"""
<div>
<div class="whyx-title">Whyx-PROmpTea</div>
<div class="whyx-subtitle">{t("app_subtitle", "en")}</div>
</div>
""")
with gr.Column(scale=0, min_width=230):
with gr.Row(equal_height=True):
theme_toggle = gr.Radio(
choices=[("🌙 Dark", "dark"), ("☀️ Light", "light")],
value="dark",
label="",
show_label=False,
interactive=True,
container=False,
elem_classes=["whyx-theme-btn", "whyx-radio-group"],
)
lang_btn = gr.Radio(
choices=["EN", "RU"],
value="EN",
label="",
show_label=False,
interactive=True,
container=False,
elem_classes=["whyx-lang-btn", "whyx-radio-group"],
)
gr.HTML('<hr class="whyx-divider">')
current_preset_state = gr.State("")
with gr.Tabs():
# ===== TAB 1: Generate =====
tab_generate = gr.TabItem(f"✨ {t('tab_generate', 'en')}")
with tab_generate:
with gr.Column(elem_classes=["whyx-tree"]):
# Tree root: Prompt
with gr.Column(elem_classes=["whyx-tree-node"]):
prompt_input = gr.Textbox(
label="",
placeholder=t("input_placeholder", "en"),
lines=5,
max_lines=10,
elem_classes=["whyx-field"],
)
# Tree branch: Model & Rating (slide-down)
with gr.Column(elem_classes=["whyx-tree-node"]):
gr.HTML(_tree_header("⚙️", "model_label", "en", "model_desc"))
with gr.Row():
model_trigger = gr.Button(
f'▸ {t("model_label", "en")}',
elem_classes=["whyx-slide-trigger"], elem_id="whyx-trig-model",
)
rating_trigger = gr.Button(
f'▸ {t("rating_label", "en")}',
elem_classes=["whyx-slide-trigger"], elem_id="whyx-trig-rating",
)
model_dropdown = gr.Radio(
choices=[(t("anima", "en"), "anima"), (t("illustrious", "en"), "illustrious")],
value="anima", label="", show_label=False,
elem_id="whyx-panel-model",
elem_classes=["whyx-slide-panel", "whyx-slide-closed", "whyx-chip-group"],
)
rating_dropdown = gr.Radio(
choices=[
(t("rating_pg", "en"), "pg"),
(t("rating_pg13", "en"), "pg13"),
(t("rating_pg16", "en"), "pg16"),
(t("rating_r", "en"), "r"),
(t("rating_rplus", "en"), "r+"),
],
value="pg", label="", show_label=False,
elem_id="whyx-panel-rating",
elem_classes=["whyx-slide-panel", "whyx-slide-closed", "whyx-chip-group"],
)
# Tree branch: Variations
with gr.Accordion(f"🔢 {t('variations_label', 'en')}", open=True, elem_classes=["whyx-tree-branch"]):
with gr.Row():
num_variations = gr.Slider(
minimum=1,
maximum=10,
value=5,
step=1,
label=t("variations_label", "en"),
info=t("variations_desc", "en"),
elem_classes=["whyx-field"],
)
seed_input = gr.Number(
value=None,
label=t("seed_label", "en"),
info=t("seed_info", "en"),
precision=0,
minimum=-1,
elem_classes=["whyx-field"],
)
# Tree branch: Creativity
with gr.Accordion(f"🎨 {t('creativity_label', 'en')}", open=True, elem_classes=["whyx-tree-branch"]):
with gr.Row():
creativity = gr.Radio(
choices=[
(t("creativity_very_low", "en"), "very_low"),
(t("creativity_low", "en"), "low"),
(t("creativity_medium", "en"), "medium"),
(t("creativity_high", "en"), "high"),
(t("creativity_very_high", "en"), "very_high"),
(t("creativity_extreme", "en"), "extreme"),
],
value="medium",
label=t("creativity_label", "en"),
info=t("creativity_desc", "en"),
elem_classes=["whyx-radio-group", "whyx-field"],
)
weight_mode = gr.Radio(
choices=[
(t("weight_off", "en"), "off"),
(t("weight_light", "en"), "light"),
(t("weight_on", "en"), "on"),
],
value="off",
label=t("weight_label", "en"),
info=t("weight_desc", "en"),
elem_classes=["whyx-radio-group", "whyx-field"],
)
# Tree branch: Mode / Web / FX
with gr.Accordion(f"⚡ {t('mode_label', 'en')}", open=True, elem_classes=["whyx-tree-branch"]):
with gr.Row():
mode_toggle = gr.Radio(
choices=[
(t("mode_standard", "en"), "standard"),
(t("mode_rewrite", "en"), "rewrite"),
],
value="standard",
label=t("mode_label", "en"),
info=t("mode_desc", "en"),
elem_classes=["whyx-radio-group", "whyx-field"],
)
web_enrich_toggle = gr.Radio(
choices=[
(t("web_enrich_off", "en"), "0"),
(t("web_enrich_on", "en"), "1"),
],
value="0",
label=t("web_enrich_label", "en"),
info=t("web_enrich_desc", "en"),
elem_classes=["whyx-radio-group", "whyx-field"],
)
fx_toggle = gr.Radio(
choices=[
(t("fx_off", "en"), "off"),
(t("fx_light", "en"), "light"),
(t("fx_rich", "en"), "rich"),
],
value="light",
label=t("fx_label", "en"),
info=t("fx_desc", "en"),
elem_classes=["whyx-radio-group", "whyx-field"],
)
# Tree root: Advanced
with gr.Column(elem_classes=["whyx-tree-node"]):
adv_header_html = gr.HTML(_tree_header("🧹", "advanced_label", "en", "advanced_desc"))
output_format_radio = gr.Radio(
choices=[
(t("output_format_prompt", "en"), "prompt"),
(t("output_format_booru", "en"), "booru"),
],
value="prompt",
label=t("output_format_label", "en"),
info=t("output_format_desc", "en"),
elem_classes=["whyx-radio-group"],
)
cleanup_acc = gr.Accordion(label="▾ " + t("cleanup_label", "en"), open=False, elem_classes=["whyx-tree-branch"])
with cleanup_acc:
blacklist_box = gr.Textbox(
label=t("blacklist_label", "en"),
info=t("blacklist_desc", "en"),
placeholder=t("blacklist_ph", "en"),
lines=2,
)
mirror_blacklist = gr.Checkbox(label=t("blacklist_mirror", "en"), value=False)
with gr.Row(elem_classes=["whyx-strip-row"]):
strip_quality = gr.Checkbox(label=t("strip_quality", "en"), value=False)
strip_artist = gr.Checkbox(label=t("strip_artist", "en"), value=False)
strip_lora = gr.Checkbox(label=t("strip_lora", "en"), value=False)
strip_meta = gr.Checkbox(label=t("strip_meta", "en"), value=False)
min_tags = gr.Slider(
minimum=0, maximum=40, step=1, value=0,
label=t("min_tags_label", "en"), info=t("min_tags_desc", "en"),
)
presets_acc = gr.Accordion(label="▾ " + t("user_presets_label", "en"), open=False, elem_classes=["whyx-tree-branch"])
with presets_acc:
user_preset_name = gr.Textbox(
label=t("user_preset_name", "en"), placeholder=t("user_preset_name_ph", "en"),
)
user_preset_tags = gr.Textbox(
label=t("user_preset_tags", "en"), placeholder=t("user_preset_tags_ph", "en"), lines=2,
)
with gr.Row():
user_preset_save = gr.Button(t("user_preset_save", "en"), variant="primary")
user_preset_delete = gr.Button(t("user_preset_delete", "en"))
user_preset_trigger = gr.Button(
f'▸ {t("user_preset_select", "en")}',
elem_classes=["whyx-slide-trigger"], elem_id="whyx-trig-user-preset",
)
user_preset_dropdown = gr.Radio(
choices=_user_preset_choices("en"), value="",
label="", show_label=False,
elem_id="whyx-panel-user-preset",
elem_classes=["whyx-slide-panel", "whyx-slide-closed", "whyx-chip-group"],
)
user_preset_apply = gr.Button(t("user_preset_apply", "en"))
user_preset_status = gr.Markdown(visible=True)
user_preset_state = gr.State("")
# Tree root: Categories
with gr.Column(elem_classes=["whyx-tree-node"]):
categories_header_html = gr.HTML(_tree_header("🎨", "categories_label", "en"))
category_checks = []
cat_names = []
for cat in ALL_CATEGORIES:
cat_name = t(cat, "en")
icon = CATEGORY_ICONS.get(cat, "📌")
cat_desc_key = f"{cat}_desc"
cat_names.append((cat, cat_name, icon, cat_desc_key))
for row_start, row_size in [(0, 7), (7, 7), (14, 7), (21, 7)]:
with gr.Row(elem_classes=["whyx-cat-row"]):
for cat, cat_name, icon, cat_desc_key in cat_names[row_start:row_start + row_size]:
with gr.Column(scale=1, min_width=100):
cb = gr.Checkbox(
label=f"{icon} {cat_name}",
info=t(cat_desc_key, "en"),
value=cat in DEFAULT_CHECKED,
elem_classes=["whyx-cat-btn"],
)
category_checks.append(cb)
categories_desc_md = gr.Markdown(
f'<div class="whyx-info-text">{t("categories_desc", "en")}</div>',
visible=True,
)
# Tree root: Presets
with gr.Column(elem_classes=["whyx-tree-node"]):
presets_header_html = gr.HTML(_tree_header("⚡", "presets_label", "en"))
presets_desc_md = gr.Markdown(
f'<div class="whyx-info-text">{t("presets_desc", "en")}</div>',
visible=True,
)
preset_groups = {}
for group in PRESET_GROUPS:
gr.Markdown(
f'<div class="whyx-preset-group-label">{group["icon"]} {group["label_en"]}</div>'
)
choices = [
(f"{PRESETS[p]["icon"]} {PRESETS[p]["label_en"]}", p)
for p in group["presets"]
]
cg = gr.CheckboxGroup(
choices=choices,
label="",
value=[],
elem_classes=["whyx-preset-toggle"],
elem_id=f"preset-group-{group["key"]}",
)
preset_groups[group["key"]] = cg
generate_btn = gr.Button(
t("generate_btn", "en"),
variant="primary",
size="lg",
elem_classes=["whyx-generate-btn"],
)
with gr.Row():
suggest_btn = gr.Button(
t("suggest_btn", "en"),
variant="secondary",
size="sm",
elem_classes=["whyx-suggest-btn"],
)
suggest_output = gr.HTML(visible=True)
hotkeys_hint_md = gr.Markdown(
f'<div style="text-align:center;font-size:11px;color:var(--text-dim);margin-top:6px;">{t("hotkeys_hint", "en")}</div>',
visible=True,
)
# Tree root: Results
with gr.Column(elem_classes=["whyx-tree-node"]):
results_header_html = gr.HTML(_tree_header("📋", "results_label", "en"))
reload_btn = gr.Button(t("reload_btn", "en"), variant="secondary", size="sm")
results_desc_md = gr.Markdown(
value=f'<div class="whyx-info-text">{t("results_desc", "en")}</div>',
)
result_boxes = []
neg_boxes = []
heart_btns = []
result_cards = []
tooltip_spans = [] # (component, "copy" | "fav") for i18n updates
for i in range(MAX_OUTPUTS):
with gr.Row(elem_classes=["whyx-result-card"], visible=False) as card:
result_cards.append(card)
with gr.Column(scale=0, min_width=40, elem_classes=["whyx-rc-num"]):
gr.HTML(f'<div class="whyx-result-num">#{i+1}</div>')
with gr.Column(scale=1, elem_classes=["whyx-rc-pos"]):
rb = gr.Textbox(
label="",
show_label=False,
lines=3,
max_lines=8,
interactive=False,
placeholder=t("result_placeholder", "en"),
elem_classes=["whyx-result-text"],
)
with gr.Column(scale=0, min_width=36, elem_classes=["whyx-rc-copypos"]):
cb_pos = gr.Button(
"📋",
size="sm",
variant="secondary",
scale=0,
min_width=32,
elem_classes=["whyx-copy-btn", "whyx-tooltip"],
elem_id=f"copy-btn-pos-{i}",
)
cb_pos.click(fn=None, inputs=[rb], outputs=[], js=COPY_JS)
tt_copy_pos = gr.HTML(f'<span class="whyx-tooltip-text">{t("tooltip_copy", "en")}</span>')
tooltip_spans.append((tt_copy_pos, "copy"))
with gr.Column(scale=0, min_width=46, elem_classes=["whyx-rc-heart"]):
hb = gr.Button(
"♡",
size="sm",
variant="secondary",
scale=0,
min_width=36,
elem_classes=["whyx-heart-btn", "whyx-tooltip"],
elem_id=f"heart-btn-{i}",
)
heart_btns.append(hb)
tt_fav = gr.HTML(f'<span class="whyx-tooltip-text">{t("tooltip_favorite", "en")}</span>')
tooltip_spans.append((tt_fav, "fav"))
with gr.Column(scale=1, elem_classes=["whyx-rc-neg"]):
nb = gr.Textbox(
label="",
show_label=False,
lines=2,
max_lines=4,
interactive=False,
placeholder=t("neg_placeholder", "en"),
elem_classes=["whyx-neg-text"],
)
with gr.Column(scale=0, min_width=36, elem_classes=["whyx-rc-copyneg"]):
cb_neg = gr.Button(
"📋",
size="sm",
variant="secondary",
scale=0,
min_width=32,
elem_classes=["whyx-copy-btn", "whyx-tooltip"],
elem_id=f"copy-btn-neg-{i}",
)
cb_neg.click(fn=None, inputs=[nb], outputs=[], js=COPY_JS)
tt_copy_neg = gr.HTML(f'<span class="whyx-tooltip-text">{t("tooltip_copy", "en")}</span>')
tooltip_spans.append((tt_copy_neg, "copy"))
result_boxes.append(rb)
neg_boxes.append(nb)
# ===== TAB 2: Tagger =====
tab_tagger = gr.TabItem(f"🖼️ {t('tab_tagger', 'en')}")
with tab_tagger:
with gr.Column(elem_classes=["whyx-tree"]):
# Tree root: Tagger
with gr.Column(elem_classes=["whyx-tree-node"]):
tagger_header_html = gr.HTML(_tree_header("🖼️", "tagger_section", "en"))
with gr.Row():
with gr.Column(scale=1):
tagger_image = gr.Image(
sources=["upload"], type="numpy", label=t("tagger_image", "en"),
height=340, elem_classes=["whyx-tagger-image"],
)
tagger_gen_threshold = gr.Slider(
minimum=0.1, maximum=0.9, value=0.35, step=0.05,
label=t("tagger_gen_threshold", "en"),
info=t("tagger_gen_threshold_desc", "en"),
)
tagger_char_threshold = gr.Slider(
minimum=0.5, maximum=1.0, value=0.75, step=0.05,
label=t("tagger_char_threshold", "en"),
info=t("tagger_char_threshold_desc", "en"),
)
tagger_btn = gr.Button(
t("tagger_btn", "en"), variant="primary", size="lg",
elem_classes=["whyx-tagger-run-btn"],
)
with gr.Column(scale=1):
# Slide-panel pattern, same as Generate tab.
with gr.Row():
tagger_mode_trigger = gr.Button(
f'▸ {t("tagger_model_label", "en")}',
elem_classes=["whyx-slide-trigger"], elem_id="whyx-trig-tagger-mode",
)
tagger_mode = gr.Radio(
choices=[
(t("tagger_mode_smart", "en"), "ensemble"),
(t("tagger_mode_eva_large", "en"), "wd:eva02"),
(t("tagger_mode_dd", "en"), "deepdanbooru"),
(t("tagger_mode_florence", "en"), "nl"),
(t("tagger_mode_qwen", "en"), "qwen"),
],
value="ensemble",
label="",
show_label=False,
elem_id="whyx-panel-tagger-mode",
elem_classes=["whyx-slide-panel", "whyx-slide-closed", "whyx-chip-group"],
)
# Single "Smart analysis" toggle — replaces the old
# separate Pose analysis / Pose method / Smart analysis
# controls. ON (default): Whole-body DWPose detects
# pose + anatomy + expression + background. OFF: only
# base WD14 tags (ratings / characters / general).
tagger_smart_toggle = gr.Checkbox(
value=True,
label=t("tagger_smart_toggle_label", "en"),
info=t("tagger_smart_toggle_info", "en"),
elem_classes=["whyx-checkbox"],
)
# Optional NL caption — OFF by default (slow on CPU).
tagger_caption_toggle = gr.Checkbox(
value=False,
label=t("tagger_caption_toggle_label", "en"),
info=t("tagger_caption_toggle_info", "en"),
elem_classes=["whyx-checkbox"],
)
# ControlNet-style preview QA for the pose pass
# (pure numpy/PIL — no extra models). Rendered
# from the same keypoints the pose tags came from.
with gr.Row():
tagger_control_trigger = gr.Button(
f'▸ {t("tagger_control_label", "en")}',
elem_classes=["whyx-slide-trigger"], elem_id="whyx-trig-tagger-control",
)
tagger_control_kind = gr.Radio(
choices=[
(t("tagger_control_skeleton_black", "en"), "skeleton_black"),
(t("tagger_control_skeleton_overlay", "en"), "skeleton_overlay"),
(t("tagger_control_canny", "en"), "canny"),
(t("tagger_control_depth", "en"), "depth"),
(t("tagger_control_segmentation", "en"), "segmentation"),
],
value="skeleton_black",
label="",
show_label=False,
elem_id="whyx-panel-tagger-control",
elem_classes=["whyx-slide-panel", "whyx-slide-closed", "whyx-chip-group"],
)
tagger_control_img = gr.Image(
label=t("tagger_control_img_label", "en"),
interactive=False, visible=False,
elem_classes=["whyx-tagger-control-img"],
)
tagger_control_dl = gr.DownloadButton(
"⬇ PNG", visible=False, size="sm",
)
tagger_format = gr.Radio(
choices=[
(t("tagger_format_prompt", "en"), "prompt"),
(t("tagger_format_raw", "en"), "raw"),
],
value="prompt",
label=t("tagger_format_label", "en"),
info=t("tagger_format_desc", "en"),
elem_classes=["whyx-radio-group"],
)
tagger_output = gr.HTML(
value=f'<div class="whyx-info-text" style="font-size:12px;">{t("tagger_desc", "en")}</div>',
)
with gr.Row():
tagger_apply_btn = gr.Button(
t("tagger_apply_btn", "en"), variant="primary", size="lg",
visible=False, elem_classes=["whyx-tagger-apply-btn"],
)
tagger_copy_btn = gr.Button(
"📋 " + t("tagger_copy", "en"), size="lg",
visible=False, elem_classes=["whyx-tagger-copy-btn"],
)
# Tree branch: Results
with gr.Accordion(f"🏷️ {t('tagger_chips_label', 'en')}", open=False, elem_classes=["whyx-tree-branch"]):
tagger_chips = gr.CheckboxGroup(
choices=[], value=[], label="",
elem_classes=["whyx-tagger-chips"], visible=False,
)
tagger_chars = gr.CheckboxGroup(
choices=[], value=[], label="",
elem_classes=["whyx-tagger-chips"], visible=False,
)
tagger_tags_box = gr.Textbox(
label=t("tagger_tags_label", "en"),
info=t("tagger_tags_desc", "en"),
lines=5, value="",
placeholder=t("tagger_tags_ph", "en"),
elem_classes=["whyx-tagger-tags-box"], visible=False,
)
tagger_raw_state = gr.State("")
tagger_esc_state = gr.State("")
# ===== TAB 3: Tools =====
tab_tools = gr.TabItem(f"🔧 {t('tab_tools', 'en')}")
with tab_tools:
with gr.Column(elem_classes=["whyx-tree"]):
# Tree root: Analyzer
with gr.Column(elem_classes=["whyx-tree-node"]):
analyzer_header_html = gr.HTML(_tree_header("📊", "analyzer_section", "en"))
analyzer_md = gr.Markdown(
value=f'<div class="whyx-info-text">{t("analyzer_desc", "en")}</div>',
visible=True,
)
# Tree root: Artist (slide-down)
with gr.Column(elem_classes=["whyx-tree-node"]):
artist_header_html = gr.HTML(_tree_header("🖌️", "artist_section", "en"))
with gr.Row():
artist_style_trigger = gr.Button(
f'▸ {t("artist_style_placeholder", "en")}',
elem_classes=["whyx-slide-trigger"], elem_id="whyx-trig-artist-style",
)
artist_search_trigger = gr.Button(
f'▸ {t("artist_search_label", "en")}',
elem_classes=["whyx-slide-trigger"], elem_id="whyx-trig-artist-search",
)
artist_multiselect_trigger = gr.Button(
f'▸ {t("artist_select", "en")}',
elem_classes=["whyx-slide-trigger"], elem_id="whyx-trig-artist-multiselect",
)
artist_style_dropdown = gr.Radio(
choices=STYLECHOICES, value="",
label="", show_label=False,
elem_id="whyx-panel-artist-style",
elem_classes=["whyx-slide-panel", "whyx-slide-closed", "whyx-chip-group"],
)
artist_search = gr.Textbox(
label=t("artist_search_label", "en"),
placeholder=t("artist_search_placeholder", "en"),
info=t("artist_search_desc", "en"),
elem_id="whyx-panel-artist-search",
elem_classes=["whyx-slide-panel", "whyx-slide-closed"],
)
artist_multiselect = gr.CheckboxGroup(
choices=ARTISTCHOICES, value=[],
label="", show_label=False,
elem_id="whyx-panel-artist-multiselect",
elem_classes=["whyx-slide-panel", "whyx-slide-closed", "whyx-chip-group"],
)
artist_style_dropdown.change(
fn=on_artist_filter_change,
inputs=[artist_style_dropdown, artist_search],
outputs=[artist_multiselect],
)
artist_search.change(
fn=on_artist_filter_change,
inputs=[artist_style_dropdown, artist_search],
outputs=[artist_multiselect],
)
artist_info_md = gr.HTML(
value=_format_artist_info([], "en"),
visible=True,
)
artist_multiselect.change(
fn=on_artist_selection_change,
inputs=[artist_multiselect, lang_state],
outputs=[artist_info_md],
)
artist_style_dropdown.change(
fn=on_artist_style_recommendations,
inputs=[artist_style_dropdown, lang_state],
outputs=[artist_info_md],
)
# Tree root: Tandem (slide-down)
with gr.Column(elem_classes=["whyx-tree-node"]):
tandem_header_html = gr.HTML(_tree_header("🤝", "tandem_section", "en"))
with gr.Row():
use_tandems = gr.Checkbox(
label=t("tandem_random", "en"),
info=t("tandem_random_desc", "en"),
value=False,
scale=0,
min_width=140,
)
tandem_trigger = gr.Button(
f'▸ {t("tandem_select", "en")}',
elem_classes=["whyx-slide-trigger"], elem_id="whyx-trig-tandem",
scale=2,
)
tandem_dropdown = gr.Radio(
choices=TANDEMCHOICES, value=None,
label="", show_label=False,
elem_id="whyx-panel-tandem",
elem_classes=["whyx-slide-panel", "whyx-slide-closed", "whyx-chip-group"],
)
# Tree root: Web Search
with gr.Column(elem_classes=["whyx-tree-node"]):
web_search_panel = gr.Accordion(t("web_search_label", "en"), open=False, elem_classes=["whyx-tree-branch"])
with web_search_panel:
web_search_input = gr.Textbox(
label="", show_label=False,
placeholder=t("web_search_placeholder", "en"),
scale=1,
)
with gr.Row():
web_search_btn = gr.Button(t("web_search_btn", "en"), scale=0, variant="secondary")
web_search_clear = gr.ClearButton(value="✕", scale=0, variant="secondary")
web_search_output = gr.HTML(
value=f'<div class="whyx-info-text" style="color:rgba(148,163,184,0.6);font-size:12px;">{t("web_search_empty", "en")}</div>',
)
# ===== TAB 4: History =====
tab_history = gr.TabItem(f"📜 {t('tab_history', 'en')}")
with tab_history:
with gr.Column(elem_classes=["whyx-tree-node"]):
history_header_html = gr.HTML(_tree_header("📜", "history_section", "en"))
history_md = gr.HTML(
value=_format_history_html("en"),
visible=True,
)
web_search_btn.click(
fn=on_web_search,
inputs=[web_search_input, lang_state],
outputs=[web_search_output],
)
web_search_input.submit(
fn=on_web_search,
inputs=[web_search_input, lang_state],
outputs=[web_search_output],
)
reload_btn.click(
fn=_reload_data,
inputs=[lang_state],
outputs=[],
)
web_search_clear.click(
fn=lambda lang: f'<div class="whyx-info-text" style="color:var(--text-faint);font-size:12px;">{t("web_search_empty", "ru" if lang == "RU" else "en")}</div>',
inputs=[lang_state],
outputs=[web_search_output],
)
for i, hb in enumerate(heart_btns):
hb.click(
fn=lambda idx=i, *args: _heart_click(idx, *args),
inputs=[lang_state] + result_boxes,
outputs=[history_md] + heart_btns,
)
# Fold ensemble `mode` into the tagger call while keeping fmt semantics.
# "nl"/"qwen" are caption-only modes: WD14 / pose / rating passes are
# skipped entirely and the caption is the sole output (see on_tag_nl_only).
def on_tag_image_with_mode(image, gen_threshold, char_threshold, fmt, mode, caption_toggle, smart_toggle, lang, control_kind="skeleton_black", progress=gr.Progress()):
if mode in ("nl", "qwen"):
caption_kind = "qwen" if mode == "qwen" else "florence"
return on_tag_nl_only(image, fmt, lang, caption_kind, progress=progress)
mode_key = mode if isinstance(mode, str) and mode.startswith(
("wd:", "deepdanbooru", "ensemble")
) else "ensemble"
effective_fmt = f"{fmt}|{mode_key}" if mode_key != "ensemble" else fmt
# Smart analysis is the single switch for the full pipeline: when ON the
# backend auto-selects Whole-body DWPose (pose + anatomy + expression +
# background). When OFF we skip pose entirely and return base WD14 tags.
smart_analysis = bool(smart_toggle)
skip_pose = not smart_analysis
pose_mode = "wholebody" if smart_analysis else "yolo"
# The caption checkbox is the single source of truth for the NL
# caption; the "nl"/"qwen" radio items only check the box (see
# on_tag_rerun). "qwen" selects the Qwen2.5-VL-3B captioner
# (Anima-style descriptions), everything else uses Florence-2-base.
with_caption = bool(caption_toggle)
caption_kind = "qwen" if mode == "qwen" else "florence"
return on_tag_image(image, gen_threshold, char_threshold, effective_fmt, lang, progress=progress, skip_pose=skip_pose, with_caption=with_caption, caption_kind=caption_kind, control_kind=control_kind, pose_mode=pose_mode, smart_analysis=smart_analysis)
tagger_outputs = [
tagger_output, tagger_apply_btn, tagger_chips, tagger_chars,
tagger_tags_box, tagger_copy_btn, tagger_raw_state, tagger_esc_state,
tagger_control_img, tagger_control_dl,
]
tagger_btn.click(
fn=on_tag_image_with_mode,
inputs=[tagger_image, tagger_gen_threshold, tagger_char_threshold, tagger_format, tagger_mode, tagger_caption_toggle, tagger_smart_toggle, lang_state, tagger_control_kind],
outputs=tagger_outputs,
)
# The "Florence NL Caption" radio item ⇔ the caption checkbox are synced:
# picking the radio item checks the box (inside on_tag_rerun below, in
# the same event as the caption run); unchecking snaps back to Smart.
tagger_caption_toggle.change(
fn=lambda checked: gr.update(value="ensemble") if not checked else gr.update(),
inputs=[tagger_caption_toggle],
outputs=[tagger_mode],
)
# Auto re-run when the model or the pose toggle changes (never for the
# caption checkbox — captioning is opt-in and slow). No-op without an
# image. Selecting the "nl"/"qwen" radio item checks the caption box and
# runs the caption in the same event, so the checkbox stays the visible
# source of truth. The component outputs are deliberately NOT read back as
# inputs here: in Gradio 6, CheckboxGroup.preprocess validates the value
# against the current choices and raises on stale selections, which
# crashed re-runs ("Value: ... is not in the list of choices").
def on_tag_rerun(image, gen_threshold, char_threshold, fmt, mode, caption_toggle, smart_toggle, lang, control_kind="skeleton_black", progress=gr.Progress()):
with_caption = bool(caption_toggle) or mode in ("nl", "qwen")
caption_update = gr.update(value=True) if mode in ("nl", "qwen") else gr.skip()
if image is None:
return (gr.skip(),) * 10 + (caption_update,)
return tuple(on_tag_image_with_mode(image, gen_threshold, char_threshold, fmt, mode, with_caption, smart_toggle, lang, control_kind=control_kind, progress=progress)) + (caption_update,)
rerun_inputs = [
tagger_image, tagger_gen_threshold, tagger_char_threshold, tagger_format,
tagger_mode, tagger_caption_toggle, tagger_smart_toggle, lang_state,
tagger_control_kind,
]
tagger_mode.change(
fn=on_tag_rerun,
inputs=rerun_inputs,
outputs=tagger_outputs + [tagger_caption_toggle],
)
tagger_control_kind.change(
fn=on_tag_rerun,
inputs=rerun_inputs,
outputs=tagger_outputs + [tagger_caption_toggle],
)
# Disable the smart-analysis toggle when a non-ensemble mode is selected
# (smart analysis only applies to the Smart tagger).
tagger_mode.change(
fn=lambda m: gr.update(interactive=(m in ("ensemble", None, ""))),
inputs=[tagger_mode],
outputs=[tagger_smart_toggle],
)
tagger_chips.change(
fn=on_tagger_selection_change,
inputs=[tagger_chips, tagger_chars, tagger_format, tagger_raw_state, tagger_esc_state],
outputs=[tagger_tags_box],
)
tagger_chars.change(
fn=on_tagger_selection_change,
inputs=[tagger_chips, tagger_chars, tagger_format, tagger_raw_state, tagger_esc_state],
outputs=[tagger_tags_box],
)
tagger_format.change(
fn=on_tagger_format_change,
inputs=[tagger_format, tagger_chips, tagger_chars],
outputs=[tagger_tags_box],
)
tagger_apply_btn.click(
fn=on_tagger_apply_tags,
inputs=[prompt_input, tagger_tags_box],
outputs=[prompt_input],
)
tagger_copy_btn.click(
fn=None,
inputs=[tagger_tags_box],
outputs=[],
js="(txt) => { try { navigator.clipboard.writeText(txt); } catch(e) {} }",
)
user_preset_save.click(
fn=on_user_preset_save,
inputs=[user_preset_name, user_preset_tags, lang_state],
outputs=[user_preset_dropdown, user_preset_tags, user_preset_status],
)
user_preset_apply.click(
fn=on_user_preset_apply,
inputs=[user_preset_dropdown, current_preset_state, lang_state],
outputs=[current_preset_state, user_preset_status],
)
user_preset_delete.click(
fn=on_user_preset_delete,
inputs=[user_preset_dropdown, lang_state],
outputs=[user_preset_dropdown, user_preset_status],
)
inputs_list = [
prompt_input, model_dropdown, rating_dropdown,
num_variations, creativity, weight_mode, mode_toggle, lang_state,
artist_style_dropdown, artist_multiselect,
use_tandems, tandem_dropdown, web_enrich_toggle,
seed_input, current_preset_state, fx_toggle,
blacklist_box, mirror_blacklist, strip_quality, strip_artist, strip_lora, strip_meta, min_tags, user_preset_state,
output_format_radio,
] + category_checks
prompt_input.change(
fn=on_prompt_analyze,
inputs=[prompt_input, lang_state],
outputs=[analyzer_md],
)
generate_btn.click(
fn=on_generate,
inputs=inputs_list,
outputs=result_boxes + neg_boxes + result_cards,
).then(
fn=lambda lang: gr.update(value=_format_history_html("ru" if lang == "RU" else "en")),
inputs=[lang_state],
outputs=[history_md],
)
suggest_btn.click(
fn=on_suggest,
inputs=[prompt_input, model_dropdown, rating_dropdown, lang_state],
outputs=[suggest_output],
)
group_components = [preset_groups[g["key"]] for g in PRESET_GROUPS]
for group_key, cg in preset_groups.items():
def make_handler():
def handler(prompt, *args):
return on_preset_change(prompt, *args)
return handler
cg.change(
fn=make_handler(),
inputs=[prompt_input] + group_components + [current_preset_state],
outputs=category_checks + [prompt_input, current_preset_state] + group_components,
)
rating_dropdown.change(
fn=on_rating_change,
inputs=[rating_dropdown] + category_checks,
outputs=category_checks,
)
def update_lang(choice):
lc = "ru" if choice == "RU" else "en"
updates = [
gr.update(value=f"""
<div>
<div class="whyx-title">Whyx-PROmpTea</div>
<div class="whyx-subtitle">{t("app_subtitle", lc)}</div>
</div>
"""),
gr.update(value=_tree_header("🎨", "categories_label", lc)),
gr.update(value=_tree_header("📊", "analyzer_section", lc)),
gr.update(value=_tree_header("🖌️", "artist_section", lc)),
gr.update(value=_tree_header("🤝", "tandem_section", lc)),
gr.update(value=_tree_header("⚡", "presets_label", lc)),
gr.update(value=_tree_header("📋", "results_label", lc)),
gr.update(value=_tree_header("📜", "history_section", lc)),
gr.update(label=t("input_label", lc), placeholder=t("input_placeholder", lc), info=t("input_desc", lc)),
gr.update(value=f'▸ {t("model_label", lc)}'),
gr.update(
choices=[(t("anima", lc), "anima"), (t("illustrious", lc), "illustrious")],
),
gr.update(value=f'▸ {t("rating_label", lc)}'),
gr.update(
choices=[
(t("rating_pg", lc), "pg"),
(t("rating_pg13", lc), "pg13"),
(t("rating_pg16", lc), "pg16"),
(t("rating_r", lc), "r"),
(t("rating_rplus", lc), "r+"),
],
),
gr.update(label=t("variations_label", lc), info=t("variations_desc", lc)),
gr.update(
choices=[
(t("creativity_very_low", lc), "very_low"),
(t("creativity_low", lc), "low"),
(t("creativity_medium", lc), "medium"),
(t("creativity_high", lc), "high"),
(t("creativity_very_high", lc), "very_high"),
(t("creativity_extreme", lc), "extreme"),
],
label=t("creativity_label", lc),
info=t("creativity_desc", lc),
),
gr.update(
choices=[
(t("weight_off", lc), "off"),
(t("weight_light", lc), "light"),
(t("weight_on", lc), "on"),
],
label=t("weight_label", lc),
info=t("weight_desc", lc),
),
gr.update(
choices=[
(t("mode_standard", lc), "standard"),
(t("mode_rewrite", lc), "rewrite"),
],
label=t("mode_label", lc),
info=t("mode_desc", lc),
),
gr.update(
choices=[
(t("web_enrich_off", lc), "0"),
(t("web_enrich_on", lc), "1"),
],
label=t("web_enrich_label", lc),
info=t("web_enrich_desc", lc),
),
gr.update(
choices=[
(t("fx_off", lc), "off"),
(t("fx_light", lc), "light"),
(t("fx_rich", lc), "rich"),
],
label=t("fx_label", lc),
info=t("fx_desc", lc),
),
gr.update(value=t("generate_btn", lc)),
gr.update(label=t("web_search_label", lc)),
gr.update(placeholder=t("web_search_placeholder", lc)),
gr.update(value=t("web_search_btn", lc)),
gr.update(value=f'▸ {t("artist_style_placeholder", lc)}'),
gr.update(
choices=[(s.title() if s else t("artist_all", lc), s) for s in artist_styles],
),
gr.update(label=t("artist_search_label", lc), placeholder=t("artist_search_placeholder", lc), info=t("artist_search_desc", lc)),
gr.update(value=f'▸ {t("artist_select", lc)}'),
gr.update(choices=ARTISTCHOICES),
gr.update(label=t("tandem_random", lc), info=t("tandem_random_desc", lc)),
gr.update(value=f'▸ {t("tandem_select", lc)}'),
gr.update(
choices=[(t("tandem_placeholder", lc), None)] + [
(
f"🤝 {tand['artists'][0]} × {tand['artists'][1]} [{tand.get('style_blend', '')}] ({tand.get('compatibility', 0):.0%})",
i,
)
for i, tand in enumerate(all_tandems)
],
),
gr.update(label=t("seed_label", lc), info=t("seed_info", lc)),
gr.update(value=t("reload_btn", lc)),
gr.update(value=_tree_header("🖼️", "tagger_section", lc)),
gr.update(label=t("tagger_image", lc)),
gr.update(label=t("tagger_gen_threshold", lc), info=t("tagger_gen_threshold_desc", lc)),
gr.update(label=t("tagger_char_threshold", lc), info=t("tagger_char_threshold_desc", lc)),
gr.update(value=t("tagger_btn", lc)),
gr.update(value=t("tagger_apply_btn", lc)),
gr.update(value=f'<div class="whyx-info-text" style="font-size:12px;">{t("tagger_desc", lc)}</div>'),
gr.update(
choices=[(t("tagger_format_prompt", lc), "prompt"), (t("tagger_format_raw", lc), "raw")],
label=t("tagger_format_label", lc), info=t("tagger_format_desc", lc),
),
gr.update(label=t("tagger_chips_label", lc)),
gr.update(label=t("tagger_chars_label", lc)),
gr.update(label=t("tagger_tags_label", lc), info=t("tagger_tags_desc", lc), placeholder=t("tagger_tags_ph", lc)),
gr.update(value="📋 " + t("tagger_copy", lc)),
gr.update(value=_tree_header("🧹", "advanced_label", lc, "advanced_desc")),
gr.update(
choices=[(t("output_format_prompt", lc), "prompt"), (t("output_format_booru", lc), "booru")],
label=t("output_format_label", lc), info=t("output_format_desc", lc),
),
gr.update(label="▾ " + t("cleanup_label", lc)),
gr.update(label=t("blacklist_label", lc), info=t("blacklist_desc", lc), placeholder=t("blacklist_ph", lc)),
gr.update(label=t("blacklist_mirror", lc)),
gr.update(label=t("strip_quality", lc)),
gr.update(label=t("strip_artist", lc)),
gr.update(label=t("strip_lora", lc)),
gr.update(label=t("strip_meta", lc)),
gr.update(label=t("min_tags_label", lc), info=t("min_tags_desc", lc)),
gr.update(label="▾ " + t("user_presets_label", lc)),
gr.update(label=t("user_preset_name", lc), placeholder=t("user_preset_name_ph", lc)),
gr.update(label=t("user_preset_tags", lc), placeholder=t("user_preset_tags_ph", lc)),
gr.update(value=t("user_preset_save", lc)),
gr.update(value=t("user_preset_delete", lc)),
gr.update(value=f'▸ {t("user_preset_select", lc)}'),
gr.update(choices=_user_preset_choices(lc)),
gr.update(value=t("user_preset_apply", lc)),
gr.update(label=t("theme_label", lc), choices=[(t("theme_dark", lc), "dark"), (t("theme_light", lc), "light")]),
]
for cat in ALL_CATEGORIES:
cn = t(cat, lc)
icon = CATEGORY_ICONS.get(cat, "📌")
cat_desc_key = f"{cat}_desc"
updates.append(gr.update(label=f"{icon} {cn}", info=t(cat_desc_key, lc)))
for group in PRESET_GROUPS:
choices = [
(f"{PRESETS[p][f"icon"]} {PRESETS[p][f"label_{lc}"]}", p)
for p in group["presets"]
]
updates.append(gr.update(choices=choices))
for _ in range(MAX_OUTPUTS):
updates.append(gr.update(placeholder=t("result_placeholder", lc)))
for _ in range(MAX_OUTPUTS):
updates.append(gr.update(placeholder=t("neg_placeholder", lc)))
updates.append(gr.update(value=f'<div class="whyx-info-text">{t("categories_desc", lc)}</div>'))
updates.append(gr.update(value=f'<div class="whyx-info-text">{t("presets_desc", lc)}</div>'))
updates.append(gr.update(value=f'<div class="whyx-info-text">{t("results_desc", lc)}</div>'))
updates.append(gr.update(value=f'<div style="text-align:center;font-size:11px;color:var(--text-dim);margin-top:6px;">{t("hotkeys_hint", lc)}</div>'))
updates.append(gr.update(value=_format_artist_info([], lc)))
updates.append(gr.update(value=f'<div class="whyx-info-text">{t("analyzer_desc", lc)}</div>'))
updates.append(gr.update(value=_format_history_html(lc)))
updates.append(gr.update(label=f"✨ {t('tab_generate', lc)}"))
updates.append(gr.update(label=f"🖼️ {t('tab_tagger', lc)}"))
updates.append(gr.update(label=f"🔧 {t('tab_tools', lc)}"))
updates.append(gr.update(label=f"📜 {t('tab_history', lc)}"))
for _comp, kind in tooltip_spans:
key = "tooltip_copy" if kind == "copy" else "tooltip_favorite"
updates.append(gr.update(value=f'<span class="whyx-tooltip-text">{t(key, lc)}</span>'))
updates.append(choice)
return updates
lang_outputs = [
title_html,
categories_header_html,
analyzer_header_html,
artist_header_html,
tandem_header_html,
presets_header_html,
results_header_html,
history_header_html,
] + [
prompt_input, model_trigger, model_dropdown, rating_trigger, rating_dropdown,
num_variations, creativity, weight_mode, mode_toggle, web_enrich_toggle, fx_toggle, generate_btn,
web_search_panel, web_search_input, web_search_btn,
artist_style_trigger, artist_style_dropdown, artist_search, artist_multiselect_trigger, artist_multiselect,
use_tandems, tandem_trigger, tandem_dropdown, seed_input, reload_btn,
] + [
tagger_header_html,
tagger_image,
tagger_gen_threshold,
tagger_char_threshold,
tagger_btn,
tagger_apply_btn,
tagger_output,
tagger_format,
tagger_chips,
tagger_chars,
tagger_tags_box,
tagger_copy_btn,
] + [
adv_header_html, output_format_radio, cleanup_acc,
blacklist_box, mirror_blacklist, strip_quality, strip_artist, strip_lora, strip_meta, min_tags,
presets_acc, user_preset_name, user_preset_tags, user_preset_save, user_preset_delete,
user_preset_trigger, user_preset_dropdown, user_preset_apply, theme_toggle,
] + category_checks + group_components + result_boxes + neg_boxes + [categories_desc_md, presets_desc_md, results_desc_md, hotkeys_hint_md, artist_info_md, analyzer_md, history_md,
tab_generate, tab_tagger, tab_tools, tab_history] + [c for c, _k in tooltip_spans] + [lang_state]
lang_btn.change(
fn=update_lang,
inputs=[lang_btn],
outputs=lang_outputs,
)
lang_btn.change(
fn=None,
inputs=[lang_btn],
outputs=[],
js="(v) => { try { localStorage.setItem('whyx-lang', v); } catch(e) {} }",
)
theme_toggle.change(
fn=None,
inputs=[theme_toggle],
outputs=[],
js="(v) => { document.body.classList.toggle('whyx-light', v === 'light'); try { localStorage.setItem('whyx-theme', v); } catch(e) {} }",
)
# Restore persisted theme + language on page load: the JS reads
# localStorage, applies the body class / radio value, then the standard
# update_lang chain re-renders every localized label.
demo.load(
fn=None,
inputs=[],
outputs=[theme_toggle],
js="() => { let t = 'dark'; try { t = localStorage.getItem('whyx-theme') || 'dark'; } catch(e) {} document.body.classList.toggle('whyx-light', t === 'light'); return t; }",
).then(
fn=None,
inputs=[],
outputs=[lang_btn],
js="() => { try { return localStorage.getItem('whyx-lang') || 'EN'; } catch(e) { return 'EN'; } }",
).then(
fn=update_lang,
inputs=[lang_btn],
outputs=lang_outputs,
)
if __name__ == "__main__":
_launch_kw = {}
if _GRADIO_MAJOR >= 6:
_launch_kw["theme"] = WhyxTheme()
_launch_kw["css"] = WHYX_CSS
_launch_kw["head"] = f"<script>{HOTKEYS_JS}</script>"
# Gradio 5 SSR on HF Spaces breaks custom JS listeners under `elem_id=` —
# disable SSR explicitly while keeping the theme/css/head in launch args.
_launch_kw.setdefault("ssr_mode", False)
demo.launch(**_launch_kw)