"""Responsive, WebUI-only author sample browser and explicit slot assignment.""" import math import re from pathlib import Path from urllib.parse import quote import gradio as gr from jujo_loras import CATALOGS, JUJO_ENTRIES, NONE_LORA, PONY_LABEL, PONY_WARNING, lora_selected ASSETS = Path(__file__).resolve().parent / "assets" / "jujo-browser" def category(entry): series = Path(entry["doc"]).parent.name if series in {"doyagao", "inv_v_eyebrows"}: return "眉" if series in {"smallmouth", "widemouth", "mouthcollection", "talkmouth", "smallclosedmouth", "smirkmouth", "smugmouth", "chestnutmouth", "circlemouth", "clenchedteeth"}: return "口" if series in {"darkness", "facelightup", "anti25d", "make25d"}: return "効果" if series in {"eyeshapes", "eyecollexl", "tareme", "tsurime", "jitome", "hosome", "closedeye", "coldeyes", "pupil", "smirkingeye", "worriedeyes", "wink", "sleepy", "surprised", "whiteeyelash", "eyelashext"} or entry["name"].startswith("hotarueye_"): return "目の形" return "表情・人物" def entries_for_browser(family): catalog = CATALOGS[family] base = f"https://huggingface.co/{catalog['repo']}" entries = [] for entry in JUJO_ENTRIES[family].values(): name = entry["name"] pony_only = bool(entry.get("pony_only")) code = name.removesuffix(".safetensors") for suffix in ("_anima_v10", "_xl_v10", "_xl_v20", "_xl_v30"): code = code.removesuffix(suffix) hint = "強度や補助プロンプトは、作者の使い方をご確認ください。" sample_note = "作者のシリーズ共通比較サンプル" if entry["group_sample"] else "作者の生成サンプル" strength = 0.8 if family == "anima" else 1.0 pupil = re.fullmatch(r"hotarupupil_xl_type([1-6])_v10\.safetensors", name) hosome = re.fullmatch(r"hotarueye_xl_hosome([12])_v30\.safetensors", name) comic = re.fullmatch(r"hotarueye_xl_comic(0[1-4])_v\d+(?:_(?:ill|pony))?\.safetensors", name) if family == "anima" and entry["doc"] == "eyeshapes/README.md": hint = "作者の強度目安:0.3~1.5。複数併用時は合計1.0~1.5程度。closed eyesは避けてください。" elif family == "illustrious" and entry["doc"] == "sdxl/eyecollexl/README.md": hint = "作者のILL向け強度目安:1.5~2.0。種類に応じてtareme / tsurimeも参照してください。" strength = 1.5 eye_code = re.fullmatch(r"eyecolle_xl_(code\d{3})", code) if eye_code: code = eye_code[1] if entry["image"] and not entry["group_sample"]: shape = "tareme" if Path(entry["image"]).name.startswith("tareme_") else "tsurime" shape_label = "タレ目系" if shape == "tareme" else "吊り目系" sample_note = f"{code}に対応する作者のIllustrious生成サンプル({shape_label})" hint = f"作者のILL向け強度目安:1.5~2.0。掲載サンプルは{shape}をプロンプトに指定しています。" elif family == "illustrious" and pupil: code = f"Type {pupil[1]}" sample_note = f"作者の比較画像から{code}の部分を表示(原図の比較画像も開けます)" descriptions = { "1": "大きな瞳・白目が少ないタイプ", "2": "寄り目・左右方向が細い瞳", "3": "寄り目・正円に近い瞳", "4": "ハイライトが少なく黒目がちな印象", "5": "左右方向が細く、上下端のカーブが見える瞳", "6": "左右方向が細く、上下端のカーブが隠れる瞳", } hint = f"{code}:{descriptions[pupil[1]]}。作者の強度目安:0.5~2.0。追加プロンプトは基本的に不要です。" elif family == "illustrious" and hosome: code = f"Type {hosome[1]} / v3.0" shape = "吊り目型・直線的な目尻" if hosome[1] == "1" else "タレ目型・曲線的な目尻" sample_note = f"Type {hosome[1]}に対応する作者のv3.0サンプル(Illustrious向け)" hint = f"{shape}。作者の強度目安:0.5~1.5。追加プロンプトは基本的に不要です。" elif family == "illustrious" and comic: code = f"Type {int(comic[1])} / {'Pony' if pony_only else 'ILL'}" sample_note = f"Type {int(comic[1])}に対応する作者のタイプ別サンプル" hint = "目の色や形などの詳細な記述はプロンプトに入れずに使用してください。" if pony_only: sample_note += "(Pony用LoRAの作者掲載画像。ILLでの再現は未確認)" entries.append(dict( name=name, title=entry["title"], code=code, category=category(entry), image=f"{base}/resolve/{catalog['revision']}/{quote(entry['image'])}" if entry["image"] else None, source=f"{base}/blob/{catalog['revision']}/{quote(entry['doc'])}", group_sample=entry["group_sample"], sample_note=sample_note, hint=hint, strength=strength, sample_viewport=entry.get("sample_viewport"), pony_only=pony_only, compatibility_label=PONY_LABEL if pony_only else "", compatibility_warning=PONY_WARNING if pony_only else "", )) return entries def slot_snapshot(values): return [dict(enabled=lora_selected(values[i], values[i + 1]), name=values[i + 1] or NONE_LORA, strength=float(values[i + 2])) for i in range(0, 12, 3)] def assignment(family, payload, values): """Validate the explicit Apply action using live Gradio slot values.""" if not isinstance(payload, dict) or payload.get("selected") not in JUJO_ENTRIES[family]: raise gr.Error("このモードのサンプルからLoRAを選択してください。") slot = payload.get("slot") if isinstance(slot, bool) or not isinstance(slot, int) or not 1 <= slot <= 4: raise gr.Error("適用先のLoRA 1~4を選択してください。") try: strength = float(payload["strength"]) except (KeyError, TypeError, ValueError): raise gr.Error("強度を0~2の範囲で入力してください。") from None if not math.isfinite(strength) or not 0 <= strength <= 2: raise gr.Error("強度を0~2の範囲で入力してください。") updated = list(values) offset = (slot - 1) * 3 updated[offset:offset + 3] = [True, payload["selected"], strength] return offset, updated class JujoBrowser(gr.HTML): def api_info(self): return {"type": "object", "additionalProperties": True} def build_jujo_browser(family, lora_components, lora_defaults): initial = [value for slot in lora_defaults for value in slot] with gr.Accordion("JujoHotaruのLoRAサンプル・使い方はこちら", open=False, elem_classes=["jujo-samples"]): browser = JujoBrowser( value={"slots": slot_snapshot(initial)}, entries=entries_for_browser(family), html_template=(ASSETS / "browser.html").read_text(encoding="utf-8"), css_template=(ASSETS / "browser.css").read_text(encoding="utf-8"), js_on_load=(ASSETS / "browser.js").read_text(encoding="utf-8"), apply_default_css=False, padding=False, ) def sync_slots(*values): return {"slots": slot_snapshot(values)} def apply_sample(payload, *values): offset, updated = assignment(family, payload, values) outputs = [gr.skip() for _ in values] outputs[offset:offset + 3] = [ gr.Checkbox(value=True, interactive=True), gr.Dropdown(value=updated[offset + 1]), gr.Slider(value=updated[offset + 2], interactive=True), ] return (*outputs, {"slots": slot_snapshot(updated), "message": f"LoRA {offset // 3 + 1}に設定して有効化しました。"}) gr.on( triggers=[component.change for component in lora_components], fn=sync_slots, inputs=lora_components, outputs=browser, queue=False, trigger_mode="always_last", api_visibility="private", ) browser.submit( apply_sample, inputs=[browser, *lora_components], outputs=[*lora_components, browser], queue=False, api_visibility="private", ) return browser