from __future__ import annotations

import base64
import html as html_utils
import io
import argparse
import json
import math
import os
import queue
import re
import shutil
import subprocess
import sys
import threading
import time
import uuid
from pathlib import Path
from urllib.parse import quote

ROOT = Path(__file__).resolve().parent
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))
FANBOX_URL = "https://studiomasakaki.fanbox.cc/"
USAGE_URL = "https://studiomasakaki.fanbox.cc/posts/12129556"
TERMS_PATH = ROOT.parent / "利用規約.txt"

STARTUP_LOG_ENABLED = os.environ.get("COPITA_STARTUP_LOG", "1") != "0"
STARTUP_STARTED_AT = time.perf_counter()


def startup_log(message: str) -> None:
    if not STARTUP_LOG_ENABLED:
        return
    elapsed = time.perf_counter() - STARTUP_STARTED_AT
    print(f"[{time.strftime('%H:%M:%S')} +{elapsed:05.1f}s] {message}", flush=True)


startup_log("UIライブラリを読み込み中...")
import gradio as gr
from PIL import Image
startup_log("UIライブラリを読み込みました。")

startup_log("Copitaモジュールを読み込み中...")
import settings
from captioning import (
    REFERENCE_STYLE_PRESETS,
    auto_caption_pair,
    build_pair_prompt,
    build_reference_prompt_bundle,
    clean_caption_reference,
    format_caption_for_display,
)
from trainer import (
    TrainOptions,
    import_anima_scripts,
    is_anima_dit_checkpoint,
    planned_output_paths,
    safe_output_name,
    scan_files,
    scan_models,
)
startup_log("Copitaモジュールを読み込みました。")


APP_TITLE = "Copita"
APP_VERSION = "v1.1"
THEME = gr.themes.Soft(primary_hue="emerald", neutral_hue="zinc")

CHECKPOINT_DIR_NAMES = (
    "StableDiffusion",
    "Stable-diffusion",
    "stable-diffusion",
    "stable_diffusion",
    "checkpoints",
    "SDXL",
)
TEXT_ENCODER_DIR_NAMES = (
    "TextEncoders",
    "TextEncoder",
    "text_encoders",
    "text_encoder",
    "text-encoders",
)
VAE_DIR_NAMES = ("VAE", "Vae", "vae")
LORA_DIR_NAMES = ("LoRA", "Lora", "lora", "LORA")
ANIMA_DIT_CACHE: dict[str, bool] = {}

STYLE_LABELS_JP = ["通常", "頭身変更あり（全身）"]
STYLE_LABELS_EN = [
    "Normal",
    "Full-body proportion change",
]
STYLE_EN_TO_JP = dict(zip(STYLE_LABELS_EN, STYLE_LABELS_JP))
STYLE_JP_TO_EN = {jp: en for en, jp in STYLE_EN_TO_JP.items()}
STYLE_JP_TO_BACKEND = {
    "通常": "実写写真（強推奨）",
    "頭身変更あり（全身）": "全身・ちびキャラ用",
}
STYLE_BACKEND_TO_JP = {backend: label for label, backend in STYLE_JP_TO_BACKEND.items()}
SPEED_LABELS_JP = list(settings.SPEED_PRESET_LABELS)
SPEED_LABELS_EN = [
    "Stable Training Set",
    "Standard Training Set",
    "Detailed Training Set",
]
SDXL_SPEED_LABELS_JP = [
    "SDXL安定学習セット",
    "SDXL通常学習セット",
    "SDXL詳細学習セット",
]
SDXL_SPEED_LABELS_EN = [
    "SDXL Stable Training Set",
    "SDXL Standard Training Set",
    "SDXL Detailed Training Set",
]
ANIMA_SPEED_LABELS_JP = [
    "Anima安定学習セット",
    "Anima通常学習セット",
    "Anima詳細学習セット",
]
ANIMA_SPEED_LABELS_EN = [
    "Anima Stable Training Set",
    "Anima Standard Training Set",
    "Anima Detailed Training Set",
]
SPEED_EN_TO_JP = dict(zip(SPEED_LABELS_EN, SPEED_LABELS_JP))
SPEED_EN_TO_JP["VRAM Saving Set"] = "安定学習セット"
SPEED_EN_TO_JP.update(dict(zip(SDXL_SPEED_LABELS_EN, SPEED_LABELS_JP)))
SPEED_EN_TO_JP.update(dict(zip(ANIMA_SPEED_LABELS_EN, SPEED_LABELS_JP)))
SPEED_JP_TO_EN = dict(zip(SPEED_LABELS_JP, SPEED_LABELS_EN))
SDXL_SPEED_JP_TO_BACKEND = dict(zip(SDXL_SPEED_LABELS_JP, SPEED_LABELS_JP))
SPEED_JP_TO_SDXL = dict(zip(SPEED_LABELS_JP, SDXL_SPEED_LABELS_JP))
SPEED_JP_TO_SDXL_EN = dict(zip(SPEED_LABELS_JP, SDXL_SPEED_LABELS_EN))
ANIMA_SPEED_JP_TO_BACKEND = dict(zip(ANIMA_SPEED_LABELS_JP, SPEED_LABELS_JP))
SPEED_JP_TO_ANIMA = dict(zip(SPEED_LABELS_JP, ANIMA_SPEED_LABELS_JP))
SPEED_JP_TO_ANIMA_EN = dict(zip(SPEED_LABELS_JP, ANIMA_SPEED_LABELS_EN))
SPEED_ALIASES_JP = {
    "VRAM節約セット": "安定学習セット",
    "低負荷（12GB向け）": "安定学習セット",
    "通常（16GB以上）": "通常学習セット",
    "高速（40分目標）": "通常学習セット",
    "高品質": "詳細学習セット",
    **SDXL_SPEED_JP_TO_BACKEND,
    **ANIMA_SPEED_JP_TO_BACKEND,
}
SPEED_DESCRIPTIONS_JP = {
    "安定学習セット": "SDXL向けの安定重視プリセット。batchを1にして、崩れにくさを優先しています",
    "通常学習セット": "SDXL向けの標準プリセット。まずここから試してください",
    "詳細学習セット": "SDXL向けの詳細プリセット。通常学習では顔立ちや塗りが学習しきれないとき用です",
}
SPEED_DESCRIPTIONS_EN = {
    "安定学習セット": "Stability-focused SDXL preset. Uses batch 1 to prioritize robustness.",
    "通常学習セット": "Standard SDXL preset. Start here.",
    "詳細学習セット": "Detailed SDXL preset for cases where standard training does not fully learn facial features or coloring.",
}
ANIMA_SPEED_DESCRIPTIONS_JP = {
    "安定学習セット": "Anima向けの安定重視プリセット。batchを1にして、学習時間も重くなりすぎないようstepsを抑えています",
    "通常学習セット": "Anima向けの通常プリセット。比較的高速",
    "詳細学習セット": "Anima向けの詳細プリセット。通常学習では顔立ちや塗りが学習しきれないとき用です",
}
ANIMA_SPEED_DESCRIPTIONS_EN = {
    "安定学習セット": "Stability-focused Anima preset. Uses batch 1 and reduced steps to avoid overly long training.",
    "通常学習セット": "Standard Anima preset. Relatively fast.",
    "詳細学習セット": "Detailed Anima preset for cases where standard training does not fully learn facial features or coloring.",
}
MODE_LABELS_JP = ["差分LoRA学習モード", "LoRAフュージョンモード", "学習履歴"]
MODE_LABELS_EN = ["Difference LoRA Training Mode", "LoRA Fusion Mode", "Training History"]
MODE_EN_TO_JP = dict(zip(MODE_LABELS_EN, MODE_LABELS_JP))
MODE_JP_TO_EN = {jp: en for en, jp in MODE_EN_TO_JP.items()}
FUSION_DIM_CHOICES = ("auto", "4", "8", "16", "32", "64")
FUSION_LORA_VISIBLE_LIMIT = 160
FUSION_LORA_MAX_SELECTION = 4
HISTORY_PAGE_SIZE = 10

UI_TEXT = {
    "JP": {
        "subtitle": "SDXL/Anima向け簡単コピー機LoRA学習アプリ",
        "fanbox_link": "スタジオ真榊FANBOX",
        "usage_link": "使い方",
        "terms_link": "利用規約",
        "mode_style": "差分LoRA学習モード",
        "mode_fusion": "LoRAフュージョンモード",
        "mode_history": "学習履歴",
        "style_preview_empty": "画像待ち",
        "comparison_preview_empty": "",
        "style_heading": "### 1. 覚えさせたい「お手本画像」",
        "style_intro": "まずここに覚えさせたい画風や概念の画像を読み込ませます。キャラクターの顔立ちをよく覚えさせたい場合は、バストアップで正面、シンプルなTシャツのイラストがおすすめ。画風以外に、線の太さや描きこみ量、胸の大きさなど、スライダーのように使える概念を差分として学習させることもできます。",
        "style_upload": "画像を読み込む",
        "style_wait": "覚えさせたい画像を1枚読み込ませてください。",
        "style_loaded": "読み込み完了。右側の案内を使って比較画像を作り、読み込ませてください。",
        "style_caption_label": "学習画像の内容タグ",
        "style_caption_placeholder": "タグ付けすると、ここに内容タグが入ります。必要なら自由に編集できます。",
        "comparison_heading": "### 2. 覚えさせたい概念だけが違う「比較画像」",
        "comparison_intro": "まず画面左側から覚えさせたい画風の画像を読み込ませてください。",
        "comparison_intro_ready": "左の画像と全く同じものが描かれていて、画風や概念だけが大きく異なる画像を入力してください。2枚の間で違っている概念（差分）が抽出され、LoRAとして学習できます。",
        "comparison_upload": "差分画像を読み込む",
        "comparison_wait_left": "左の画像を先に読み込ませます。",
        "comparison_wait": "",
        "comparison_loaded": "画像ペアの読み込み完了。タグ付けボタンを押してください",
        "comparison_caption_label": "差分画像の内容タグ（左と同じ）",
        "comparison_caption_placeholder": "タグ付けすると、左と同じ内容タグが入ります。必要なら自由に編集できます。",
        "guide_heading": "### 高品質な差分画像を作るには？",
        "guide_note": "画像編集ができるモデルに左の画像を入力し、以下のプロンプトで生成するのが簡単です。頭身や体型を変更する場合は「特殊体型用」を使ってください。画風以外の概念を抽出したい場合は「概念スライダー用」の○○部分を書き換え、学習させたい「線の太さ」や「胸の大きさ」といった概念だけが異なる画像を用意してください。",
        "direction": "コース",
        "normal_gpt_prompt": "通常用",
        "special_gpt_prompt": "特殊体型用",
        "concept_slider_gpt_prompt": "概念スライダー用",
        "copy_prompt": "プロンプトをコピー",
        "copy_normal_prompt": "通常用をコピー",
        "copy_special_prompt": "特殊体型用をコピー",
        "copy_concept_prompt": "概念スライダー用をコピー",
        "danbooru": "danbooruタグ案",
        "negative": "避けるタグ",
        "image_ai_prompt": "画像生成AIへの指示文",
        "tag_wait": "画像を2枚読み込むとタグ付けできます",
        "tag_ready": "タグ付けする",
        "tag_ready_detailed": "より詳細にタグ付けする",
        "tagging": "タグ付け中...",
        "tagging_detailed": "詳細タグ付け中...",
        "tagging_report": "タグ付け中です。画像を読んで内容タグを検出しています。\n初回はタグ推定モデルの読み込みで少し時間がかかります。",
        "tag_done": "### タグ付けのポイント\n左右の画像で共通する要素をタグとして自動検出しました。 正しい内容か確認し、不要なタグがあれば除去してください。足りないタグがあるとその要素も誤って一緒に学習されてしまいますので、不足を感じた場合は「より詳細にタグ付けする」ボタンを使ってみてください。（例：sweatを忘れると常に汗をかくLoRAになります）\n\n必要なのは、人物の容姿・服・髪型・構図を表すタグです。キャラ名のタグ、realisticやflat colorといった画風を表すタグ、意味の重複するタグは除去してください。例えば、white shirtがあればshirtは不要ですし、red bowがあればred ribbonは不要です。\n\n左右のプロンプトが全く同じで、それぞれの画像の内容と矛盾しないことを確認したら、この下の学習設定に進みます。",
        "tag_report_label": "タグ付けのポイント",
        "settings_heading": "### 学習設定",
        "settings_note": "これから学習するLoRAがSDXL用かAnima用かを選びます。",
        "models_heading": "### あなたのModelsフォルダ",
        "models_note": "普段ForgeなどのSDwebUIで使っているModelsフォルダを選びます。",
        "models_placeholder": "Modelsフォルダを選択してください",
        "models_required": "Modelsフォルダを選択してください。フォルダボタンから選べます。",
        "folder_open": "📁",
        "pick_models_title": "modelsフォルダを選択",
        "pick_output_title": "保存先フォルダを選択",
        "pick_anima_model_title": "Animaモデルを選択",
        "pick_qwen3_title": "Qwen3を選択",
        "pick_vae_title": "VAEを選択",
        "picked_anima_model": "Animaモデルを選択しました。",
        "picked_qwen3": "Qwen3を選択しました。",
        "picked_vae": "VAEを選択しました。",
        "image_display_error": "画像を表示できません",
        "load_models": "モデルを読み込み",
        "save_models": "Modelsフォルダを記憶",
        "models_loaded": "modelsフォルダを読み込みました。",
        "models_missing": "modelsフォルダが見つかりません。",
        "model_status_loaded": "モデル一覧を読み込み済み。",
        "model_status_empty": "modelsフォルダを選んでください。",
        "base_heading": "### 学習させるベースモデル",
        "base_note": "作ったLoRAを適用したいモデルか、そのベースモデルを選ぶのが基本。Animaの場合は、qwen_3_06b_base.safetensorsとqwen_image_vae.safetensorsの場所も指定してください。",
        "sdxl_model": "SDXLモデル",
        "sdxl_note": "",
        "save_sdxl": "SDXL向けのモデル設定を保存",
        "anima_model": "Animaモデル",
        "qwen3": "Qwen3",
        "vae": "VAE",
        "anima_note": "",
        "save_anima": "Anima向けのモデル設定を保存",
        "save_heading": "### LoRAの名前と保存先",
        "output_name": "学習するLoRA名",
        "save_folder": "保存先フォルダ",
        "output_placeholder": "LoRA保存先フォルダを選択してください",
        "output_required": "LoRA保存先フォルダを選択してください。フォルダボタンから選べます。",
        "save_output": "保存先を記憶",
        "speed": "学習プリセット",
        "speed_sdxl": "SDXL用学習プリセット",
        "speed_anima": "Anima用学習プリセット",
        "speed_note": "3種類のプリセットから学習設定を選びます。SDXL用とAnima用でそれぞれ別のプリセットです。下の学習設定タブで微調整できます",
        "advanced": "学習設定",
        "steps_label": "steps",
        "lr_label": "LR",
        "batch_label": "batch",
        "dim_label": "dim",
        "alpha_label": "alpha",
        "merge_strength": "差分強度",
        "vram_saving": "VRAM節約",
        "low_vram_label": "lowram（低VRAMモード）",
        "gradient_checkpointing_label": "gradient checkpointing",
        "color_focus_grayscale_reference": "カラーを重視（比較画像を白黒にして学習）",
        "steps_help": "学習を何回くり返すかです。数が多いほど画風を覚えやすくなりますが、時間も長くなります。多すぎると元画像の内容まで覚えすぎることがあります。",
        "lr_help": "1回ごとの学習の強さです。大きいほど早く効きますが、強すぎると線や色が荒れたり、余計な癖まで入りやすくなります。",
        "batch_help": "一度に処理する画像の数です。2にすると速くなりやすいですが、VRAMを多く使います。VRAM不足なら1にします。",
        "dim_help": "LoRAが画風を覚えるための入れ物の大きさです。大きいほど細かい顔立ちや塗りを覚えやすくなりますが、余計な特徴も拾いやすくなります。",
        "alpha_help": "覚えた内容をどれくらい効かせるかの目安です。dimとセットで考えます。alphaが小さいと控えめ、大きいと強めに効きます。",
        "merge_help": "1枚目のコピー機LoRAから2枚目のコピー機LoRAを引くときの強さです。大きいほど画風差分が強く出ますが、強すぎると崩れやすくなります。",
        "save_step_variants": "100ステップおきにLoRAを3パターン保存",
        "advanced_note": "",
        "start_heading": "### 学習開始",
        "start_note": "全ての準備ができたら、学習開始ボタンを押してください。学習は以下のフローで行われます。\n①まず左の画像しか生成できない「コピー機LoRA」を指定ステップ分学習します。\n②次に右の画像で同じようにコピー機LoRAを学習します。1つ目のLoRAとほぼ同じくらいの時間が掛かります。\n③最後に①と②の差分を抽出します。２つの画像の間で異なる概念が画風だけなら、左の画像の画風LoRAになります。",
        "phase_1": "1つめのコピー機LoRAを学習中（1/3）",
        "phase_2": "2つめのコピー機LoRAを学習中（2/3）",
        "phase_3": "差分を抽出して画風LoRAを作成中（3/3）",
        "train_wait": "タグ付け後に学習開始できます",
        "train_start": "学習開始",
        "train_stop": "学習停止",
        "starting": "起動中...",
        "log_heading": "### 学習ログ",
        "log_label": "進行ログ",
        "log_ready": "準備できたら、左右の画像を入れてタグ付けしてください。",
        "progress_waiting": "待機中",
        "progress_training": "学習中",
        "progress_stopped": "停止しました",
        "progress_complete": "完成",
        "progress_finished": "完了または終了",
        "progress_empty": "ログはここに表示されます。",
        "progress_phase": "工程",
        "progress_step": "step",
        "progress_loss": "loss",
        "progress_elapsed": "経過",
        "progress_eta": "残り",
        "progress_checkpoint": "保存",
        "progress_blank": "-",
        "progress_phase_1_short": "1/3: 左画像のLoRA学習中",
        "progress_phase_2_short": "2/3: 右画像のLoRA学習中",
        "progress_phase_3_short": "3/3: 2つの差分を計算中",
        "progress_phase_finished": "作業終了",
        "log_idle": "待機中です。",
        "log_omitted": "...前のログを省略...",
        "output_file": "完成LoRAの場所",
        "history_heading": "### 学習履歴",
        "history_note": "学習終了時に画像ペア、説明、LoRA名、状態を自動で記録します。バージョンアップ時は Copita/learning_history フォルダをコピーすると移行できます。",
        "history_select": "履歴",
        "history_empty": "まだ履歴がありません。",
        "history_detail": "履歴詳細",
        "history_style_image": "学習画像",
        "history_comparison_image": "差分画像",
        "history_result_image": "生成結果をここにドラッグ",
        "history_description": "説明",
        "history_result_note": "生成結果メモ",
        "history_page": "ページ",
        "history_image_a": "お手本画像",
        "history_image_b": "比較画像",
        "history_result_short": "LoRA生成例",
        "history_result_drop_short": "なし（LoRAの生成結果をドラッグして記録できます）",
        "history_status_completed": "完走",
        "history_status_stopped": "停止",
        "history_status_unknown": "状態未記録",
        "history_page_status": "{count}件中 {start}-{end}件を表示（{page}/{pages}）",
        "history_save": "履歴を保存",
        "history_saved": "履歴を保存しました。",
        "history_recorded": "学習履歴に記録しました。",
        "history_result_saved": "生成結果を記録しました。",
        "history_result_cleared": "LoRA生成例を消しました。",
        "history_select_required": "履歴を選択してください。",
        "history_delete": "削除",
        "history_delete_confirm": "この履歴を削除してもいいですか？",
        "history_delete_yes": "はい",
        "history_delete_no": "いいえ",
        "history_deleted": "履歴を削除しました。",
        "history_image_empty": "未記録",
        "history_lora_name": "LoRA名",
        "history_lora_path": "LoRAファイル",
        "history_created_at": "記録日",
        "history_model_type": "種類",
        "history_settings": "学習設定",
        "fusion_tab": "LoRAフュージョン",
        "fusion_heading": "### LoRAフュージョン",
        "fusion_note": '<div class="fusion-note"><p>過去に作った同じ目的のコピー機LoRAを2〜4個混ぜることで、副作用が少ないLoRAにするモードです。</p><p>Anima用LoRAとSDXL用LoRAを混ぜることはできません。</p><p>出力dimは、低いと狙った効果が弱くなりやすく、高いと絵柄や構図への副作用が出やすくなります。分からなかったらdim8あたりがおすすめ。控えめなら4、強めなら16も試してください。</p><p>LoRAの重みは自動で合計1になるよう計算されます。迷ったら全部1にすれば均等に混ざります。</p></div>',
        "fusion_lora_folder_heading": "### LoRAフォルダ",
        "fusion_lora_folder_note": "",
        "fusion_lora_folder_placeholder": "LoRAフォルダを選択してください",
        "fusion_save_folder": "LoRAフォルダを記憶",
        "fusion_loras": "混ぜるLoRA（2〜4個）",
        "fusion_selected_heading": "選択中のLoRA（重み1〜4の対応）",
        "fusion_selected_empty": "まだLoRAが選択されていません。混ぜたいLoRAを2〜4個選んでください。",
        "fusion_selected_trimmed": "5個以上は選べません。先頭4個だけ残しました。",
        "fusion_selected_weight": "重み",
        "fusion_selected_empty_slot": "未選択",
        "fusion_lora_filter": "LoRA名で絞り込み",
        "refresh_loras": "LoRA一覧を更新",
        "weight": "重み",
        "fusion_output_name": "出力LoRA名",
        "fusion_output_dims": "出力dim（複数可）",
        "fusion_dim_auto": "入力最大dim",
        "fusion_dim_error": "出力dimを1つ以上選んでください。",
        "fusion_start": "フュージョン開始",
        "fusion_result": "フュージョン結果",
        "lora_list_loaded": "LoRA一覧を読み込みました。",
        "lora_list_empty": "LoRA保存先にLoRAが見つかりません。",
        "language_label": "言語",
        "fusion_select_error": "LoRAを2〜4個選んでください。",
        "fusion_weight_error": "重みの合計が0より大きくなるようにしてください。",
        "fusion_output_exists_error": "同じ名前のLoRAがすでに保存先にあります。上書きはしません。出力LoRA名を変えるか、保存先を変更してください。\n既存ファイル: {path}",
        "fusion_output_exists_popup": "同じ名前のLoRAがすでにあります。フュージョン結果は上書きしません。出力LoRA名を変えるか、保存先を変更してください。",
        "fusion_missing": "LoRAファイルが見つかりません。",
        "fusion_structure_error": "LoRAの構造が違います。同じモデル種別・同じrankで作ったLoRAを選んでください。",
        "fusion_shape_error": "形が違います。同じrankのLoRAを選んでください。",
        "fusion_mixed_model_error": "Anima用LoRAとSDXL用LoRAは混ぜられません。同じ種類のLoRAだけを選んでください。",
        "fusion_relaxed_anima": "Anima LoRAの構造差を吸収するため、結合してからdim {rank}へ再圧縮する緩い平均で保存しました。",
        "fusion_relaxed_anima_multi": "Anima LoRAを結合後、指定dimごとに再圧縮して保存しました。",
        "fusion_failed": "フュージョンに失敗しました。",
        "fusion_complete": "フュージョン完了。",
        "fusion_progress_start": "フュージョンを開始しました。",
        "fusion_progress_selected": "選択LoRA: {sources}",
        "fusion_progress_loading": "LoRAを読み込んでいます...",
        "fusion_progress_loaded": "{count}個のLoRAを読み込みました。",
        "fusion_progress_checking": "LoRAの種類とrankを確認しています...",
        "fusion_progress_anima": "Anima LoRAとして処理します。入力最大dim: {rank}",
        "fusion_progress_scripts": "sd-scriptsのフュージョン処理を準備しています...",
        "fusion_progress_strict": "通常LoRAとして厳密平均します。",
        "fusion_progress_concat": "{label}: LoRAを結合しています...",
        "fusion_progress_resize": "{label}: dim {rank}へ再圧縮しています...",
        "fusion_progress_metadata": "{label}: メタデータを書き込んでいます...",
        "fusion_progress_saved": "{label}: 保存しました。{path}",
        "fusion_progress_average": "テンソルを平均しています...",
        "fusion_progress_save": "保存しています...",
        "saved_to": "保存先",
        "weights": "重み",
        "sdxl_saved": "SDXLのモデル設定を保存しました。",
        "anima_saved": "Anima向けのモデル設定を保存しました。",
        "no_training_to_stop": "停止できる学習はありません。",
        "stop_requested": "停止要求を送りました。現在の学習プロセスを終了しています。",
        "preparing": "学習準備中...",
        "auto_captioning": "キャプションを自動確認しています。",
        "training_launch": "学習を開始します。",
        "training_type": "種類",
        "training_style_image": "学習画像",
        "training_comparison_image": "比較画像",
        "training_output": "保存先",
        "training_speed": "速度設定",
        "training_vram_saving": "VRAM節約",
        "training_color_focus": "カラー重視: 比較画像を白黒化",
        "training_standard": "標準",
        "training_log_file": "学習ログファイル",
        "training_elapsed": "学習開始からの経過時間",
        "already_running": "すでに別の学習が動いています。停止してからもう一度開始してください。",
        "output_exists_error": "同じ名前のLoRAがすでに保存先にあります。上書きはしません。LoRA名を変えるか、保存先を変更してください。\n既存ファイル: {path}",
        "output_exists_popup": "同じ名前のLoRAがすでにあります。上書きはしません。LoRA名を変えるか、保存先を変更してください。",
        "result_read_error": "学習結果ファイルを読めませんでした。",
        "training_stopped": "学習を停止しました。",
        "process_exit": "学習プロセスが終了しました。終了コード",
        "process_no_result": "学習プロセスは終了しましたが、結果が見つかりませんでした。",
        "module_log_omitted": "Anima内部の詳細なモジュール設定ログを省略しています。",
        "module_log_live": "Anima内部の詳細ログを省略中...",
        "resize_log_omitted": "差分抽出の細かな層ごとのログを省略しています。",
        "resize_log_live": "差分抽出の詳細ログを省略中...",
        "training_setup_log_omitted": "学習準備の詳細ログを省略しています。",
        "training_step_log_omitted": "ステップごとの詳細ログを省略しています。進捗は上のバーで確認できます。",
        "training_heartbeat": "学習は動作中です。ログが止まって見えてもGPUで処理しています",
    },
    "EN": {
        "subtitle": "Easy Style LoRA Training from Image Differences for SDXL/Anima",
        "fanbox_link": "Studio Masakaki FANBOX",
        "usage_link": "Usage",
        "terms_link": "Terms",
        "mode_style": "Difference LoRA Training Mode",
        "mode_fusion": "LoRA Fusion Mode",
        "mode_history": "Training History",
        "style_preview_empty": "Waiting for image",
        "comparison_preview_empty": "",
        "style_heading": "### 1. Style Image",
        "style_intro": "Load the image whose style you want to learn. For face-focused style learning, a front-facing bust-up illustration works well. Simple T-shirt clothing is recommended.",
        "style_upload": "Load Image",
        "style_wait": "Load one image whose style you want to learn.",
        "style_loaded": "Loaded. Use the guide on the right to make and load the comparison image.",
        "style_caption_label": "Content tags for style image",
        "style_caption_placeholder": "After tagging, content tags appear here. You can edit them freely.",
        "comparison_heading": "### 2. Comparison Image With Different Style Only",
        "comparison_intro": "First, load the style image on the left.",
        "comparison_intro_ready": "Load a comparison image with exactly the same content as the left image, but with only the style changed.",
        "comparison_upload": "Load Comparison Image",
        "comparison_wait_left": "Load the left image first.",
        "comparison_wait": "",
        "comparison_loaded": "Image pair loaded. Press the tag button.",
        "comparison_caption_label": "Content tags for comparison image",
        "comparison_caption_placeholder": "After tagging, the same content tags appear here. You can edit them freely.",
        "guide_heading": "### How Do I Make an Image With Only the Style Changed?",
        "guide_note": "Load an image that shows the same thing as the left image, but in a different style. For example, use a realistic image. The easiest method is to load the left image into an image-editing model and generate with one of the prompts below. Use the special-body prompt if the proportions or body shape are unusual. Sexual images may be refused by hosted models, so img2img with a local realistic model can be an option.",
        "direction": "Course",
        "normal_gpt_prompt": "Normal",
        "special_gpt_prompt": "Special body type",
        "concept_slider_gpt_prompt": "Concept slider",
        "copy_prompt": "Copy Prompt",
        "copy_normal_prompt": "Copy Normal Prompt",
        "copy_special_prompt": "Copy Special-Body Prompt",
        "copy_concept_prompt": "Copy Concept-Slider Prompt",
        "danbooru": "danbooru tag draft",
        "negative": "Tags to avoid",
        "image_ai_prompt": "Instruction for image generation AI",
        "tag_wait": "Load two images to enable tagging",
        "tag_ready": "Tag Images",
        "tag_ready_detailed": "Tag More Details",
        "tagging": "Tagging...",
        "tagging_detailed": "Detailed tagging...",
        "tagging_report": "Tagging in progress. The app is reading both images and detecting content tags.\nThe first run may take a little while while the tagger loads.",
        "tag_done": "### Tagging Points\nThe app automatically detected tags for elements common to both images. Check that the tags are correct and remove unnecessary ones.\n\nKeep tags for the person's appearance, clothing, hairstyle, and composition. Remove style tags such as realistic or flat color, and remove duplicate-meaning tags. For example, if white shirt is present, shirt is unnecessary; if red bow is present, red ribbon is unnecessary.\n\nAfter confirming that the left and right prompts are exactly the same and do not contradict either image, continue to the training settings below.",
        "tag_report_label": "Tagging Points",
        "settings_heading": "### Training Settings",
        "settings_note": "Choose whether the LoRA you are about to train is for SDXL or Anima.",
        "models_heading": "### Your Models Folder",
        "models_note": "Select the Models folder you normally use in Forge or another SD web UI.",
        "models_placeholder": "Select your Models folder",
        "models_required": "Select your Models folder with the folder button.",
        "folder_open": "📁",
        "pick_models_title": "Select models folder",
        "pick_output_title": "Select output folder",
        "pick_anima_model_title": "Select Anima model",
        "pick_qwen3_title": "Select Qwen3",
        "pick_vae_title": "Select VAE",
        "picked_anima_model": "Selected Anima model.",
        "picked_qwen3": "Selected Qwen3.",
        "picked_vae": "Selected VAE.",
        "image_display_error": "Could not display image",
        "load_models": "Load Models",
        "save_models": "Remember Models Folder",
        "models_loaded": "Models folder loaded.",
        "models_missing": "Models folder not found.",
        "model_status_loaded": "Model list loaded.",
        "model_status_empty": "Select your models folder.",
        "base_heading": "### Base Model",
        "base_note": "Choose the model you want to use the finished LoRA with, or its base model. For Anima, also specify qwen_3_06b_base.safetensors and qwen_image_vae.safetensors.",
        "sdxl_model": "SDXL model",
        "sdxl_note": "",
        "save_sdxl": "Save SDXL Model Settings",
        "anima_model": "Anima model",
        "qwen3": "Qwen3",
        "vae": "VAE",
        "anima_note": "",
        "save_anima": "Save Anima Model Settings",
        "save_heading": "### LoRA Name and Output Folder",
        "output_name": "LoRA name",
        "save_folder": "Output folder",
        "output_placeholder": "Select the LoRA output folder",
        "output_required": "Select the LoRA output folder with the folder button.",
        "save_output": "Remember Output Folder",
        "speed": "Training Preset",
        "speed_sdxl": "SDXL Training Presets",
        "speed_anima": "Anima Training Presets",
        "speed_note": "Choose from 3 training presets. SDXL and Anima use separate preset sets. You can fine-tune details in the Training Settings tab below.",
        "advanced": "Training Settings",
        "steps_label": "steps",
        "lr_label": "LR",
        "batch_label": "batch",
        "dim_label": "dim",
        "alpha_label": "alpha",
        "merge_strength": "Difference strength",
        "vram_saving": "VRAM Saving",
        "low_vram_label": "lowram (low VRAM mode)",
        "gradient_checkpointing_label": "gradient checkpointing",
        "color_focus_grayscale_reference": "Prioritize color (train with grayscale comparison image)",
        "steps_help": "How many training iterations to run. More steps can learn the style better, but take longer. Too many steps can memorize the source content too strongly.",
        "lr_help": "How strongly each training step updates the LoRA. Higher values learn faster, but can make lines, colors, or unwanted habits unstable.",
        "batch_help": "How many images are processed at once. Batch 2 is often faster, but uses more VRAM. Use batch 1 when VRAM is tight.",
        "dim_help": "The size of the LoRA's capacity for storing style. Larger dim can learn finer facial features and coloring, but can also capture unwanted details.",
        "alpha_help": "How strongly the learned LoRA content is scaled. Think of it together with dim. Smaller alpha is gentler; larger alpha is stronger.",
        "merge_help": "How strongly the app extracts the difference between the first and second copy-style LoRAs. Larger values make the style difference stronger, but can become unstable.",
        "save_step_variants": "Save up to 3 LoRAs at 100-step intervals",
        "advanced_note": "",
        "start_heading": "### Start Training",
        "start_note": "When everything is ready, press the training start button. Training follows this flow.\n1. First, the app trains a copy-style LoRA that can generate only the left image for the selected steps.\n2. Next, it trains the same kind of copy-style LoRA from the right image. This takes almost as long as the first LoRA.\n3. Finally, it extracts the difference between 1 and 2. If the only different concept between the two images is the style, the result becomes the style LoRA for the left image.",
        "phase_1": "Training the first copy-style LoRA (1/3)",
        "phase_2": "Training the second copy-style LoRA (2/3)",
        "phase_3": "Extracting the difference style LoRA (3/3)",
        "train_wait": "Tag images to enable training",
        "train_start": "Start Training",
        "train_stop": "Stop Training",
        "starting": "Starting...",
        "log_heading": "### Training Log",
        "log_label": "Progress log",
        "log_ready": "When ready, load both images and tag them.",
        "progress_waiting": "Waiting",
        "progress_training": "Training",
        "progress_stopped": "Stopped",
        "progress_complete": "Complete",
        "progress_finished": "Finished or ended",
        "progress_empty": "Logs will appear here.",
        "progress_phase": "Phase",
        "progress_step": "step",
        "progress_loss": "loss",
        "progress_elapsed": "Elapsed",
        "progress_eta": "ETA",
        "progress_checkpoint": "Saved",
        "progress_blank": "-",
        "progress_phase_1_short": "1/3: Training left-image LoRA",
        "progress_phase_2_short": "2/3: Training right-image LoRA",
        "progress_phase_3_short": "3/3: Calculating the difference",
        "progress_phase_finished": "Finished",
        "log_idle": "Waiting.",
        "log_omitted": "...earlier log omitted...",
        "output_file": "Finished LoRA path",
        "history_heading": "### Training History",
        "history_note": "Training runs are recorded with the image pair, description, LoRA name, and status. To migrate during an update, copy the Copita/learning_history folder.",
        "history_select": "History",
        "history_empty": "No history yet.",
        "history_detail": "History Detail",
        "history_style_image": "Training image",
        "history_comparison_image": "Comparison image",
        "history_result_image": "Drag a generation result here",
        "history_description": "Description",
        "history_result_note": "Generation result note",
        "history_page": "Page",
        "history_image_a": "Reference Image",
        "history_image_b": "Comparison Image",
        "history_result_short": "LoRA Sample",
        "history_result_drop_short": "None (drag a LoRA generation result here to save it)",
        "history_status_completed": "Completed",
        "history_status_stopped": "Stopped",
        "history_status_unknown": "Status not recorded",
        "history_page_status": "Showing {start}-{end} of {count} ({page}/{pages})",
        "history_save": "Save History",
        "history_saved": "History saved.",
        "history_recorded": "Recorded to training history.",
        "history_result_saved": "Generation result recorded.",
        "history_result_cleared": "LoRA sample cleared.",
        "history_select_required": "Select a history item.",
        "history_delete": "Delete",
        "history_delete_confirm": "Delete this history item?",
        "history_delete_yes": "Yes",
        "history_delete_no": "No",
        "history_deleted": "History item deleted.",
        "history_image_empty": "Not recorded",
        "history_lora_name": "LoRA name",
        "history_lora_path": "LoRA file",
        "history_created_at": "Recorded",
        "history_model_type": "Type",
        "history_settings": "Training settings",
        "fusion_tab": "LoRA Fusion",
        "fusion_heading": "### LoRA Fusion",
        "fusion_note": '<div class="fusion-note"><p>Fuse 2 to 4 copy-style LoRAs made for the same purpose into a LoRA with fewer side effects.</p><p>Anima LoRAs and SDXL LoRAs cannot be mixed.</p><p>Lower output dim can make the intended effect too weak; higher dim can bring back style or composition side effects. If unsure, start around dim 8. Try 4 for a gentler LoRA or 16 for a stronger one.</p><p>Weights are normalized automatically to add up to 1. Set all weights to 1 for an even mix.</p></div>',
        "fusion_lora_folder_heading": "### LoRA Folder",
        "fusion_lora_folder_note": "",
        "fusion_lora_folder_placeholder": "Select a LoRA folder",
        "fusion_save_folder": "Remember LoRA Folder",
        "fusion_loras": "LoRAs to fuse (2 to 4)",
        "fusion_selected_heading": "Selected LoRAs (Weight 1 to 4)",
        "fusion_selected_empty": "No LoRA selected yet. Select 2 to 4 LoRAs to fuse.",
        "fusion_selected_trimmed": "You can select up to 4 LoRAs. Only the first 4 were kept.",
        "fusion_selected_weight": "Weight",
        "fusion_selected_empty_slot": "Not selected",
        "fusion_lora_filter": "Filter LoRA names",
        "refresh_loras": "Refresh LoRA List",
        "weight": "Weight",
        "fusion_output_name": "Output LoRA name",
        "fusion_output_dims": "Output dim (multiple allowed)",
        "fusion_dim_auto": "Input max dim",
        "fusion_dim_error": "Select at least one output dim.",
        "fusion_start": "Start Fusion",
        "fusion_result": "Fusion Result",
        "lora_list_loaded": "LoRA list loaded.",
        "lora_list_empty": "No LoRAs found in the output folder.",
        "language_label": "Language",
        "fusion_select_error": "Select 2 to 4 LoRAs.",
        "fusion_weight_error": "Weights must add up to more than 0.",
        "fusion_output_exists_error": "A LoRA with the same name already exists in the output folder. Copita will not overwrite it. Change the output LoRA name or choose another output folder.\nExisting file: {path}",
        "fusion_output_exists_popup": "A LoRA with the same name already exists. Copita will not overwrite the fusion result. Change the output LoRA name or choose another output folder.",
        "fusion_missing": "Missing LoRA file.",
        "fusion_structure_error": "LoRA structures differ. Use LoRAs made for the same model type and rank.",
        "fusion_shape_error": "Shape mismatch. Use LoRAs with the same rank.",
        "fusion_mixed_model_error": "Anima LoRAs and SDXL LoRAs cannot be mixed. Select only one LoRA type.",
        "fusion_relaxed_anima": "Saved with relaxed Anima averaging by concatenating the LoRAs and recompressing to dim {rank}.",
        "fusion_relaxed_anima_multi": "Saved Anima LoRAs by concatenating them and recompressing once per selected dim.",
        "fusion_failed": "Fusion failed.",
        "fusion_complete": "Fusion complete.",
        "fusion_progress_start": "Fusion started.",
        "fusion_progress_selected": "Selected LoRAs: {sources}",
        "fusion_progress_loading": "Loading LoRAs...",
        "fusion_progress_loaded": "Loaded {count} LoRAs.",
        "fusion_progress_checking": "Checking LoRA type and rank...",
        "fusion_progress_anima": "Processing as Anima LoRAs. Input max dim: {rank}",
        "fusion_progress_scripts": "Preparing sd-scripts fusion tools...",
        "fusion_progress_strict": "Processing as normal LoRAs with strict averaging.",
        "fusion_progress_concat": "{label}: concatenating LoRAs...",
        "fusion_progress_resize": "{label}: recompressing to dim {rank}...",
        "fusion_progress_metadata": "{label}: writing metadata...",
        "fusion_progress_saved": "{label}: saved. {path}",
        "fusion_progress_average": "Averaging tensors...",
        "fusion_progress_save": "Saving...",
        "saved_to": "Saved",
        "weights": "Weights",
        "sdxl_saved": "SDXL model settings saved.",
        "anima_saved": "Anima selection saved.",
        "no_training_to_stop": "No training job is running.",
        "stop_requested": "Stop requested. The current training process is ending.",
        "preparing": "Preparing training...",
        "auto_captioning": "Checking captions automatically.",
        "training_launch": "Starting training.",
        "training_type": "Type",
        "training_style_image": "Style image",
        "training_comparison_image": "Comparison image",
        "training_output": "Output folder",
        "training_speed": "Speed preset",
        "training_vram_saving": "VRAM saving",
        "training_color_focus": "Color priority: grayscale comparison image",
        "training_standard": "Standard",
        "training_log_file": "Training log file",
        "training_elapsed": "Elapsed since training start",
        "already_running": "Another training job is already running. Stop it before starting again.",
        "output_exists_error": "A LoRA with the same name already exists in the output folder. Copita will not overwrite it. Change the LoRA name or choose another output folder.\nExisting file: {path}",
        "output_exists_popup": "A LoRA with the same name already exists. Copita will not overwrite it. Change the LoRA name or choose another output folder.",
        "result_read_error": "Could not read the training result file.",
        "training_stopped": "Training stopped.",
        "process_exit": "Training process ended. Exit code",
        "process_no_result": "Training process ended, but no result was found.",
        "module_log_omitted": "Detailed Anima module setup logs are hidden.",
        "module_log_live": "Hiding detailed Anima logs...",
        "resize_log_omitted": "Detailed layer-by-layer extraction logs are hidden.",
        "resize_log_live": "Hiding detailed extraction logs...",
        "training_setup_log_omitted": "Detailed training setup logs are hidden.",
        "training_step_log_omitted": "Per-step logs are hidden. Check the progress bar above.",
        "training_heartbeat": "Training is still running. The GPU may be working even when logs are quiet",
    },
}

CSS = """
html,
body,
#root {
    background: #0f0f12 !important;
    color: #f4f4f5 !important;
    color-scheme: dark;
}
.gradio-container {
    --body-background-fill: #0f0f12;
    --body-background-fill-subdued: #111113;
    --body-text-color: #f4f4f5;
    --body-text-color-subdued: #d4d4d8;
    --background-fill-primary: #0f0f12;
    --background-fill-secondary: #18181b;
    --block-background-fill: #18181b;
    --block-border-color: #3f3f46;
    --block-border-width: 1px;
    --border-color-primary: #3f3f46;
    --border-color-accent: #2dd4bf;
    --input-background-fill: #3f3f46;
    --input-background-fill-focus: #3f3f46;
    --input-border-color: #52525b;
    --input-border-color-focus: #2dd4bf;
    --input-placeholder-color: #a1a1aa;
    --button-secondary-background-fill: #3f3f46;
    --button-secondary-background-fill-hover: #52525b;
    --button-secondary-border-color: #52525b;
    --button-secondary-text-color: #ffffff;
    --button-primary-background-fill: #0d9488;
    --button-primary-background-fill-hover: #0f766e;
    --button-primary-border-color: #2dd4bf;
    --button-primary-text-color: #ffffff;
    --copita-active-bg: #0d9488;
    --copita-active-border: #2dd4bf;
    --copita-active-hover-bg: #0f766e;
    --copita-active-hover-border: #5eead4;
    background: #0f0f12 !important;
    color: #f4f4f5 !important;
    width: min(86vw, 1720px) !important;
    max-width: 1720px !important;
    margin: 0 auto !important;
    padding-left: 0 !important;
    padding-right: 0 !important;
    overflow-x: visible !important;
    min-height: 100vh !important;
    box-sizing: border-box !important;
    font-family: "Yu Gothic UI", "Meiryo", "Segoe UI", Arial, sans-serif !important;
    font-size: 18px !important;
    line-height: 1.55 !important;
}
.gradio-container .main,
.gradio-container main,
.gradio-container .contain,
.gradio-container .wrap,
.gradio-container .block,
.gradio-container .form,
.gradio-container .gap {
    background-color: transparent !important;
    color: #f4f4f5 !important;
}
.gradio-container {
    background-color: #0f0f12 !important;
    box-shadow: 0 0 0 100vmax #0f0f12 !important;
    clip-path: inset(0 -100vmax) !important;
}
body > gradio-app hr,
body > gradio-app .divider,
.gradio-container hr,
.gradio-container .divider {
    display: none !important;
    border: 0 !important;
    background: transparent !important;
}
body > gradio-app footer,
body > gradio-app .footer,
body > gradio-app [data-testid="footer"],
.gradio-container footer,
.gradio-container .footer,
.gradio-container [data-testid="footer"] {
    display: none !important;
    visibility: hidden !important;
    height: 0 !important;
    min-height: 0 !important;
    margin: 0 !important;
    padding: 0 !important;
    overflow: hidden !important;
    background: #0f0f12 !important;
    border: 0 !important;
}
body > gradio-app .toast-body.error,
body > gradio-app .toast-item:has(.toast-body.error),
body > gradio-app .toast-wrap:has(.toast-body.error),
.toast-body.error,
.toast-item:has(.toast-body.error),
.toast-wrap:has(.toast-body.error),
.gradio-container .toast-body.error,
.gradio-container .toast-item:has(.toast-body.error),
.gradio-container .toast-wrap:has(.toast-body.error) {
    display: none !important;
    visibility: hidden !important;
    height: 0 !important;
    min-height: 0 !important;
    margin: 0 !important;
    padding: 0 !important;
    overflow: hidden !important;
    border: 0 !important;
    background: transparent !important;
    box-shadow: none !important;
}
.gradio-container:has(.mode-tabs .wrap > label:nth-of-type(2) input:checked) {
    --copita-active-bg: #0e7490;
    --copita-active-border: #22d3ee;
    --copita-active-hover-bg: #155e75;
    --copita-active-hover-border: #67e8f9;
}
.gradio-container:has(.mode-tabs .wrap > label:nth-of-type(3) input:checked) {
    --copita-active-bg: #7c3aed;
    --copita-active-border: #a78bfa;
    --copita-active-hover-bg: #6d28d9;
    --copita-active-hover-border: #c4b5fd;
}
.gradio-container *,
.gradio-container *::before,
.gradio-container *::after {
    box-sizing: border-box;
    font-family: inherit !important;
}
.gradio-container p,
.gradio-container li,
.gradio-container label,
.gradio-container span,
.gradio-container input,
.gradio-container textarea,
.gradio-container select,
.gradio-container button {
    font-size: 1rem !important;
    line-height: 1.5 !important;
}
.gradio-container h1,
.gradio-container h2,
.gradio-container h3,
.gradio-container h4,
.gradio-container h5,
.gradio-container h6 {
    color: #f4f4f5 !important;
}
.gradio-container p,
.gradio-container li {
    color: #d4d4d8 !important;
}
.gradio-container label,
.gradio-container span {
    color: inherit !important;
}
.gradio-container input,
.gradio-container textarea,
.gradio-container select {
    background: #3f3f46 !important;
    border-color: #52525b !important;
    color: #f4f4f5 !important;
}
.gradio-container input::placeholder,
.gradio-container textarea::placeholder {
    color: #a1a1aa !important;
    opacity: 1 !important;
}
.gradio-container input[type="checkbox"] {
    appearance: auto !important;
    -webkit-appearance: checkbox !important;
    accent-color: var(--copita-active-bg) !important;
    background: revert !important;
    border: revert !important;
    width: 18px !important;
    height: 18px !important;
    min-width: 18px !important;
    min-height: 18px !important;
}
.gradio-container button {
    background: #3f3f46 !important;
    border: 1px solid #52525b !important;
    color: #ffffff !important;
}
.gradio-container button:hover {
    background: #52525b !important;
    border-color: #71717a !important;
    color: #ffffff !important;
}
.gradio-container [role="listbox"],
.gradio-container [role="option"],
.gradio-container .dropdown,
.gradio-container .secondary-wrap {
    background: #3f3f46 !important;
    border-color: #52525b !important;
    color: #f4f4f5 !important;
}
.gradio-container .label-wrap,
.gradio-container span[data-testid="block-label"],
.gradio-container .block-label,
.gradio-container span.svelte-jdcl7l {
    background: var(--copita-active-bg) !important;
    border: 1px solid var(--copita-active-border) !important;
    color: #ffffff !important;
    font-weight: 800 !important;
    border-radius: 5px !important;
}
.gradio-container .label-wrap *,
.gradio-container span[data-testid="block-label"] *,
.gradio-container .block-label * {
    color: #ffffff !important;
}
.gradio-container button.label-wrap {
    background: #3f3f46 !important;
    border-color: #52525b !important;
    color: #f4f4f5 !important;
}
.gradio-container button.label-wrap * {
    color: #f4f4f5 !important;
}
.gradio-container details > summary,
.gradio-container details > summary *,
.gradio-container .accordion,
.gradio-container .accordion *,
.gradio-container [data-testid="accordion"],
.gradio-container [data-testid="accordion"] * {
    background: #27272a !important;
    border-color: #3f3f46 !important;
    color: #f4f4f5 !important;
}
.gradio-container > .main,
.gradio-container main,
.gradio-container .contain,
.gradio-container .wrap,
.gradio-container .block,
.gradio-container .gap {
    max-width: none !important;
}
.gradio-container .contain {
    width: 100% !important;
    max-width: 100% !important;
}
.app-title h1 {
    font-size: 3.25rem !important;
    letter-spacing: 0 !important;
    margin: 0 0 0.15rem 0 !important;
}
.app-subtitle {
    color: #a1a1aa;
    font-size: 1.05rem !important;
    line-height: 1.5;
    margin-bottom: 1.25rem;
}
.app-subtitle p {
    margin: 0.1rem 0 !important;
}
.app-subtitle a {
    color: #67e8f9;
    font-weight: 700;
    text-decoration: none;
}
.app-subtitle a:hover {
    text-decoration: underline;
}
.gradio-container h2,
.gradio-container h3 {
    font-size: 1.28rem !important;
    line-height: 1.35 !important;
}
.top-bar {
    align-items: flex-start !important;
    gap: 16px !important;
    margin-bottom: 1.2rem;
    padding-right: 170px !important;
    overflow: visible !important;
    background: transparent !important;
    border: 0 !important;
    box-shadow: none !important;
    outline: 0 !important;
}
.title-stack {
    min-width: 0 !important;
    overflow: visible !important;
    background: transparent !important;
    border: 0 !important;
    box-shadow: none !important;
    outline: 0 !important;
    margin-bottom: 1.2rem !important;
    padding-right: 170px !important;
}
.title-stack *,
.top-bar *,
.language-toggle,
.language-toggle * {
    scrollbar-width: none !important;
    -ms-overflow-style: none !important;
}
.title-stack *::-webkit-scrollbar,
.top-bar *::-webkit-scrollbar,
.language-toggle::-webkit-scrollbar,
.language-toggle *::-webkit-scrollbar {
    display: none !important;
    width: 0 !important;
    height: 0 !important;
}
.app-title,
.app-subtitle,
.app-title.block,
.app-subtitle.block,
.app-title.hide-container,
.app-subtitle.hide-container,
.app-title .prose,
.app-subtitle .prose,
.title-stack > .block,
.title-stack > .form,
.title-stack > div {
    background: transparent !important;
    border: 0 !important;
    border-color: transparent !important;
    border-width: 0 !important;
    box-shadow: none !important;
    outline: 0 !important;
    padding: 0 !important;
    overflow: visible !important;
    min-height: 0 !important;
}
.app-title::before,
.app-title::after,
.app-subtitle::before,
.app-subtitle::after,
.title-stack::before,
.title-stack::after {
    display: none !important;
    content: none !important;
}
.language-toggle {
    position: fixed !important;
    top: 14px !important;
    right: max(18px, calc((100vw - min(86vw, 1720px)) / 2)) !important;
    z-index: 1000 !important;
    width: 142px !important;
    max-width: 142px !important;
    padding: 6px 8px !important;
    background: #18181b !important;
    border: 1px solid #3f3f46 !important;
    border-radius: 8px !important;
    overflow: hidden !important;
}
.language-toggle .wrap {
    display: flex !important;
    flex-direction: row !important;
    gap: 8px !important;
    overflow: hidden !important;
}
.language-toggle label {
    min-width: 50px !important;
    justify-content: center !important;
    overflow: hidden !important;
}
.language-toggle label {
    background: #3f3f46 !important;
    border-color: #52525b !important;
    color: #ffffff !important;
    font-weight: 800 !important;
}
.language-toggle label:has(input:checked) {
    background: var(--copita-active-bg) !important;
    border-color: var(--copita-active-border) !important;
    color: #ffffff !important;
}
.mode-tabs {
    width: 100% !important;
    margin: 0 0 24px 0 !important;
    background: transparent !important;
    border: 0 !important;
    border-radius: 0 !important;
    padding: 0 !important;
    min-height: 0 !important;
    box-shadow: none !important;
    overflow: visible !important;
}
.gradio-container .form:has(> .mode-tabs),
.gradio-container .form:has(fieldset.mode-tabs) {
    background: transparent !important;
    border: 0 !important;
    box-shadow: none !important;
    overflow: visible !important;
}
.mode-tabs .wrap {
    margin: 0 !important;
    padding: 0 !important;
    background: transparent !important;
    border: 0 !important;
    min-height: 0 !important;
    box-shadow: none !important;
}
.mode-tabs .wrap:has(> label) {
    display: grid !important;
    grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
    gap: 8px !important;
    background: #18181b !important;
    border: 1px solid #3f3f46 !important;
    border-radius: 10px !important;
    padding: 8px !important;
}
.mode-tabs fieldset,
.mode-tabs .form {
    margin: 0 !important;
    padding: 0 !important;
    background: transparent !important;
    border: 0 !important;
    min-height: 0 !important;
    box-shadow: none !important;
}
.mode-tabs label {
    min-height: 58px !important;
    border-radius: 8px !important;
    justify-content: center !important;
    font-size: 1.2rem !important;
    font-weight: 800 !important;
    border: 1px solid #3f3f46 !important;
    color: #e4e4e7 !important;
}
.mode-tabs label span {
    color: inherit !important;
}
.mode-tabs .wrap > label:nth-of-type(1) {
    background: #3f3f46 !important;
    border-color: #3f3f46 !important;
    color: #ffffff !important;
}
.mode-tabs .wrap > label:nth-of-type(2) {
    background: #3f3f46 !important;
    border-color: #3f3f46 !important;
    color: #ffffff !important;
}
.mode-tabs .wrap > label:nth-of-type(3) {
    background: #3f3f46 !important;
    border-color: #3f3f46 !important;
    color: #ffffff !important;
}
.mode-tabs .wrap > label:nth-of-type(1):hover {
    background: #52525b !important;
    border-color: #71717a !important;
    color: #ffffff !important;
}
.mode-tabs .wrap > label:nth-of-type(2):hover {
    background: #52525b !important;
    border-color: #71717a !important;
    color: #ffffff !important;
}
.mode-tabs .wrap > label:nth-of-type(3):hover {
    background: #52525b !important;
    border-color: #71717a !important;
    color: #ffffff !important;
}
.mode-tabs .wrap > label:nth-of-type(1):has(input:checked) {
    background: var(--copita-active-bg) !important;
    border-color: var(--copita-active-border) !important;
    color: #ffffff !important;
    box-shadow: 0 0 0 1px rgba(94, 234, 212, 0.35) inset !important;
}
.mode-tabs .wrap > label:nth-of-type(2):has(input:checked) {
    background: #0e7490 !important;
    border-color: #22d3ee !important;
    color: #ffffff !important;
    box-shadow: 0 0 0 1px rgba(103, 232, 249, 0.35) inset !important;
}
.mode-tabs .wrap > label:nth-of-type(3):has(input:checked) {
    background: #7c3aed !important;
    border-color: #a78bfa !important;
    color: #ffffff !important;
    box-shadow: 0 0 0 1px rgba(196, 181, 253, 0.35) inset !important;
}
.mode-tabs input[type="radio"] {
    display: none !important;
}
.mode-workspace {
    width: 100% !important;
}
#copita_training_workspace.copita-workspace-visible,
#copita_fusion_workspace.copita-workspace-visible,
#copita_history_workspace.copita-workspace-visible {
    display: block !important;
}
#copita_training_workspace.copita-workspace-hidden,
#copita_fusion_workspace.copita-workspace-hidden,
#copita_history_workspace.copita-workspace-hidden {
    display: none !important;
}
.mode-workspace,
.mode-workspace:focus,
.mode-workspace:focus-visible,
.mode-workspace:focus-within,
.mode-workspace > .form,
.mode-workspace > .form:focus,
.mode-workspace > .form:focus-visible,
.mode-workspace > .form:focus-within {
    outline: none !important;
    box-shadow: none !important;
    border-color: transparent !important;
}
#copita_mode_loading_overlay {
    position: fixed;
    left: 50%;
    top: 50%;
    z-index: 2800;
    transform: translate(-50%, -50%);
    opacity: 0;
    pointer-events: none;
    transition: opacity 120ms ease;
}
body.copita-mode-loading #copita_mode_loading_overlay {
    opacity: 1;
    pointer-events: auto;
}
.mode-loading-box {
    min-width: 210px;
    border: 1px solid #52525b;
    border-radius: 8px;
    background: rgba(24, 24, 27, 0.94);
    box-shadow: 0 18px 44px rgba(0, 0, 0, 0.42);
    color: #f4f4f5;
    padding: 16px 18px;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 10px;
    font-weight: 900;
}
.mode-loading-spinner {
    width: 18px;
    height: 18px;
    border-radius: 999px;
    border: 3px solid #3f3f46;
    border-top-color: var(--copita-active-border);
    animation: copita-spin 0.85s linear infinite;
}
@keyframes copita-spin {
    to {
        transform: rotate(360deg);
    }
}
.flow-section {
    margin-top: 8px;
}
.pair-grid {
    align-items: stretch !important;
    gap: 18px !important;
}
.pair-column {
    min-height: 100%;
}
.pair-card,
.guide-panel,
.settings-panel,
.start-panel,
.wide-log-panel {
    background: #18181b;
    border: 1px solid #3f3f46;
    border-radius: 8px;
    padding: 16px;
}
.pair-card {
    min-height: 660px;
    display: flex;
    flex-direction: column;
}
.pair-card > .block {
    width: 100% !important;
}
.pair-card h2,
.settings-panel h2,
.wide-log-panel h2 {
    margin-top: 0 !important;
}
.pair-step {
    color: #f4f4f5;
    font-size: 1.22rem !important;
    font-weight: 700;
    margin: 0 0 10px 0;
}
.image-file label {
    font-weight: 700 !important;
}
.image-file {
    margin-bottom: 10px;
}
#style_image_input,
#comparison_image_input,
.image-input {
    margin: 12px 0 10px 0 !important;
}
#style_image_input,
#comparison_image_input {
    position: relative !important;
    min-height: 520px !important;
}
#style_image_input .image-container,
#comparison_image_input .image-container,
#style_image_input .image-frame,
#comparison_image_input .image-frame {
    width: 100% !important;
    height: 520px !important;
    min-height: 520px !important;
    max-height: 520px !important;
}
#style_image_input .image-container,
#comparison_image_input .image-container,
#style_image_input .image-frame,
#comparison_image_input .image-frame {
    position: relative !important;
    display: flex !important;
    align-items: center !important;
    justify-content: center !important;
    overflow: hidden !important;
}
#style_image_input img,
#comparison_image_input img,
#style_image_input .image-frame img,
#comparison_image_input .image-frame img,
#style_image_input img[class*="svelte"],
#comparison_image_input img[class*="svelte"] {
    width: 100% !important;
    height: 100% !important;
    max-width: 100% !important;
    max-height: 520px !important;
    object-fit: cover !important;
    object-position: center center !important;
}
#style_image_input .wrap,
#comparison_image_input .wrap {
    color: #f4f4f5 !important;
    text-align: center !important;
}
#style_image_input .upload-container,
#comparison_image_input .upload-container,
#style_image_input [data-testid="file-upload"],
#comparison_image_input [data-testid="file-upload"] {
    min-height: 520px !important;
    border-radius: 8px !important;
}
#style_image_input .icon-button-wrapper,
#comparison_image_input .icon-button-wrapper {
    position: absolute !important;
    top: 8px !important;
    right: 8px !important;
    bottom: auto !important;
    left: auto !important;
    transform: none !important;
}
.pair-note {
    min-height: 112px;
    display: flex;
    align-items: flex-start;
}
.pair-note p {
    margin: 0 !important;
}
.pair-status-message {
    min-height: 44px !important;
    margin-top: 12px !important;
    color: #f4f4f5 !important;
    font-weight: 700 !important;
    display: flex !important;
    align-items: flex-start !important;
}
.pair-status-message p {
    margin: 0 !important;
}
.app-version-footer {
    margin: 44px 0 18px 0 !important;
    text-align: center !important;
    color: #a1a1aa !important;
}
.app-version-footer p {
    margin: 0 !important;
    color: #a1a1aa !important;
    font-size: 0.92rem !important;
    line-height: 1.4 !important;
}
.guide-panel {
    margin-top: 18px;
    background: transparent !important;
    border: 0 !important;
    padding: 0 !important;
}
.prompt-choice-panel {
    margin-top: 12px !important;
}
.prompt-choice-note {
    margin: 4px 0 10px 0 !important;
}
.prompt-choice-note p {
    margin: 0 !important;
    color: #d4d4d8 !important;
    line-height: 1.6 !important;
}
.prompt-choice-panel textarea {
    min-height: 78px !important;
    max-height: 96px !important;
    font-size: 0.9rem !important;
    line-height: 1.45 !important;
}
.prompt-header-row {
    display: flex !important;
    flex-direction: row !important;
    flex-wrap: nowrap !important;
    align-items: center !important;
    gap: 8px !important;
    margin: 10px 0 4px 0 !important;
}
.prompt-header-row > .block,
.prompt-header-row > .form,
.prompt-header-row > div {
    min-width: 0 !important;
}
.prompt-header-row > .block:first-child,
.prompt-header-row > div:first-child {
    flex: 1 1 auto !important;
    width: auto !important;
}
.prompt-header-row > .block:last-child,
.prompt-header-row > div:last-child {
    flex: 0 0 auto !important;
    width: auto !important;
}
.prompt-inline-label {
    flex: 1 1 auto !important;
    min-width: 0 !important;
}
.prompt-inline-label p {
    display: inline-block !important;
    margin: 0 !important;
    padding: 3px 8px !important;
    background: var(--copita-active-bg) !important;
    border-radius: 5px !important;
    color: #ffffff !important;
    font-weight: 800 !important;
    line-height: 1.35 !important;
}
.prompt-copy-button {
    flex: 0 0 auto !important;
    width: auto !important;
    min-width: 0 !important;
    margin: 0 !important;
}
.prompt-copy-button button {
    min-height: 24px !important;
    width: auto !important;
    min-width: 0 !important;
    padding: 2px 8px !important;
    font-size: 0.78rem !important;
    font-weight: 800 !important;
}
.image-preview-wrap {
    margin-top: 12px !important;
}
.comparison-preview {
    margin-top: 12px !important;
}
.image-preview-wrap {
    position: relative !important;
}
.preview-clear-button {
    position: absolute !important;
    top: 10px !important;
    right: 10px !important;
    z-index: 8 !important;
    width: 34px !important;
    min-width: 34px !important;
    margin: 0 !important;
}
.preview-clear-button button {
    width: 34px !important;
    min-width: 34px !important;
    height: 34px !important;
    min-height: 34px !important;
    padding: 0 !important;
    border-radius: 999px !important;
    font-size: 1.25rem !important;
    line-height: 1 !important;
    background: #3f3f46 !important;
    border: 1px solid #71717a !important;
    color: #ffffff !important;
}
.preview-clear-button button:hover {
    background: #52525b !important;
    border-color: #a1a1aa !important;
}
.guide-card {
    background: #202124;
    border: 1px solid #3f3f46;
    border-radius: 6px;
    padding: 12px;
}
.tag-action-row {
    margin: 18px 0 8px 0;
    justify-content: center !important;
}
.tag-result-grid {
    margin-top: 14px;
    gap: 18px !important;
    align-items: stretch !important;
}
.tag-result-card {
    background: #202124;
    border: 1px solid #3f3f46;
    border-radius: 6px;
    padding: 12px;
    min-height: 190px;
}
.tag-result-card textarea {
    min-height: 120px !important;
}
.tag-textbox {
    margin-top: 12px;
}
.tag-textbox textarea {
    min-height: 120px !important;
}
.tag-report-panel {
    margin-top: 12px !important;
    padding: 14px !important;
    background: #202124 !important;
    border: 1px solid #3f3f46 !important;
    border-radius: 6px !important;
    color: #e4e4e7 !important;
}
.tag-report-panel h3 {
    margin-top: 0 !important;
}
.tag-report-panel p {
    margin: 0.45rem 0 !important;
    line-height: 1.65 !important;
}
.tag-action-row button,
.start-panel button {
    min-height: 48px !important;
    font-weight: 700 !important;
}
.tag-action-row button {
    max-width: 520px !important;
}
.settings-panel {
    margin-top: 18px;
}
.settings-grid {
    gap: 16px !important;
}
.settings-grid > div {
    min-width: 0 !important;
}
.button-row {
    gap: 8px !important;
}
.button-row button {
    min-height: 46px !important;
    font-weight: 700 !important;
}
.training-action-grid {
    margin-top: 16px;
}
.model-type-tabs {
    margin: 6px 0 16px 0;
    background: transparent !important;
    border: 0 !important;
    border-radius: 0 !important;
    padding: 0 !important;
    box-shadow: none !important;
    overflow: visible !important;
}
.gradio-container .form:has(> .model-type-tabs),
.gradio-container .form:has(fieldset.model-type-tabs) {
    background: transparent !important;
    border: 0 !important;
    box-shadow: none !important;
    overflow: visible !important;
}
.model-type-tabs .wrap {
    display: grid !important;
    grid-template-columns: 1fr 1fr !important;
    gap: 8px !important;
    flex-direction: row !important;
    background: #18181b !important;
    border: 1px solid #3f3f46 !important;
    border-radius: 8px !important;
    padding: 8px !important;
    margin: 0 !important;
}
.model-type-tabs label {
    min-height: 48px !important;
    border: 1px solid #3f3f46 !important;
    border-radius: 6px !important;
    padding: 8px 12px !important;
    margin: 0 !important;
    justify-content: center !important;
    background: #3f3f46 !important;
    font-weight: 800 !important;
}
.model-type-tabs label:has(input:checked) {
    color: #ffffff !important;
    background: var(--copita-active-bg) !important;
    border-color: var(--copita-active-border) !important;
}
.model-type-tabs input[type="radio"] {
    display: none !important;
}
.course-strip {
    width: 100% !important;
    margin: 0 0 14px 0 !important;
    padding: 10px 12px 12px 12px !important;
    background: #18181b !important;
    border: 1px solid #3f3f46 !important;
    border-radius: 8px !important;
}
.course-tabs {
    width: 100% !important;
    margin: 0 !important;
}
.course-tabs .wrap {
    display: grid !important;
    grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
    gap: 8px !important;
    flex-direction: row !important;
}
.course-tabs .wrap label {
    min-height: 44px !important;
    border: 1px solid #3f3f46 !important;
    border-radius: 6px !important;
    padding: 8px 12px !important;
    margin: 0 !important;
    justify-content: center !important;
    background: #27272a !important;
    font-weight: 800 !important;
}
.course-tabs .wrap label:has(input:checked) {
    color: #ffffff !important;
    background: var(--copita-active-bg) !important;
    border-color: var(--copita-active-border) !important;
}
.course-tabs .wrap input[type="radio"] {
    display: none !important;
}
.settings-card {
    background: #202124;
    border: 1px solid #3f3f46;
    border-radius: 6px;
    padding: 14px;
    min-height: 300px;
}
.training-action-grid .settings-card,
.training-action-grid .start-panel {
    min-height: 190px;
}
.speed-card {
    padding-bottom: 10px;
}
.preset-heading h3,
.preset-heading p {
    margin: 0 0 8px 0 !important;
    color: #f4f4f5 !important;
    font-weight: 800 !important;
}
.preset-tabs {
    margin: 10px 0 0 0 !important;
    background: transparent !important;
    border: 0 !important;
    border-radius: 0 !important;
    padding: 0 !important;
    box-shadow: none !important;
    overflow: visible !important;
}
.gradio-container .form:has(> .preset-tabs),
.gradio-container .form:has(fieldset.preset-tabs) {
    background: transparent !important;
    border: 0 !important;
    box-shadow: none !important;
    overflow: visible !important;
}
.preset-tabs .wrap {
    display: grid !important;
    grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
    gap: 8px !important;
    flex-direction: row !important;
    background: #18181b !important;
    border: 1px solid #3f3f46 !important;
    border-radius: 8px !important;
    padding: 8px !important;
    margin: 0 !important;
}
.preset-tabs .wrap label {
    min-height: 48px !important;
    border: 1px solid #3f3f46 !important;
    border-radius: 6px !important;
    padding: 8px 10px !important;
    margin: 0 !important;
    justify-content: center !important;
    text-align: center !important;
    background: #27272a !important;
    font-weight: 800 !important;
}
.preset-tabs .wrap label:has(input:checked) {
    color: #ffffff !important;
    background: var(--copita-active-bg) !important;
    border-color: var(--copita-active-border) !important;
}
.preset-tabs .wrap input[type="radio"] {
    display: none !important;
}
.preset-description {
    margin-top: 10px !important;
    padding: 10px 12px !important;
    background: #18181b !important;
    border: 1px solid #3f3f46 !important;
    border-radius: 6px !important;
    color: #d4d4d8 !important;
}
.preset-description p {
    margin: 0 !important;
}
.field-help-row {
    display: flex !important;
    flex-wrap: nowrap !important;
    align-items: center !important;
    gap: 6px !important;
    margin: 10px 0 4px 0 !important;
}
.field-help-row > .field-help-label {
    flex: 1 1 0 !important;
    min-width: 0 !important;
    width: auto !important;
    max-width: none !important;
}
.field-help-label {
    margin: 0 !important;
    flex: 1 1 auto !important;
}
.field-help-label p {
    margin: 0 !important;
    color: #f4f4f5 !important;
    font-weight: 800 !important;
}
.vram-saving-title {
    margin: 14px 0 6px 0 !important;
}
.vram-saving-title p {
    margin: 0 !important;
    color: #f4f4f5 !important;
    font-weight: 800 !important;
}
.settings-card h3,
.save-card h3 {
    margin-top: 0 !important;
}
.save-card {
    background: #202124;
    border: 1px solid #3f3f46;
    border-radius: 6px;
    padding: 14px;
    margin-top: 0;
    min-height: 300px;
}
.model-status-box {
    min-height: 92px;
}
.compact-field textarea,
.compact-field input {
    min-height: 44px !important;
}
.log-shell {
    margin-top: 18px;
}
.tool-panel {
    background: #18181b;
    border: 1px solid #3f3f46;
    border-radius: 8px;
    padding: 14px;
}
.tool-panel h2,
.tool-panel h3 {
    margin-top: 0 !important;
}
.mini-note {
    color: #a1a1aa;
    font-size: 1rem !important;
    line-height: 1.45;
}
.status-note {
    color: #d4d4d8;
    font-size: 1rem !important;
    line-height: 1.55;
}
.training-flow-note,
.training-flow-note p {
    white-space: pre-line !important;
    line-height: 1.65 !important;
}
.folder-button button,
.small-button button {
    min-width: 52px !important;
    height: 52px !important;
    padding-left: 0 !important;
    padding-right: 0 !important;
    display: flex !important;
    align-items: center !important;
    justify-content: center !important;
}
.path-label {
    margin: 0.7rem 0 0.25rem 0;
    color: #f4f4f5;
    font-weight: 700;
}
.path-label p {
    margin: 0 !important;
}
.path-row {
    display: flex !important;
    flex-wrap: nowrap !important;
    gap: 8px !important;
    align-items: center !important;
}
.path-row > div {
    min-width: 0 !important;
}
.path-display-wrap {
    flex: 1 1 0 !important;
    min-width: 0 !important;
    width: auto !important;
}
.path-row .folder-button {
    flex: 0 0 52px !important;
    width: 52px !important;
    min-width: 52px !important;
    max-width: 52px !important;
}
.model-file-row {
    display: flex !important;
    flex-wrap: nowrap !important;
    gap: 8px !important;
    align-items: end !important;
}
.model-file-row > div:first-child {
    min-width: 0 !important;
    flex: 1 1 auto !important;
}
.model-file-row .folder-button {
    flex: 0 0 52px !important;
    width: 52px !important;
    min-width: 52px !important;
    max-width: 52px !important;
}
.model-path-dropdown {
    min-width: 0 !important;
}
.model-file-row .model-path-dropdown {
    flex: 1 1 auto !important;
}
.model-path-dropdown .wrap,
.model-path-dropdown .secondary-wrap,
.model-path-dropdown .dropdown {
    min-width: 0 !important;
    overflow: hidden !important;
}
.model-path-dropdown input,
.model-path-dropdown [role="combobox"] {
    min-width: 0 !important;
    max-width: 100% !important;
    box-sizing: border-box !important;
    overflow: hidden !important;
    text-overflow: ellipsis !important;
    white-space: nowrap !important;
    padding-right: 3rem !important;
}
.model-path-dropdown .single-select,
.model-path-dropdown .selected-item,
.model-path-dropdown .token {
    min-width: 0 !important;
    max-width: 100% !important;
    overflow: hidden !important;
    text-overflow: ellipsis !important;
    white-space: nowrap !important;
    padding-right: 2.5rem !important;
    box-sizing: border-box !important;
}
.model-path-dropdown svg {
    flex: 0 0 auto !important;
}
.path-display {
    min-height: 52px;
    width: 100%;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    overflow: hidden;
    padding: 0 12px;
    background: #3f3f46;
    border: 1px solid #52525b;
    border-radius: 6px;
    color: #f4f4f5;
    font-weight: 700;
}
.path-display span {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.path-display-empty {
    color: #a1a1aa;
    font-weight: 600;
}
.path-row textarea,
.path-row input {
    white-space: nowrap !important;
    min-height: 52px !important;
    overflow-x: hidden !important;
}
.main-action,
.main-action button {
    min-height: 46px !important;
    font-weight: 700 !important;
}
.train-action {
    width: min(360px, 100%) !important;
    margin: 18px auto 0 !important;
}
.gradio-container button.train-action,
.gradio-container .train-action button {
    width: min(360px, 100%) !important;
    min-height: 70px !important;
    padding: 17px 34px !important;
    font-size: 1.24rem !important;
    font-weight: 900 !important;
}
.gradio-container button.train-action:not(:disabled),
.gradio-container .train-action button:not(:disabled),
button.train-action.primary,
button.train-action[class*="primary"],
.train-action.primary,
.train-action[class*="primary"],
.train-action.primary button,
.train-action[class*="primary"] button,
.train-action button.primary,
.train-action button[class*="primary"] {
    min-height: 68px !important;
    padding: 16px 34px !important;
    background: linear-gradient(180deg, #fb923c 0%, #f97316 48%, #ea580c 100%) !important;
    border-color: #fb923c !important;
    color: #ffffff !important;
    font-size: 1.22rem !important;
    font-weight: 900 !important;
    box-shadow: 0 0 0 1px rgba(251, 146, 60, 0.35), 0 10px 24px rgba(234, 88, 12, 0.24) !important;
}
.gradio-container button.train-action:not(:disabled):hover,
.gradio-container .train-action button:not(:disabled):hover,
button.train-action.primary:hover,
button.train-action[class*="primary"]:hover,
.train-action.primary:hover,
.train-action[class*="primary"]:hover,
.train-action.primary button:hover,
.train-action[class*="primary"] button:hover,
.train-action button.primary:hover,
.train-action button[class*="primary"]:hover {
    background: linear-gradient(180deg, #fdba74 0%, #f97316 45%, #c2410c 100%) !important;
    border-color: #fdba74 !important;
}
.gradio-container button.train-action:disabled,
.gradio-container .train-action button:disabled,
.train-action button.secondary,
.train-action button[class*="secondary"] {
    background: #52525b !important;
    border-color: #71717a !important;
    color: #e4e4e7 !important;
    box-shadow: none !important;
}
.gradio-container button.train-action.stop,
.gradio-container button.train-action[class*="stop"],
.gradio-container .train-action.stop button,
.gradio-container .train-action[class*="stop"] button,
.train-action button.stop,
.train-action button[class*="stop"] {
    background: #dc2626 !important;
    border-color: #ef4444 !important;
    color: #ffffff !important;
}
.preview-box {
    width: 100%;
    border: 1px solid #3f3f46;
    border-radius: 6px;
    background: #202124;
    display: flex;
    align-items: center;
    justify-content: center;
    overflow: hidden;
}
.preview-empty-box {
    min-height: 420px;
}
.preview-loaded {
    height: auto;
}
.preview-box img {
    width: 100%;
    height: auto;
    display: block;
}
.preview-empty {
    color: #d4d4d8;
    text-align: center;
    line-height: 1.7;
    font-size: 1.05rem;
}
.preview-meta {
    color: #a1a1aa;
    font-size: 1rem !important;
    margin-top: 0.35rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.wide-log-panel {
    margin-top: 0;
}
.fusion-panel {
    margin-top: 18px;
    background: #18181b;
    border: 1px solid #3f3f46;
    border-radius: 8px;
    padding: 16px;
}
.fusion-panel h2,
.fusion-panel h3 {
    margin-top: 0 !important;
}
.fusion-note {
    color: #d4d4d8;
    line-height: 1.65;
    font-size: 1rem;
}
.fusion-note p {
    margin: 0.35rem 0 !important;
}
.fusion-top-grid {
    gap: 16px !important;
    align-items: stretch !important;
    margin-bottom: 18px !important;
}
.fusion-top-card,
.fusion-folder-card {
    min-width: 0 !important;
}
.fusion-top-card {
    min-height: 260px !important;
    background: #202124 !important;
    border: 1px solid #3f3f46 !important;
    border-radius: 8px !important;
    padding: 18px !important;
    display: flex !important;
    flex-direction: column !important;
}
.fusion-top-card h2,
.fusion-top-card h3 {
    margin-top: 0 !important;
    margin-bottom: 14px !important;
}
.fusion-folder-card {
    align-self: stretch !important;
}
.fusion-folder-card .path-row {
    margin-top: 8px !important;
    align-items: center !important;
}
.fusion-folder-card .button-row {
    margin-top: 18px !important;
    gap: 10px !important;
}
.fusion-folder-card .path-display {
    min-height: 60px;
}
.fusion-folder-card .folder-button,
.fusion-folder-card .folder-button button {
    min-height: 56px !important;
}
.fusion-weights {
    gap: 10px !important;
}
.fusion-weight-card {
    margin: 12px 0 12px;
    background: #202124;
    border: 1px solid #3f3f46;
    border-radius: 8px;
    padding: 12px;
}
.fusion-weights input {
    min-height: 42px !important;
}
.fusion-weight-heading h3,
.fusion-weight-heading p {
    color: #ffffff;
    font-size: 1rem !important;
    font-weight: 900;
    margin: 0 0 10px 0 !important;
}
.fusion-selected-slot {
    min-height: 76px;
    border-radius: 7px;
    border: 1px solid #3f3f46;
    padding: 9px 10px;
    display: flex;
    flex-direction: column;
    gap: 5px;
    justify-content: center;
    min-width: 0;
    margin-bottom: 8px;
}
.fusion-selected-slot span {
    color: #67e8f9;
    font-size: 0.88rem !important;
    font-weight: 900;
}
.fusion-selected-slot strong {
    color: #f4f4f5;
    font-size: 0.95rem !important;
    line-height: 1.25;
    overflow-wrap: anywhere;
}
.fusion-selected-filled {
    background: rgba(14, 116, 144, 0.24);
    border-color: #22d3ee;
}
.fusion-selected-empty {
    background: #18181b;
}
.fusion-selected-empty strong {
    color: #a1a1aa;
}
.fusion-selected-empty-note,
.fusion-selected-warning {
    color: #a1a1aa;
    font-size: 0.92rem !important;
    line-height: 1.45;
    margin: 0 0 10px 0 !important;
}
.fusion-selected-warning {
    color: #fed7aa;
    font-weight: 800;
}
.fusion-weight-slot-wrap {
    min-width: 0 !important;
}
.fusion-weight-slot-wrap input:disabled {
    color: transparent !important;
}
.fusion-weight-slot-wrap:has(input:disabled) {
    opacity: 0.72;
}
.fusion-panel button,
.fusion-panel button.primary,
.fusion-panel button.secondary,
.fusion-panel button[class*="primary"],
.fusion-panel button[class*="secondary"] {
    background: #0e7490 !important;
    border-color: #22d3ee !important;
    color: #ffffff !important;
    font-weight: 800 !important;
}
.fusion-panel button:hover,
.fusion-panel button.primary:hover,
.fusion-panel button.secondary:hover,
.fusion-panel button[class*="primary"]:hover,
.fusion-panel button[class*="secondary"]:hover {
    background: #155e75 !important;
    border-color: #67e8f9 !important;
    color: #ffffff !important;
}
.fusion-panel label {
    background: transparent !important;
    color: #ffffff !important;
}
.fusion-panel label span {
    color: inherit !important;
}
.history-panel {
    margin-top: 18px;
    background: #18181b;
    border: 1px solid #3f3f46;
    border-radius: 8px;
    padding: 16px;
}
.history-panel h2,
.history-panel h3 {
    margin-top: 0 !important;
}
.history-grid {
    gap: 16px !important;
    align-items: stretch !important;
}
.history-list-card {
    background: #202124;
    border: 1px solid #3f3f46;
    border-radius: 6px;
    padding: 14px !important;
    margin-bottom: 12px !important;
    align-items: stretch !important;
    gap: 16px !important;
}
.history-list-info {
    min-width: 0;
}
.history-list-info-box {
    color: #d4d4d8;
    line-height: 1.45;
    min-width: 0;
}
.history-title-row {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 10px;
    margin-bottom: 6px;
}
.history-lora-name {
    color: #ffffff;
    font-size: 1.45rem !important;
    line-height: 1.18;
    font-weight: 900;
    min-width: 0;
    overflow-wrap: anywhere;
}
.history-status-badge {
    flex: 0 0 auto;
    border-radius: 999px;
    border: 1px solid #52525b;
    padding: 4px 9px;
    font-size: 0.82rem !important;
    line-height: 1;
    font-weight: 900;
    white-space: nowrap;
}
.history-status-completed {
    background: rgba(5, 150, 105, 0.22);
    border-color: #10b981;
    color: #bbf7d0;
}
.history-status-stopped {
    background: rgba(234, 88, 12, 0.22);
    border-color: #fb923c;
    color: #fed7aa;
}
.history-status-unknown {
    background: #27272a;
    border-color: #52525b;
    color: #d4d4d8;
}
.history-card-meta,
.history-card-path,
.history-card-settings {
    color: #a1a1aa;
    font-size: 0.9rem !important;
    margin-bottom: 3px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.history-card-description {
    color: #e4e4e7;
    font-size: 0.96rem !important;
    margin-top: 8px;
    max-height: 5.8em;
    overflow: hidden;
    overflow-wrap: anywhere;
}
.history-page-selector {
    margin-bottom: 10px !important;
}
.history-page-selector .wrap {
    gap: 8px !important;
}
.history-page-selector label {
    background: #27272a !important;
    border: 1px solid #3f3f46 !important;
    border-radius: 6px !important;
    color: #f4f4f5 !important;
    padding: 7px 10px !important;
}
.history-list-images {
    gap: 10px !important;
    align-items: stretch !important;
}
.history-result-drop {
    min-height: 230px !important;
}
.history-result-drop > .wrap,
.history-result-drop [data-testid="image"],
.history-result-drop img {
    min-height: 210px !important;
}
.history-list-card .image-container,
.history-list-card [data-testid="image"],
.history-list-card img {
    background: #050506 !important;
    border-radius: 6px !important;
}
.history-list-card .wrap,
.history-list-card .image-frame,
.history-list-card .upload-container,
.history-list-card [data-testid="file-upload"],
.history-list-card [data-testid="image"] > div {
    background: #050506 !important;
}
.history-list-card img {
    object-fit: contain !important;
}
@media (max-width: 1100px) {
    .fusion-top-grid {
        flex-direction: column !important;
    }
    .history-list-card {
        flex-direction: column !important;
    }
    .history-list-images {
        flex-direction: column !important;
    }
    .history-card-meta,
    .history-card-path,
    .history-card-settings {
        white-space: normal;
    }
}
.history-panel button,
.history-panel button.primary,
.history-panel button.secondary,
.history-panel button[class*="primary"],
.history-panel button[class*="secondary"] {
    background: #7c3aed !important;
    border-color: #a78bfa !important;
    color: #ffffff !important;
    font-weight: 800 !important;
}
.history-panel button:hover,
.history-panel button.primary:hover,
.history-panel button.secondary:hover,
.history-panel button[class*="primary"]:hover,
.history-panel button[class*="secondary"]:hover {
    background: #6d28d9 !important;
    border-color: #c4b5fd !important;
    color: #ffffff !important;
}
.history-list-card button,
.history-list-card button.primary,
.history-list-card button.secondary,
.history-list-card button[class*="primary"],
.history-list-card button[class*="secondary"] {
    background: #050506 !important;
    border-color: #3f3f46 !important;
    color: #f4f4f5 !important;
}
.history-list-card button:hover,
.history-list-card button.primary:hover,
.history-list-card button.secondary:hover,
.history-list-card button[class*="primary"]:hover,
.history-list-card button[class*="secondary"]:hover {
    background: #18181b !important;
    border-color: #52525b !important;
    color: #ffffff !important;
}
.history-delete-button,
.history-delete-button button {
    width: 110px !important;
    min-height: 36px !important;
    margin-top: 10px !important;
    background: #7f1d1d !important;
    border-color: #ef4444 !important;
    color: #ffffff !important;
    font-weight: 900 !important;
}
.history-delete-button:hover,
.history-delete-button button:hover {
    background: #991b1b !important;
    border-color: #f87171 !important;
}
.history-delete-overlay {
    position: fixed !important;
    inset: 0 !important;
    z-index: 3000 !important;
    background: rgba(0, 0, 0, 0.62) !important;
    padding: 24px !important;
}
.history-delete-dialog {
    position: absolute !important;
    top: 50% !important;
    left: 50% !important;
    transform: translate(-50%, -50%) !important;
    width: min(420px, calc(100vw - 48px)) !important;
    height: auto !important;
    min-height: 0 !important;
    max-height: min(260px, calc(100vh - 48px)) !important;
    flex: 0 0 auto !important;
    background: #202124 !important;
    border: 1px solid #52525b !important;
    border-radius: 8px !important;
    padding: 22px !important;
    box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45) !important;
    overflow: visible !important;
}
.history-delete-dialog .prose,
.history-delete-dialog p {
    margin: 0 !important;
    color: #f4f4f5 !important;
    font-size: 1.08rem !important;
    font-weight: 800 !important;
    text-align: center !important;
}
.history-delete-actions {
    justify-content: center !important;
    gap: 12px !important;
    margin-top: 16px !important;
}
.history-delete-confirm-button,
.history-delete-confirm-button button {
    background: #dc2626 !important;
    border-color: #ef4444 !important;
    color: #ffffff !important;
    font-weight: 900 !important;
}
.history-delete-cancel-button,
.history-delete-cancel-button button {
    background: #3f3f46 !important;
    border-color: #71717a !important;
    color: #ffffff !important;
    font-weight: 900 !important;
}
.fusion-panel .label-wrap,
.fusion-panel span[data-testid="block-label"],
.fusion-panel .block-label,
.fusion-panel span.svelte-jdcl7l,
.fusion-panel label > span:first-child {
    background: #0e7490 !important;
    border-color: #22d3ee !important;
    color: #ffffff !important;
    font-weight: 800 !important;
}
.progress-panel {
    background: #202124;
    border: 1px solid #3f3f46;
    border-radius: 6px;
    padding: 12px;
    margin-bottom: 10px;
}
.progress-row {
    display: flex;
    justify-content: space-between;
    gap: 12px;
    align-items: center;
    color: #f4f4f5;
    font-weight: 700;
}
.progress-sub {
    color: #a1a1aa;
    font-size: 0.96rem !important;
    margin-top: 8px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.progress-details {
    display: grid;
    grid-template-columns: repeat(12, minmax(0, 1fr));
    gap: 8px;
    margin-top: 10px;
}
.progress-metric {
    grid-column: span 2;
    background: #18181b;
    border: 1px solid #3f3f46;
    border-radius: 6px;
    padding: 8px 10px;
    min-width: 0;
}
.progress-metric-label {
    color: #a1a1aa;
    font-size: 0.82rem !important;
    font-weight: 700;
    margin-bottom: 3px;
}
.progress-metric-value {
    color: #f4f4f5;
    font-size: 0.98rem !important;
    font-weight: 800;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.progress-checkpoint {
    grid-column: span 2;
}
@media (max-width: 1100px) {
    .progress-details {
        grid-template-columns: repeat(2, minmax(0, 1fr));
    }
    .progress-metric {
        grid-column: span 1;
    }
    .progress-checkpoint {
        grid-column: span 2;
    }
}
.progress-bar {
    margin-top: 10px;
    height: 10px;
    border-radius: 999px;
    background: #3f3f46;
    overflow: hidden;
}
.progress-fill {
    height: 100%;
    border-radius: 999px;
    background: #2dd4bf;
    transition: width 0.25s ease;
}
#training_log_text textarea {
    font-family: "Cascadia Mono", Consolas, "Yu Gothic UI", monospace !important;
    font-size: 1.02rem !important;
    line-height: 1.45 !important;
    min-height: 520px !important;
}
.lora-choice-list {
    max-height: 360px;
    overflow-y: auto;
}
.lora-choice-list label {
    word-break: break-all;
}
textarea,
input,
select,
button {
    font-size: 1.05rem !important;
}
"""

COPY_PROMPT_JS = """
(promptText) => {
    const text = promptText || "";
    if (!text.trim()) {
        return [];
    }
    if (navigator.clipboard && window.isSecureContext) {
        navigator.clipboard.writeText(text);
        return [];
    }
    const textarea = document.createElement("textarea");
    textarea.value = text;
    textarea.setAttribute("readonly", "");
    textarea.style.position = "fixed";
    textarea.style.left = "-9999px";
    document.body.appendChild(textarea);
    textarea.select();
    document.execCommand("copy");
    document.body.removeChild(textarea);
    return [];
}
"""

MODE_SWITCH_JS = """
(modeName, language) => {
    const normalizeMode = (value) => {
        const mode = value || "";
        if (mode.includes("Fusion") || mode.includes("フュージョン")) {
            return "fusion";
        }
        if (mode.includes("History") || mode.includes("履歴")) {
            return "history";
        }
        return "training";
    };
    const ids = {
        training: "copita_training_workspace",
        fusion: "copita_fusion_workspace",
        history: "copita_history_workspace",
    };
    const applyMode = (normalized) => {
        window.copitaCurrentWorkspaceMode = normalized;
        for (const [key, id] of Object.entries(ids)) {
            const el = document.getElementById(id);
            if (!el) {
                continue;
            }
            const show = key === normalized;
            el.hidden = false;
            el.removeAttribute("hidden");
            el.classList.toggle("copita-workspace-visible", show);
            el.classList.toggle("copita-workspace-hidden", !show);
            el.style.setProperty("display", show ? "block" : "none", "important");
            el.setAttribute("aria-hidden", show ? "false" : "true");
        }
    };
    window.copitaApplyWorkspaceMode = applyMode;

    const loadingText = language === "EN" ? "Loading..." : "読み込み中...";
    let overlay = document.getElementById("copita_mode_loading_overlay");
    if (!overlay) {
        overlay = document.createElement("div");
        overlay.id = "copita_mode_loading_overlay";
        overlay.innerHTML = '<div class="mode-loading-box"><span class="mode-loading-spinner"></span><span class="mode-loading-text"></span></div>';
        document.body.appendChild(overlay);
    }
    const text = overlay.querySelector(".mode-loading-text");
    if (text) {
        text.textContent = loadingText;
    }
    document.body.classList.add("copita-mode-loading");
    if (window.copitaModeLoadingTimer) {
        window.clearTimeout(window.copitaModeLoadingTimer);
    }
    window.copitaModeLoadingTimer = window.setTimeout(() => {
        document.body.classList.remove("copita-mode-loading");
    }, 2500);

    const normalized = normalizeMode(modeName);
    if (window.copitaWorkspaceSyncTimers) {
        window.copitaWorkspaceSyncTimers.forEach((timer) => window.clearTimeout(timer));
    }
    window.copitaWorkspaceSyncTimers = [0, 40, 120, 260, 520, 900, 1400, 2100].map((delay) => (
        window.setTimeout(() => applyMode(normalized), delay)
    ));
    return [];
}
"""

MODE_READY_JS = """
(modeName, language) => {
    const mode = modeName || "";
    const normalized = mode.includes("Fusion") || mode.includes("フュージョン")
        ? "fusion"
        : (mode.includes("History") || mode.includes("履歴") ? "history" : "training");
    if (window.copitaApplyWorkspaceMode) {
        window.copitaApplyWorkspaceMode(normalized);
        window.setTimeout(() => window.copitaApplyWorkspaceMode(normalized), 80);
        window.setTimeout(() => window.copitaApplyWorkspaceMode(normalized), 240);
    }
    if (window.copitaModeLoadingTimer) {
        window.clearTimeout(window.copitaModeLoadingTimer);
        window.copitaModeLoadingTimer = null;
    }
    window.setTimeout(() => {
        document.body.classList.remove("copita-mode-loading");
    }, 650);
    return [];
}
"""

GRADIO_CHROME_FIX_HEAD = """
<style>
footer,
[data-testid="footer"],
.footer {
    display: none !important;
    visibility: hidden !important;
    width: 0 !important;
    height: 0 !important;
    min-width: 0 !important;
    min-height: 0 !important;
    margin: 0 !important;
    padding: 0 !important;
    overflow: hidden !important;
    border: 0 !important;
    background: transparent !important;
    box-shadow: none !important;
    opacity: 0 !important;
    pointer-events: none !important;
}
</style>
<script>
(() => {
    const removeConnectionToasts = () => {
        document.querySelectorAll(".toast-wrap, .toast-item, .toast-body.error").forEach((el) => {
            const text = (el.textContent || "").toLowerCase();
            if (text.includes("connection to the server was lost")) {
                el.remove();
            }
        });
    };
    removeConnectionToasts();
    new MutationObserver(removeConnectionToasts).observe(document.documentElement, {
        childList: true,
        subtree: true,
    });
    window.setInterval(removeConnectionToasts, 500);
})();
</script>
"""

COPITA_GLOBAL_JS = """
() => {
    const styleId = "copita-global-cleanup-style";
    const installStyle = () => {
        if (document.getElementById(styleId)) {
            return;
        }
        const style = document.createElement("style");
        style.id = styleId;
        style.textContent = `
            footer,
            [data-testid="footer"],
            .footer {
                display: none !important;
                visibility: hidden !important;
                height: 0 !important;
                min-height: 0 !important;
                margin: 0 !important;
                padding: 0 !important;
                overflow: hidden !important;
                border: 0 !important;
                background: transparent !important;
                box-shadow: none !important;
            }
        `;
        document.head.appendChild(style);
    };
    const removeConnectionToasts = () => {
        document.querySelectorAll(".toast-wrap, .toast-item, .toast-body, [class*='toast']").forEach((el) => {
            const text = (el.textContent || "").toLowerCase();
            if (text.includes("connection to the server was lost")) {
                el.remove();
            }
        });
    };
    const cleanup = () => {
        installStyle();
        removeConnectionToasts();
    };
    cleanup();
    new MutationObserver(cleanup).observe(document.documentElement, {
        childList: true,
        subtree: true,
    });
    window.setInterval(cleanup, 500);
    return [];
}
"""

def ui_text(language: str | None, key: str) -> str:
    lang = "EN" if language == "EN" else "JP"
    return UI_TEXT[lang].get(key, UI_TEXT["JP"].get(key, key))


def local_file_url(path: Path) -> str:
    return "/gradio_api/file=" + quote(str(path.resolve()).replace("\\", "/"), safe="/")


def subtitle_with_links(language: str | None) -> str:
    return (
        f"{ui_text(language, 'subtitle')}\n\n"
        f"[{ui_text(language, 'fanbox_link')}]({FANBOX_URL})"
        "　"
        f"[{ui_text(language, 'usage_link')}]({USAGE_URL})"
        "　"
        f"[{ui_text(language, 'terms_link')}]({local_file_url(TERMS_PATH)})"
    )


def localized_fusion_dim_choices(language: str | None) -> list[str]:
    return ["4", "8", "16", "32", "64"]


def normalize_fusion_output_dims(selected_dims, language: str | None) -> list[int | None]:
    if selected_dims is None:
        raw_values: list[str] = []
    elif isinstance(selected_dims, (list, tuple, set)):
        raw_values = [str(value).strip() for value in selected_dims]
    else:
        raw_values = [str(selected_dims).strip()]

    auto_labels = {
        "auto",
        "automatic",
        "自動",
        ui_text(language, "fusion_dim_auto").strip().lower(),
        ui_text("JP", "fusion_dim_auto").strip().lower(),
        ui_text("EN", "fusion_dim_auto").strip().lower(),
    }
    dims: list[int | None] = []
    for raw_value in raw_values:
        value = raw_value.strip()
        lowered = value.lower()
        if not value:
            continue
        if lowered in auto_labels:
            candidate: int | None = None
        else:
            try:
                parsed = int(value)
            except Exception:
                continue
            if str(parsed) not in FUSION_DIM_CHOICES:
                continue
            candidate = parsed
        if candidate not in dims:
            dims.append(candidate)
    return dims


def saved_user_setting(key: str) -> str:
    try:
        loaded = json.loads(settings.USER_SETTINGS_PATH.read_text(encoding="utf-8"))
    except Exception:
        return ""
    if not isinstance(loaded, dict):
        return ""
    return str(loaded.get(key, "")).strip()


def is_internal_models_root(path_text: str) -> bool:
    if not path_text:
        return False
    try:
        path = settings.normalize_user_path(path_text, settings.TOOL_DIR / "models").resolve()
        return path == (settings.TOOL_DIR / "models").resolve()
    except Exception:
        return False


def initial_models_root_text() -> str:
    explicit = saved_user_setting("models_root_dir")
    if not explicit or is_internal_models_root(explicit):
        return ""
    return str(settings.get_models_root_dir())


def is_internal_output_dir(path_text: str) -> bool:
    if not path_text:
        return False
    try:
        path = settings.normalize_user_path(path_text, settings.OUTPUT_DIR).resolve()
        return path == settings.OUTPUT_DIR.resolve()
    except Exception:
        return False


def initial_output_dir_text(user_settings: dict[str, str]) -> str:
    explicit = saved_user_setting("lora_output_dir")
    if not explicit or is_internal_output_dir(explicit):
        return ""
    return str(user_settings.get("lora_output_dir", ""))


def compact_path_text(path_text: str, max_chars: int = 64) -> str:
    text = str(path_text or "").strip()
    if len(text) <= max_chars:
        return text
    head = max(12, max_chars // 3)
    tail = max(20, max_chars - head - 3)
    return f"{text[:head]}...{text[-tail:]}"


def path_display_html(path_text: str, empty_text: str) -> str:
    text = str(path_text or "").strip()
    display_text = compact_path_text(text) if text else empty_text
    css_class = "path-display" if text else "path-display path-display-empty"
    title = html_utils.escape(text or empty_text, quote=True)
    body = html_utils.escape(display_text)
    return f'<div class="{css_class}" title="{title}"><span>{body}</span></div>'


def ensure_history_dirs() -> None:
    settings.HISTORY_ASSETS_DIR.mkdir(parents=True, exist_ok=True)


def load_history_records() -> list[dict]:
    if not settings.HISTORY_PATH.exists():
        return []
    try:
        data = json.loads(settings.HISTORY_PATH.read_text(encoding="utf-8"))
    except Exception:
        return []
    records = data.get("records") if isinstance(data, dict) else data
    if not isinstance(records, list):
        return []
    return [record for record in records if isinstance(record, dict)]


def save_history_records(records: list[dict]) -> None:
    ensure_history_dirs()
    settings.HISTORY_PATH.write_text(
        json.dumps({"version": 1, "records": records}, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )


def history_now() -> str:
    return time.strftime("%Y-%m-%d %H:%M:%S")


def new_history_id() -> str:
    return f"{time.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"


def relative_history_path(path: Path) -> str:
    try:
        return str(path.resolve().relative_to(settings.HISTORY_DIR.resolve())).replace("\\", "/")
    except Exception:
        return str(path)


def history_asset_path(value: str | None) -> Path | None:
    text = str(value or "").strip()
    if not text:
        return None
    path = Path(text)
    if not path.is_absolute():
        path = settings.HISTORY_DIR / path
    return path


def copy_history_asset(source_value, record_id: str, role: str) -> str:
    source_text = upload_path(source_value)
    if not source_text:
        return ""
    source = Path(source_text)
    if not source.exists() or not source.is_file():
        return ""
    ensure_history_dirs()
    suffix = source.suffix.lower()
    if suffix not in {".png", ".jpg", ".jpeg", ".webp", ".bmp"}:
        suffix = ".png"
    destination = settings.HISTORY_ASSETS_DIR / f"{record_id}_{role}{suffix}"
    try:
        if source.resolve() == destination.resolve():
            return relative_history_path(destination)
    except Exception:
        pass
    shutil.copy2(source, destination)
    return relative_history_path(destination)


def remove_history_asset(value: str | None) -> None:
    path = history_asset_path(value)
    if not path or not path.exists() or not path.is_file():
        return
    try:
        path.resolve().relative_to(settings.HISTORY_DIR.resolve())
    except Exception:
        return
    try:
        path.unlink()
    except Exception:
        pass


def history_record_label(record: dict) -> str:
    name = str(record.get("lora_name") or Path(str(record.get("lora_path") or "")).stem or "LoRA")
    created = str(record.get("created_at") or "")
    model_type = str(record.get("model_type") or "")
    parts = [part for part in (created, model_type, name) if part]
    return " / ".join(parts)


def history_choices(records: list[dict] | None = None) -> list[tuple[str, str]]:
    records = records if records is not None else load_history_records()
    choices: list[tuple[str, str]] = []
    for record in reversed(records):
        record_id = str(record.get("id") or "")
        if record_id:
            choices.append((history_record_label(record), record_id))
    return choices


def find_history_record(records: list[dict], record_id: str | None) -> dict | None:
    target = str(record_id or "").strip()
    if not target:
        return records[-1] if records else None
    for record in records:
        if str(record.get("id") or "") == target:
            return record
    return None


def history_image_value(record: dict | None, key: str) -> str | None:
    if not record:
        return None
    path = history_asset_path(str(record.get(key) or ""))
    if path and path.exists():
        return str(path)
    return None


def history_preview_value(record: dict | None, key: str, language: str | None) -> str:
    path = history_image_value(record, key)
    return preview_html(path, ui_text(language, "history_image_empty"), language)


def history_detail_html(record: dict | None, language: str | None = "JP") -> str:
    if not record:
        return f'<div class="history-detail">{html_utils.escape(ui_text(language, "history_empty"))}</div>'
    settings_values = record.get("settings") if isinstance(record.get("settings"), dict) else {}
    setting_parts = []
    for key in ("steps", "batch", "dim", "alpha", "learning_rate", "merge_strength"):
        value = settings_values.get(key)
        if value not in (None, ""):
            setting_parts.append(f"{key}: {value}")
    settings_text = " / ".join(setting_parts) if setting_parts else "-"
    rows = [
        (ui_text(language, "history_lora_name"), str(record.get("lora_name") or "-")),
        (ui_text(language, "history_lora_path"), str(record.get("lora_path") or "-")),
        (ui_text(language, "history_created_at"), str(record.get("created_at") or "-")),
        (ui_text(language, "history_model_type"), str(record.get("model_type") or "-")),
        (ui_text(language, "history_settings"), settings_text),
    ]
    body = "".join(
        f"<p><strong>{html_utils.escape(label)}:</strong> {html_utils.escape(value)}</p>"
        for label, value in rows
    )
    return f'<div class="history-detail">{body}</div>'


def history_display_records(records: list[dict] | None = None) -> list[dict]:
    records = records if records is not None else load_history_records()
    return list(reversed(records))


def history_page_count(records: list[dict] | None = None) -> int:
    total = len(records if records is not None else load_history_records())
    return max(1, math.ceil(total / HISTORY_PAGE_SIZE))


def history_page_choices(records: list[dict] | None = None) -> list[str]:
    return [str(index) for index in range(1, history_page_count(records) + 1)]


def normalize_history_page(page, records: list[dict] | None = None) -> int:
    try:
        value = int(str(page or "1").strip())
    except Exception:
        value = 1
    return max(1, min(value, history_page_count(records)))


def history_records_for_page(records: list[dict], page) -> list[dict]:
    display_records = history_display_records(records)
    page_value = normalize_history_page(page, records)
    start = (page_value - 1) * HISTORY_PAGE_SIZE
    return display_records[start : start + HISTORY_PAGE_SIZE]


def history_settings_text(record: dict | None) -> str:
    if not record:
        return "-"
    settings_values = record.get("settings") if isinstance(record.get("settings"), dict) else {}
    setting_parts = []
    for key in ("steps", "batch", "dim", "alpha", "learning_rate", "merge_strength"):
        value = settings_values.get(key)
        if value not in (None, ""):
            setting_parts.append(f"{key}: {value}")
    return " / ".join(setting_parts) if setting_parts else "-"


def history_status_key(record: dict | None) -> str:
    if not record:
        return "unknown"
    status = str(record.get("training_status") or record.get("status") or "").strip().lower()
    if status in {"completed", "stopped"}:
        return status
    return "unknown"


def history_status_badge_html(record: dict | None, language: str | None = "JP") -> str:
    status = history_status_key(record)
    label = ui_text(language, f"history_status_{status}")
    return (
        f'<span class="history-status-badge history-status-{html_utils.escape(status)}">'
        f"{html_utils.escape(label)}"
        "</span>"
    )


def history_page_status_text(records: list[dict], page, language: str | None = "JP", status: str = "") -> str:
    total = len(records)
    if not total:
        return status or ui_text(language, "history_empty")
    page_value = normalize_history_page(page, records)
    pages = history_page_count(records)
    start = (page_value - 1) * HISTORY_PAGE_SIZE + 1
    end = min(total, page_value * HISTORY_PAGE_SIZE)
    summary = ui_text(language, "history_page_status").format(
        count=total,
        start=start,
        end=end,
        page=page_value,
        pages=pages,
    )
    if status:
        return f"{status}\n\n{summary}"
    return summary


def history_card_info_html(record: dict | None, language: str | None = "JP") -> str:
    if not record:
        return ""
    name = str(record.get("lora_name") or Path(str(record.get("lora_path") or "")).stem or "LoRA")
    created = str(record.get("created_at") or "-")
    model_type = str(record.get("model_type") or "-")
    lora_path = str(record.get("lora_path") or "-")
    description = str(record.get("description") or record.get("target_caption") or "-")
    return (
        '<div class="history-list-info-box">'
        '<div class="history-title-row">'
        f'<div class="history-lora-name">{html_utils.escape(name)}</div>'
        f"{history_status_badge_html(record, language)}"
        "</div>"
        f'<div class="history-card-meta">{html_utils.escape(created)} / {html_utils.escape(model_type)}</div>'
        f'<div class="history-card-path" title="{html_utils.escape(lora_path, quote=True)}">{html_utils.escape(compact_path_text(lora_path, 84))}</div>'
        f'<div class="history-card-settings">{html_utils.escape(history_settings_text(record))}</div>'
        f'<div class="history-card-description">{html_utils.escape(description)}</div>'
        "</div>"
    )


def history_slot_record(records: list[dict], page, slot_index: int) -> dict | None:
    page_records = history_records_for_page(records, page)
    if 0 <= slot_index < len(page_records):
        return page_records[slot_index]
    return None


def history_slot_updates(record: dict | None, language: str | None = "JP") -> tuple:
    if not record:
        return (
            gr.update(visible=False),
            "",
            "",
            gr.update(label=ui_text(language, "history_image_a"), value=None),
            gr.update(label=ui_text(language, "history_image_b"), value=None),
            gr.update(
                label=ui_text(language, "history_result_short"),
                value=None,
                placeholder=ui_text(language, "history_result_drop_short"),
            ),
        )
    return (
        gr.update(visible=True),
        str(record.get("id") or ""),
        history_card_info_html(record, language),
        gr.update(label=ui_text(language, "history_image_a"), value=history_image_value(record, "style_image")),
        gr.update(label=ui_text(language, "history_image_b"), value=history_image_value(record, "comparison_image")),
        gr.update(
            label=ui_text(language, "history_result_short"),
            value=history_image_value(record, "result_image"),
            placeholder=ui_text(language, "history_result_drop_short"),
        ),
    )


def history_page_outputs(page, language: str | None = "JP", status: str = "") -> tuple:
    records = load_history_records()
    page_value = normalize_history_page(page, records)
    show_page_selector = history_page_count(records) > 1
    outputs: list[object] = [
        gr.update(
            label=ui_text(language, "history_page"),
            choices=history_page_choices(records),
            value=str(page_value),
            visible=show_page_selector,
        ),
        history_page_status_text(records, page_value, language, status),
    ]
    for slot_index in range(HISTORY_PAGE_SIZE):
        outputs.extend(history_slot_updates(history_slot_record(records, page_value, slot_index), language))
    return tuple(outputs)


def history_initial_slot(record: dict | None, language: str | None = "JP") -> dict[str, object]:
    return {
        "visible": bool(record),
        "id": str(record.get("id") or "") if record else "",
        "info": history_card_info_html(record, language),
        "style": history_image_value(record, "style_image"),
        "comparison": history_image_value(record, "comparison_image"),
        "result": history_image_value(record, "result_image"),
    }


def change_history_page(page, language: str | None = "JP"):
    return history_page_outputs(page, language)


def refresh_history_after_mode_switch(mode_name: str | None, page, language: str | None = "JP"):
    if backend_mode_name(mode_name) != MODE_LABELS_JP[2]:
        return tuple(gr.update() for _ in range(2 + HISTORY_PAGE_SIZE * 6))
    return history_page_outputs(page, language)


def show_history_delete_confirm(record_id: str | None, language: str | None = "JP"):
    records = load_history_records()
    record = find_history_record(records, record_id)
    if not record:
        return "", gr.update(visible=False), ui_text(language, "history_select_required")
    return (
        str(record.get("id") or ""),
        gr.update(visible=True),
        ui_text(language, "history_delete_confirm"),
    )


def cancel_history_delete(language: str | None = "JP"):
    return "", gr.update(visible=False), ui_text(language, "history_delete_confirm")


def confirm_history_delete(record_id: str | None, page, language: str | None = "JP"):
    records = load_history_records()
    target = str(record_id or "").strip()
    if not target:
        return (
            "",
            gr.update(visible=False),
            ui_text(language, "history_delete_confirm"),
            *history_page_outputs(page, language, ui_text(language, "history_select_required")),
        )

    remaining: list[dict] = []
    deleted = False
    for record in records:
        if str(record.get("id") or "") == target:
            deleted = True
            for key in ("style_image", "comparison_image", "result_image"):
                remove_history_asset(str(record.get(key) or ""))
            continue
        remaining.append(record)

    if deleted:
        save_history_records(remaining)
    status = ui_text(language, "history_deleted") if deleted else ui_text(language, "history_select_required")
    page_value = normalize_history_page(page, remaining)
    return (
        "",
        gr.update(visible=False),
        ui_text(language, "history_delete_confirm"),
        *history_page_outputs(str(page_value), language, status),
    )


def attach_history_result_image_to_slot(
    record_id: str | None,
    result_image,
    page,
    language: str | None = "JP",
):
    records = load_history_records()
    record = find_history_record(records, record_id)
    if not record:
        return (
            gr.update(value=None),
            "",
            history_page_status_text(records, page, language, ui_text(language, "history_select_required")),
        )
    copied = copy_history_asset(result_image, str(record.get("id") or new_history_id()), "result")
    if copied:
        record["result_image"] = copied
    record["updated_at"] = history_now()
    save_history_records(records)
    return (
        gr.update(
            label=ui_text(language, "history_result_short"),
            value=history_image_value(record, "result_image"),
            placeholder=ui_text(language, "history_result_drop_short"),
        ),
        history_card_info_html(record, language),
        history_page_status_text(records, page, language, ui_text(language, "history_result_saved")),
    )


def clear_history_result_image_from_slot(
    record_id: str | None,
    page,
    language: str | None = "JP",
):
    records = load_history_records()
    record = find_history_record(records, record_id)
    if not record:
        return (
            gr.update(
                label=ui_text(language, "history_result_short"),
                value=None,
                placeholder=ui_text(language, "history_result_drop_short"),
            ),
            "",
            history_page_status_text(records, page, language, ui_text(language, "history_select_required")),
        )
    remove_history_asset(str(record.get("result_image") or ""))
    record["result_image"] = ""
    record["updated_at"] = history_now()
    save_history_records(records)
    return (
        gr.update(
            label=ui_text(language, "history_result_short"),
            value=None,
            placeholder=ui_text(language, "history_result_drop_short"),
        ),
        history_card_info_html(record, language),
        history_page_status_text(records, page, language, ui_text(language, "history_result_cleared")),
    )


def selected_history_outputs(record_id: str | None, language: str | None = "JP", status: str = ""):
    records = load_history_records()
    record = find_history_record(records, record_id)
    selected_id = str(record.get("id") or "") if record else ""
    status_value = status or ("" if record else ui_text(language, "history_empty"))
    return (
        selected_id,
        history_detail_html(record, language),
        history_preview_value(record, "style_image", language),
        history_preview_value(record, "comparison_image", language),
        gr.update(
            label=ui_text(language, "history_result_image"),
            value=history_image_value(record, "result_image"),
            placeholder=ui_text(language, "history_result_image"),
        ),
        gr.update(
            label=ui_text(language, "history_description"),
            value=str(record.get("description") or "") if record else "",
        ),
        gr.update(
            label=ui_text(language, "history_result_note"),
            value=str(record.get("result_note") or "") if record else "",
        ),
        status_value,
    )


def select_history_record(record_id: str | None, language: str | None = "JP"):
    return selected_history_outputs(record_id, language)


def save_history_text(record_id: str | None, description: str | None, result_note: str | None, language: str | None = "JP"):
    records = load_history_records()
    record = find_history_record(records, record_id)
    if not record:
        return history_detail_html(None, language), ui_text(language, "history_select_required")
    record["description"] = str(description or "")
    record["result_note"] = str(result_note or "")
    record["updated_at"] = history_now()
    save_history_records(records)
    return history_detail_html(record, language), ui_text(language, "history_saved")


def attach_history_result_image(
    record_id: str | None,
    result_image,
    result_note: str | None,
    language: str | None = "JP",
):
    records = load_history_records()
    record = find_history_record(records, record_id)
    if not record:
        return None, history_detail_html(None, language), ui_text(language, "history_select_required")
    copied = copy_history_asset(result_image, str(record.get("id") or new_history_id()), "result")
    if copied:
        record["result_image"] = copied
    record["result_note"] = str(result_note or record.get("result_note") or "")
    record["updated_at"] = history_now()
    save_history_records(records)
    return (
        history_image_value(record, "result_image"),
        history_detail_html(record, language),
        ui_text(language, "history_result_saved"),
    )


def make_training_history_event(
    training_status: str,
    output_path: str | None,
    output_dir: Path,
    lora_name: str,
) -> str:
    status = str(training_status or "").strip().lower()
    if status not in {"completed", "stopped"}:
        return ""
    return json.dumps(
        {
            "event_id": new_history_id(),
            "training_status": status,
            "output_path": str(output_path or ""),
            "expected_path": str(output_dir / f"{lora_name}.safetensors"),
            "lora_name": lora_name,
            "created_at": history_now(),
        },
        ensure_ascii=False,
    )


def record_training_event_to_history(
    history_event,
    style_image,
    comparison_image,
    training_type,
    output_name,
    target_caption,
    comparison_caption,
    lora_output_dir,
    max_train_steps,
    save_step_variants,
    grayscale_reference_for_color,
    learning_rate,
    train_batch_size,
    network_dim,
    network_alpha,
    merge_strength,
    speed_preset,
    language,
):
    event_text = str(history_event or "").strip()
    if not event_text:
        return tuple(gr.update() for _ in range(2 + HISTORY_PAGE_SIZE * 6))
    try:
        event = json.loads(event_text)
    except Exception:
        return tuple(gr.update() for _ in range(2 + HISTORY_PAGE_SIZE * 6))
    if not isinstance(event, dict):
        return tuple(gr.update() for _ in range(2 + HISTORY_PAGE_SIZE * 6))

    status_key = str(event.get("training_status") or "").strip().lower()
    if status_key not in {"completed", "stopped"}:
        return tuple(gr.update() for _ in range(2 + HISTORY_PAGE_SIZE * 6))
    event_id = str(event.get("event_id") or "")
    expected_name = safe_output_name(event.get("lora_name") or output_name or "style_diff_lora")
    output_text = str(event.get("output_path") or "").strip()
    expected_path_text = str(event.get("expected_path") or "").strip()
    if not expected_path_text:
        output_dir = settings.normalize_user_path(lora_output_dir, settings.get_lora_output_dir())
        expected_path_text = str(output_dir / f"{expected_name}.safetensors")
    if status_key == "completed":
        if not output_text:
            return tuple(gr.update() for _ in range(2 + HISTORY_PAGE_SIZE * 6))
        lora_path = Path(output_text)
        if not lora_path.exists() or lora_path.stem != expected_name:
            return tuple(gr.update() for _ in range(2 + HISTORY_PAGE_SIZE * 6))
        lora_path_text = output_text
    else:
        lora_path_text = expected_path_text

    records = load_history_records()
    record = None
    for candidate in records:
        if event_id and str(candidate.get("event_id") or "") == event_id:
            record = candidate
            break
        if status_key == "completed" and str(candidate.get("lora_path") or "") == output_text:
            record = candidate
            break

    if record is None:
        record_id = new_history_id()
        description = format_caption_for_display(target_caption or comparison_caption or "")
        created_at = str(event.get("created_at") or history_now())
        record = {
            "id": record_id,
            "event_id": event_id,
            "created_at": created_at,
            "updated_at": history_now(),
            "training_status": status_key,
            "model_type": "Anima" if training_type == "Anima" else "SDXL",
            "lora_name": expected_name,
            "lora_path": lora_path_text,
            "description": description,
            "target_caption": format_caption_for_display(target_caption or ""),
            "comparison_caption": format_caption_for_display(comparison_caption or ""),
            "style_image": copy_history_asset(style_image, record_id, "style"),
            "comparison_image": copy_history_asset(comparison_image, record_id, "comparison"),
            "result_image": "",
            "result_note": "",
            "settings": {
                "speed_preset": backend_speed_name(speed_preset),
                "steps": int(max_train_steps or 0),
                "save_step_variants": bool(save_step_variants),
                "grayscale_reference_for_color": bool(grayscale_reference_for_color),
                "learning_rate": float(learning_rate or 0),
                "batch": int(train_batch_size or 0),
                "dim": int(network_dim or 0),
                "alpha": float(network_alpha or 0),
                "merge_strength": float(merge_strength or 0),
            },
        }
        records.append(record)
    else:
        record["updated_at"] = history_now()
        record["training_status"] = status_key
        if event_id:
            record["event_id"] = event_id

    save_history_records(records)
    return history_page_outputs("1", language, ui_text(language, "history_recorded"))


def format_elapsed(seconds: float, language: str | None = "JP") -> str:
    total = max(0, int(round(seconds)))
    hours, remainder = divmod(total, 3600)
    minutes, secs = divmod(remainder, 60)
    if hours:
        return f"{hours:02d}:{minutes:02d}:{secs:02d}"
    return f"{minutes:02d}:{secs:02d}"


def localized_style_choices(language: str | None) -> list[str]:
    return STYLE_LABELS_EN if language == "EN" else STYLE_LABELS_JP


def backend_style_name(style_name: str | None) -> str | None:
    if style_name in STYLE_EN_TO_JP:
        style_name = STYLE_EN_TO_JP[style_name]
    if style_name in STYLE_JP_TO_BACKEND:
        return STYLE_JP_TO_BACKEND[style_name]
    return style_name


def frontend_style_name(style_name: str | None, language: str | None) -> str:
    backend_name = backend_style_name(style_name)
    jp_label = STYLE_BACKEND_TO_JP.get(backend_name or "", style_name)
    if language == "EN":
        if jp_label in STYLE_JP_TO_EN:
            return STYLE_JP_TO_EN[jp_label]
        if style_name in STYLE_LABELS_EN:
            return style_name
        return STYLE_LABELS_EN[0]
    if jp_label in STYLE_LABELS_JP:
        return jp_label
    return STYLE_LABELS_JP[0]


def is_anima_training_type(training_type: str | None) -> bool:
    return training_type == "Anima"


def speed_heading_text(language: str | None, training_type: str | None = "SDXL") -> str:
    key = "speed_anima" if is_anima_training_type(training_type) else "speed_sdxl"
    return ui_text(language, key)


def localized_speed_choices(language: str | None, training_type: str | None = "SDXL") -> list[str]:
    if is_anima_training_type(training_type):
        return ANIMA_SPEED_LABELS_EN if language == "EN" else ANIMA_SPEED_LABELS_JP
    return SDXL_SPEED_LABELS_EN if language == "EN" else SDXL_SPEED_LABELS_JP


def backend_speed_name(speed_name: str | None) -> str:
    if speed_name in SPEED_EN_TO_JP:
        speed_name = SPEED_EN_TO_JP[speed_name]
    if speed_name in SPEED_ALIASES_JP:
        speed_name = SPEED_ALIASES_JP[speed_name]
    speed_name = speed_name or settings.DEFAULT_SPEED_PRESET
    return speed_name if speed_name in SPEED_LABELS_JP else settings.DEFAULT_SPEED_PRESET


def frontend_speed_name(speed_name: str | None, language: str | None, training_type: str | None = "SDXL") -> str:
    speed_name = backend_speed_name(speed_name)
    if is_anima_training_type(training_type):
        if language == "EN":
            return SPEED_JP_TO_ANIMA_EN.get(speed_name, ANIMA_SPEED_LABELS_EN[0])
        return SPEED_JP_TO_ANIMA.get(speed_name, ANIMA_SPEED_LABELS_JP[0])
    if language == "EN":
        return SPEED_JP_TO_SDXL_EN.get(speed_name, SDXL_SPEED_LABELS_EN[0])
    return SPEED_JP_TO_SDXL.get(speed_name, SDXL_SPEED_LABELS_JP[0])


def preset_description(speed_name: str | None, language: str | None = "JP", training_type: str | None = "SDXL") -> str:
    preset_name = backend_speed_name(speed_name)
    if is_anima_training_type(training_type):
        if language == "EN":
            return ANIMA_SPEED_DESCRIPTIONS_EN.get(
                preset_name,
                ANIMA_SPEED_DESCRIPTIONS_EN[settings.DEFAULT_SPEED_PRESET],
            )
        return ANIMA_SPEED_DESCRIPTIONS_JP.get(
            preset_name,
            ANIMA_SPEED_DESCRIPTIONS_JP[settings.DEFAULT_SPEED_PRESET],
        )
    if language == "EN":
        return SPEED_DESCRIPTIONS_EN.get(preset_name, SPEED_DESCRIPTIONS_EN[settings.DEFAULT_SPEED_PRESET])
    return SPEED_DESCRIPTIONS_JP.get(preset_name, SPEED_DESCRIPTIONS_JP[settings.DEFAULT_SPEED_PRESET])


def preset_description_update(speed_name: str | None, language: str | None = "JP", training_type: str | None = "SDXL"):
    return gr.update(value=preset_description(speed_name, language, training_type), visible=True)


def color_focus_for_speed_preset(speed_name: str | None) -> bool:
    return backend_speed_name(speed_name) == "詳細学習セット"


def low_vram_for_speed_preset(speed_name: str | None) -> bool:
    return False


def gradient_checkpointing_for_speed_preset(speed_name: str | None) -> bool:
    return True


def localized_mode_choices(language: str | None) -> list[str]:
    return MODE_LABELS_EN if language == "EN" else MODE_LABELS_JP


def backend_mode_name(mode_name: str | None) -> str:
    if mode_name in MODE_EN_TO_JP:
        return MODE_EN_TO_JP[mode_name]
    if mode_name in MODE_LABELS_JP:
        return mode_name
    return MODE_LABELS_JP[0]


def frontend_mode_name(mode_name: str | None, language: str | None) -> str:
    mode_name = backend_mode_name(mode_name)
    if language == "EN":
        return MODE_JP_TO_EN.get(mode_name, MODE_LABELS_EN[0])
    return mode_name


def build_pair_prompt_local(
    style_name: str | None = None,
    content_caption: str | None = None,
    image_size: tuple[int, int] | None = None,
    language: str | None = "JP",
) -> str:
    if language != "EN":
        return build_pair_prompt(backend_style_name(style_name), content_caption, image_size)

    backend_style = backend_style_name(style_name)
    direction = style_name or STYLE_LABELS_EN[0]
    clean_content_caption = clean_caption_reference(content_caption)
    content_line = f"\n\nContent tag reference:\n{clean_content_caption}" if clean_content_caption else ""
    if image_size:
        width, height = image_size
        aspect = width / max(height, 1)
        size_line = (
            f"- Output at the same size as the source image, {width}x{height}, or the exact same aspect ratio {aspect:.4f}:1.\n"
            "- Do not make it square. Do not add margins. Keep face/body placement, scale, and camera distance unchanged.\n"
        )
    else:
        size_line = (
            "- Output at the same width and height as the source image, or the exact same aspect ratio.\n"
            "- Do not make it square. Do not add margins. Keep face/body placement, scale, and camera distance unchanged.\n"
        )
    if backend_style == "全身・ちびキャラ用":
        return (
            "Use the attached image as reference. Create one comparison image for LoRA training while keeping the person, pose, expression, clothing, hairstyle, composition, and background placement as close as possible, but changing only the visual style and body proportions dramatically."
            f"{content_line}\n\n"
            "Comparison direction:\n"
            "Reconstruct it as a complete real photograph. Make it look like a portrait or cosplay photo of a real person. Do not keep the source lineart, cel shading, anime look, illustration look, or specific art-style quirks.\n\n"
            "Important:\n"
            "- Do not return to a cute anime style, painterly anime style, or AI illustration style.\n"
            "- Use real skin, hair, fabric, and light instead of drawn outlines.\n"
            "- Do not naturally correct the face or body into a realistic human. Eye size, iris, pupil, and highlights may change toward realism.\n\n"
            "Conditions:\n"
            "- Do not change the character, objects, clothing, pose, camera distance, or background placement.\n"
            "- Change only line quality, coloring, shadows, texture, color design, and rendering medium.\n"
            "- Do not include artist names, work names, or instructions to imitate a specific artist.\n"
            "- Do not add logos, text, signatures, watermarks, or extra props.\n"
            f"{size_line}"
            "- The finished image should have the same content when placed next to the source, with only the style and body proportions changed.\n\n"
            "Failure cases:\n"
            "- The image keeps almost the same style, lineart, cel shading, or color design as the source.\n"
            "- Photoreal mode still looks anime, illustration-like, or CG-illustration-like.\n"
            "- The face or body is corrected into a natural realistic human.\n"
            "- The face outline, neck, shoulders, or clothing outer silhouette changes from the source.\n"
            "- Clothing, pose, composition, crop, or aspect ratio changes from the source."
        )

    return (
        "Use the attached image as reference. Create one comparison image for LoRA training with the same person, pose, expression, clothing, "
        "hairstyle, composition, and background placement, but with a clearly different visual style."
        f"{content_line}\n\n"
        f"Comparison direction:\n{direction}\n\n"
        "Important:\n"
        "- Highest priority: keep the outer silhouette as close as possible to the source image.\n"
        "- Do not change face width, cheeks, jaw shape, head size, neck length, shoulder width, torso, or clothing outline.\n"
        "- Do not correct the body or face to look naturally human. Even if it looks unnatural, preserve the source outline.\n"
        "- Eye size, iris, pupil, highlights, and eye texture may change to match the comparison direction.\n"
        "- Do not change the character, objects, clothing, pose, camera distance, or background layout.\n"
        "- Change only line quality, coloring, shadows, texture, medium, and rendering style.\n"
        "- Do not include artist names, work names, or instructions to imitate a specific artist.\n"
        "- Do not add logos, text, signatures, watermarks, or extra props.\n"
        f"{size_line}"
        "- The finished image should look like the same content in a different style.\n\n"
        "Failure cases:\n"
        "- The image keeps almost the same style, lineart, cel shading, or color design as the source.\n"
        "- Photoreal mode still looks anime, illustration-like, or CG-illustration-like.\n"
        "- The face or body is corrected to look naturally human.\n"
        "- The face outline, neck, shoulders, or clothing silhouette changes from the source.\n"
        "- Clothing, pose, composition, crop, or aspect ratio changes from the source."
    )


def prompt_choice_styles(language: str | None = "JP") -> tuple[str, str]:
    if language == "EN":
        return STYLE_LABELS_EN[0], STYLE_LABELS_EN[1]
    return STYLE_LABELS_JP[0], STYLE_LABELS_JP[1]


def image_size_for_prompt(image_path: str | Path | None) -> tuple[int, int] | None:
    if not image_path:
        return None
    try:
        with Image.open(image_path) as image:
            return image.size
    except Exception:
        return None


def prompt_size_line(image_size: tuple[int, int] | None, language: str | None = "JP") -> str:
    if image_size:
        width, height = image_size
        aspect = width / max(height, 1)
        if language == "EN":
            return (
                f"- Output at the same size as the source image, {width}x{height}, or the exact same aspect ratio {aspect:.4f}:1.\n"
                "- Do not make it square. Do not add margins. Keep face/body placement, scale, and camera distance unchanged.\n"
            )
        return (
            f"- 出力サイズは元画像と同じ {width}x{height}、または完全に同じ縦横比 {aspect:.4f}:1 にする\n"
            "- 正方形に変えない。上下左右に余白を足さない。顔や体の位置、拡大率、カメラ距離も変えない\n"
        )
    if language == "EN":
        return (
            "- Output at the same width and height as the source image, or the exact same aspect ratio.\n"
            "- Do not make it square. Do not add margins. Keep face/body placement, scale, and camera distance unchanged.\n"
        )
    return (
        "- 出力サイズは元画像と同じ幅・高さ、または完全に同じ縦横比にする\n"
        "- 正方形に変えない。上下左右に余白を足さない。顔や体の位置、拡大率、カメラ距離も変えない\n"
    )


def build_concept_slider_prompt_local(image_size: tuple[int, int] | None = None, language: str | None = "JP") -> str:
    size_line = prompt_size_line(image_size, language)
    if language == "EN":
        return (
            "Use the attached image as reference. Create one comparison image for LoRA training while keeping the person, pose, expression, clothing, hairstyle, composition, and background placement as close as possible, but changing ____ to ____.\n\n"
            "Comparison direction:\n"
            "Change ____ to ____.\n\n"
            "Use this for a concept slider LoRA, such as controlling breast size, line thickness, eye size, shadow strength, color saturation, or another single visual concept.\n\n"
            "Conditions:\n"
            "- Change only the concept written in the blanks above.\n"
            "- Keep the character, objects, clothing, pose, camera distance, crop, and background layout unchanged.\n"
            "- Do not change the overall art style unless the blank specifically asks for a style-related change such as line thickness or coloring.\n"
            "- Do not include artist names, work names, logos, text, signatures, watermarks, or extra props.\n"
            f"{size_line}"
            "- The finished image should look like the same image with only that one concept changed."
        )
    return (
        "添付画像を参照して、人物・ポーズ・表情・服・髪型・構図・背景の位置関係をできるだけ同じに保ったまま、＿＿を＿＿に変更したLoRA学習用の比較画像を1枚作ってください。\n\n"
        "比較画像の方向:\n"
        "＿＿を＿＿に変更する。\n\n"
        "胸の大きさ、線の太さ、目の大きさ、影の強さ、彩度など、単一の見た目の概念をコントロールする概念スライダーLoRA用です。\n\n"
        "条件:\n"
        "- 上の空欄に書いた概念だけを変える\n"
        "- キャラクターや物体、服、ポーズ、カメラ距離、トリミング、背景配置は変えない\n"
        "- 線の太さや色などを指定した場合を除き、全体の画風は変えない\n"
        "- 元画像の作者名、作品名、固有画風を真似する指定は入れない\n"
        "- ロゴ、文字、署名、透かし、余計な小物は入れない\n"
        f"{size_line}"
        "- 完成画像は、元画像と並べて見た時にその概念だけが違う状態にする"
    )


def build_gpt_prompt_choices(style_image: str | Path | None, language: str | None = "JP") -> tuple[str, str, str]:
    image_size = image_size_for_prompt(upload_path(style_image))
    normal_style, special_style = prompt_choice_styles(language)
    return (
        build_pair_prompt_local(normal_style, image_size=image_size, language=language),
        build_pair_prompt_local(special_style, image_size=image_size, language=language),
        build_concept_slider_prompt_local(image_size=image_size, language=language),
    )


def build_reference_prompt_bundle_local(style_image: str | Path, style_name: str | None = None, language: str | None = "JP"):
    target_caption, reference_caption, instruction, danbooru_prompt, negative_prompt, report = build_reference_prompt_bundle(
        style_image,
        backend_style_name(style_name),
    )
    if language != "EN":
        return target_caption, reference_caption, instruction, danbooru_prompt, negative_prompt, report
    try:
        with Image.open(style_image) as image:
            image_size = image.size
    except Exception:
        image_size = None
    instruction = build_pair_prompt_local(style_name, target_caption, image_size, "EN")
    if image_size:
        width, height = image_size
        report = (
            f"Style image tagged.\n"
            f"Recommended comparison size: {width}x{height}.\n"
            "Use the instruction and danbooru tag draft below to create the comparison image.\n"
            "After creating it, check that the person, clothing, pose, and composition match the source."
        )
    else:
        report = (
            "Style image tagged.\n"
            "Use the instruction and danbooru tag draft below to create the comparison image.\n"
            "After creating it, check that the person, clothing, pose, and composition match the source."
        )
    return target_caption, reference_caption, instruction, danbooru_prompt, negative_prompt, report


def file_choices(root: str | Path, files: list[str]) -> list[tuple[str, str]]:
    root_path = Path(root)
    choices: list[tuple[str, str]] = []
    seen: dict[str, int] = {}
    for model in files:
        model_path = Path(model)
        try:
            label = str(model_path.relative_to(root_path))
        except ValueError:
            label = model_path.name
        seen[label] = seen.get(label, 0) + 1
        if seen[label] > 1:
            label = f"{label} ({seen[label]})"
        choices.append((label, model))
    return choices


def root_has_candidate_file(root: Path, keywords: tuple[str, ...] = ()) -> bool:
    if not root.exists() or not root.is_dir():
        return False
    extensions = {".safetensors", ".ckpt", ".pth", ".pt", ".bin"}
    lowered_keywords = tuple(keyword.lower() for keyword in keywords)
    try:
        children = list(root.iterdir())
    except OSError:
        return False
    for child in children:
        if not child.is_file() or child.suffix.lower() not in extensions:
            continue
        haystack = child.name.lower()
        if not lowered_keywords or any(keyword in haystack for keyword in lowered_keywords):
            return True
    return False


def direct_subdir(root: Path, names: tuple[str, ...], direct_file_keywords: tuple[str, ...] = ()) -> Path:
    if root.exists():
        try:
            children = {child.name.lower(): child for child in root.iterdir() if child.is_dir()}
        except OSError:
            children = {}
        for name in names:
            found = children.get(name.lower())
            if found:
                return found
    if root.name.lower() in {name.lower() for name in names}:
        return root
    if root_has_candidate_file(root, direct_file_keywords):
        return root
    return root / names[0]


def existing_direct_subdir(root: Path, names: tuple[str, ...]) -> Path | None:
    if not root.exists():
        return None
    try:
        children = {child.name.lower(): child for child in root.iterdir() if child.is_dir()}
    except OSError:
        return None
    for name in names:
        found = children.get(name.lower())
        if found:
            return found
    return None


def component_path_choices(root: str | Path | None, fallback: Path, include_model_dirs: bool = False) -> list[tuple[str, str]]:
    root_path = settings.normalize_user_path(root, fallback) if root else fallback
    paths: list[str] = []
    if not root_path.exists():
        return []

    for extension in (".safetensors", ".pth", ".pt", ".bin"):
        for path in sorted(root_path.rglob(f"*{extension}")):
            paths.append(str(path))

    if include_model_dirs:
        marker_names = {
            "config.json",
            "tokenizer.json",
            "tokenizer_config.json",
            "model.safetensors",
            "pytorch_model.bin",
        }
        candidate_dirs = [root_path] + [path for path in sorted(root_path.rglob("*")) if path.is_dir()]
        for directory in candidate_dirs:
            try:
                names = {child.name for child in directory.iterdir()}
            except OSError:
                continue
            if marker_names & names:
                paths.append(str(directory))

    return file_choices(root_path, sorted(dict.fromkeys(paths)))


def choose_saved_or_guess(
    saved: str,
    choices: list[tuple[str, str]],
    keywords: tuple[str, ...] = (),
    avoid: tuple[str, ...] = (),
) -> str | None:
    choice_values = {str(value) for _, value in choices}
    if saved and Path(saved).exists() and saved in choice_values:
        return saved

    def allowed(value: str) -> bool:
        name = Path(value).name.lower()
        full = value.lower()
        return not any(keyword in name or keyword in full for keyword in avoid)

    if keywords:
        for keyword in keywords:
            keyword = keyword.lower()
            for _, value in choices:
                name = Path(value).name.lower()
                full = value.lower()
                if allowed(value) and (keyword in name or keyword in full):
                    return value
        return None

    for _, value in choices:
        if allowed(value):
            return value
    return None


def anima_dit_choices(checkpoint_choices: list[tuple[str, str]]) -> list[tuple[str, str]]:
    choices: list[tuple[str, str]] = []
    total = len(checkpoint_choices)
    if total:
        startup_log(f"Animaモデル候補を判定中... 0/{total}")
    last_log_at = time.perf_counter()
    for index, (label, value) in enumerate(checkpoint_choices, start=1):
        model_path = Path(value)
        if model_path.suffix.lower() != ".safetensors":
            continue
        cache_key = str(model_path)
        if cache_key not in ANIMA_DIT_CACHE:
            ANIMA_DIT_CACHE[cache_key] = is_anima_dit_checkpoint(model_path)
        if ANIMA_DIT_CACHE[cache_key]:
            choices.append((label, value))
        now = time.perf_counter()
        if total and (now - last_log_at >= 5 or index == total):
            startup_log(f"Animaモデル候補を判定中... {index}/{total}")
            last_log_at = now
    return choices


def scan_models_bundle(models_root: str | Path, user_settings: dict[str, str] | None = None) -> dict[str, object]:
    scan_started_at = time.perf_counter()
    values = user_settings or settings.load_user_settings()
    root = settings.normalize_user_path(models_root, settings.get_models_root_dir())
    startup_log(f"Modelsフォルダを確認中: {root}")
    checkpoint_dir = direct_subdir(root, CHECKPOINT_DIR_NAMES)
    text_encoder_dir = direct_subdir(root, TEXT_ENCODER_DIR_NAMES, ("qwen", "text", "encoder"))
    vae_dir = direct_subdir(root, VAE_DIR_NAMES, ("vae",))
    lora_output_dir = existing_direct_subdir(root, LORA_DIR_NAMES)

    section_started_at = time.perf_counter()
    startup_log(f"StableDiffusionモデルを検索中: {checkpoint_dir}")
    checkpoint_choices = file_choices(checkpoint_dir, scan_models(checkpoint_dir))
    startup_log(
        f"StableDiffusionモデル: {len(checkpoint_choices)}件 "
        f"({time.perf_counter() - section_started_at:.1f}秒)"
    )

    section_started_at = time.perf_counter()
    anima_choices = anima_dit_choices(checkpoint_choices)
    startup_log(f"Animaモデル候補: {len(anima_choices)}件 ({time.perf_counter() - section_started_at:.1f}秒)")
    anima_values = {value for _, value in anima_choices}

    section_started_at = time.perf_counter()
    startup_log(f"Qwen3候補を検索中: {text_encoder_dir}")
    qwen3_files = component_path_choices(text_encoder_dir, text_encoder_dir, include_model_dirs=True)
    startup_log(f"Qwen3候補: {len(qwen3_files)}件 ({time.perf_counter() - section_started_at:.1f}秒)")

    section_started_at = time.perf_counter()
    startup_log(f"VAE候補を検索中: {vae_dir}")
    vae_files = component_path_choices(vae_dir, vae_dir)
    startup_log(f"VAE候補: {len(vae_files)}件 ({time.perf_counter() - section_started_at:.1f}秒)")

    saved_sdxl_model = values.get("sdxl_base_model_path", "")
    sdxl_choice_values = {value for _, value in checkpoint_choices}
    if saved_sdxl_model and saved_sdxl_model in sdxl_choice_values and saved_sdxl_model not in anima_values:
        sdxl_model = saved_sdxl_model
    else:
        sdxl_model = choose_saved_or_guess(
            "",
            [(label, value) for label, value in checkpoint_choices if value not in anima_values],
        )
    anima_model = choose_saved_or_guess(
        values.get("anima_dit_path", ""),
        anima_choices,
    )
    anima_qwen3 = choose_saved_or_guess(
        values.get("anima_qwen3_path", ""),
        qwen3_files,
        ("qwen_3", "qwen3", "qwen-3", "qwen_06", "qwen06"),
        ("vae", "adapter"),
    )
    anima_vae = choose_saved_or_guess(
        values.get("anima_vae_path", ""),
        vae_files,
        ("qwen_image_vae", "qwen-image-vae", "qwenimagevae", "qwen_image", "qwen-image"),
        ("adapter", "text_encoder", "encoder"),
    )

    startup_log(f"モデル一覧の準備が完了しました。({time.perf_counter() - scan_started_at:.1f}秒)")
    return {
        "root": root,
        "checkpoint_dir": checkpoint_dir,
        "text_encoder_dir": text_encoder_dir,
        "vae_dir": vae_dir,
        "lora_output_dir": lora_output_dir,
        "checkpoint_choices": checkpoint_choices,
        "anima_choices": anima_choices,
        "qwen3_choices": qwen3_files,
        "vae_choices": vae_files,
        "sdxl_model": sdxl_model,
        "anima_model": anima_model,
        "anima_qwen3": anima_qwen3,
        "anima_vae": anima_vae,
    }


def format_model_status(bundle: dict[str, object], language: str | None = "JP") -> str:
    if bundle["checkpoint_choices"] or bundle["anima_choices"]:
        return ui_text(language, "model_status_loaded")
    return ui_text(language, "model_status_empty")


def save_models_root(models_root: str, language: str = "JP"):
    ANIMA_DIT_CACHE.clear()
    if not str(models_root or "").strip():
        empty_status = ui_text(language, "model_status_empty")
        return (
            "",
            path_display_html("", ui_text(language, "models_placeholder")),
            gr.update(choices=[], value=None),
            gr.update(choices=[], value=None),
            gr.update(choices=[], value=None),
            gr.update(choices=[], value=None),
            gr.update(),
            gr.update(),
            gr.update(),
            empty_status,
            ui_text(language, "models_required"),
        )
    bundle = scan_models_bundle(models_root)
    root = bundle["root"]
    checkpoint_dir = bundle["checkpoint_dir"]
    text_encoder_dir = bundle["text_encoder_dir"]
    vae_dir = bundle["vae_dir"]
    lora_output_dir = bundle["lora_output_dir"]

    updates = {
        "models_root_dir": str(root),
        "sdxl_model_dir": str(checkpoint_dir),
        "anima_model_dir": str(checkpoint_dir),
        "anima_text_encoder_dir": str(text_encoder_dir),
        "anima_vae_dir": str(vae_dir),
        "anima_llm_adapter_path": "",
        "sdxl_base_model_path": str(bundle["sdxl_model"] or ""),
        "anima_dit_path": str(bundle["anima_model"] or ""),
        "anima_qwen3_path": str(bundle["anima_qwen3"] or ""),
        "anima_vae_path": str(bundle["anima_vae"] or ""),
    }
    if lora_output_dir:
        updates["lora_output_dir"] = str(lora_output_dir)
    settings.save_user_settings(updates)

    if not Path(root).exists():
        status = f"{ui_text(language, 'models_missing')}\n{root}"
    else:
        status = ui_text(language, "models_loaded")
    return (
        str(root),
        path_display_html(str(root), ui_text(language, "models_placeholder")),
        gr.update(choices=bundle["checkpoint_choices"], value=bundle["sdxl_model"]),
        gr.update(choices=bundle["anima_choices"], value=bundle["anima_model"]),
        gr.update(choices=bundle["qwen3_choices"], value=bundle["anima_qwen3"]),
        gr.update(choices=bundle["vae_choices"], value=bundle["anima_vae"]),
        str(lora_output_dir) if lora_output_dir else gr.update(),
        path_display_html(str(lora_output_dir), ui_text(language, "output_placeholder")) if lora_output_dir else gr.update(),
        path_display_html(str(lora_output_dir), ui_text(language, "fusion_lora_folder_placeholder")) if lora_output_dir else gr.update(),
        format_model_status(bundle, language),
        status,
    )


def scan_lora_files(lora_dir: str | Path | None = None) -> list[tuple[str, str]]:
    directory = settings.normalize_user_path(lora_dir, settings.get_lora_output_dir()) if lora_dir else settings.get_lora_output_dir()
    files = scan_files(directory, settings.get_lora_output_dir(), (".safetensors", ".pt"))
    return file_choices(directory, files)


def normalize_lora_choice(choice) -> tuple[str, str]:
    if isinstance(choice, (list, tuple)) and len(choice) >= 2:
        return str(choice[0]), str(choice[1])
    value = str(choice)
    return Path(value).name, value


def normalized_selected_lora_values(selected_loras, limit: int | None = None) -> tuple[list[str], bool]:
    if isinstance(selected_loras, str):
        candidates = [selected_loras]
    else:
        candidates = selected_loras or []
    values: list[str] = []
    for choice in candidates:
        _, value = normalize_lora_choice(choice)
        value_text = value.strip()
        if value_text and value_text not in values:
            values.append(value_text)
    trimmed = bool(limit is not None and len(values) > limit)
    if limit is not None:
        values = values[:limit]
    return values, trimmed


def fusion_weight_slot_html(selected_values: list[str], index: int, language: str | None = "JP") -> str:
    weight_text = html_utils.escape(ui_text(language, "fusion_selected_weight"))
    empty_slot = html_utils.escape(ui_text(language, "fusion_selected_empty_slot"))
    value = selected_values[index] if index < len(selected_values) else ""
    class_name = "fusion-selected-slot fusion-selected-filled" if value else "fusion-selected-slot fusion-selected-empty"
    name = html_utils.escape(Path(value).name) if value else empty_slot
    return (
        f'<div class="{class_name}">'
        f'<span>{weight_text} {index + 1}</span><strong>{name}</strong>'
        '</div>'
    )


def fusion_selection_notice_update(selected_values: list[str], language: str | None = "JP", trimmed: bool = False):
    if trimmed:
        return gr.update(value=ui_text(language, "fusion_selected_trimmed"), visible=True)
    if not selected_values:
        return gr.update(value=ui_text(language, "fusion_selected_empty"), visible=True)
    return gr.update(value="", visible=False)


def fusion_weight_value_update(current_value, active: bool):
    if active:
        try:
            numeric_value = float(current_value)
        except (TypeError, ValueError):
            numeric_value = 0.0
        if current_value is None or current_value == "" or numeric_value == 0.0:
            current_value = 1.0
        return gr.update(value=current_value, interactive=True)
    return gr.update(value=None, interactive=False)


def fusion_weight_display_outputs(
    selected_loras,
    language: str | None = "JP",
    weight_1=None,
    weight_2=None,
    weight_3=None,
    weight_4=None,
):
    selected_values, trimmed = normalized_selected_lora_values(selected_loras, FUSION_LORA_MAX_SELECTION)
    weights = [weight_1, weight_2, weight_3, weight_4]
    slot_html = [
        fusion_weight_slot_html(selected_values, index, language)
        for index in range(FUSION_LORA_MAX_SELECTION)
    ]
    weight_updates = [
        fusion_weight_value_update(weights[index], index < len(selected_values))
        for index in range(FUSION_LORA_MAX_SELECTION)
    ]
    return (
        gr.update(value=selected_values),
        fusion_selection_notice_update(selected_values, language, trimmed),
        *slot_html,
        *weight_updates,
    )


def filtered_fusion_lora_choices(all_choices, filter_text: str | None, selected_loras) -> tuple[list[tuple[str, str]], list[str]]:
    normalized = [normalize_lora_choice(choice) for choice in (all_choices or [])]
    by_value = {value: (label, value) for label, value in normalized}
    selected_values, _ = normalized_selected_lora_values(selected_loras, FUSION_LORA_MAX_SELECTION)

    selected_choices: list[tuple[str, str]] = []
    for value in selected_values:
        selected_choices.append(by_value.get(value, (Path(value).name, value)))

    tokens = [token for token in str(filter_text or "").lower().split() if token]
    selected_set = set(selected_values)
    filtered: list[tuple[str, str]] = []
    for label, value in normalized:
        if value in selected_set:
            continue
        haystack = f"{label} {Path(value).name} {value}".lower()
        if all(token in haystack for token in tokens):
            filtered.append((label, value))
    return selected_choices + filtered[:FUSION_LORA_VISIBLE_LIMIT], selected_values


def update_fusion_lora_filter_after_idle(
    filter_text: str,
    selected_loras,
    all_choices,
    observed_filter_text: str | None,
    applied_filter_text: str | None,
):
    current_filter = str(filter_text or "")
    observed_filter = str(observed_filter_text or "")
    applied_filter = str(applied_filter_text or "")
    if current_filter != observed_filter:
        return gr.update(), current_filter, applied_filter
    if current_filter == applied_filter:
        return gr.update(), observed_filter, applied_filter

    filtered_choices, selected_values = filtered_fusion_lora_choices(all_choices, filter_text, selected_loras)
    return gr.update(choices=filtered_choices, value=selected_values), observed_filter, current_filter


def refresh_fusion_loras(lora_output_dir: str, filter_text: str = "", selected_loras=None, language: str = "JP"):
    filter_text = str(filter_text or "")
    choices = scan_lora_files(lora_output_dir)
    filtered_choices, selected_values = filtered_fusion_lora_choices(choices, filter_text, selected_loras)
    message = ui_text(language, "lora_list_loaded") if choices else ui_text(language, "lora_list_empty")
    return choices, gr.update(choices=filtered_choices, value=selected_values), message, filter_text, filter_text


def read_lora_metadata(path: Path) -> dict[str, str]:
    try:
        from safetensors import safe_open

        with safe_open(str(path), framework="pt") as handle:
            return dict(handle.metadata() or {})
    except Exception:
        return {}


def write_lora_metadata(path: Path, updates: dict[str, str]) -> None:
    try:
        from safetensors import safe_open
        from safetensors.torch import load_file, save_file

        with safe_open(str(path), framework="pt") as handle:
            metadata = dict(handle.metadata() or {})
        tensors = {key: value.clone() for key, value in load_file(str(path), device="cpu").items()}
        metadata.update(updates)
        temp_path = path.with_name(path.stem + "_metadata_tmp.safetensors")
        save_file(tensors, str(temp_path), metadata=metadata)
        temp_path.replace(path)
    except Exception:
        return


def lora_tensors_look_anima(tensors: dict, metadata: dict[str, str] | None = None) -> bool:
    metadata = metadata or {}
    network_module = (metadata.get("ss_network_module") or "").lower()
    base_model_version = (metadata.get("ss_base_model_version") or "").lower()
    if "lora_anima" in network_module:
        return True
    if base_model_version == "anima":
        return True
    return any(
        str(key).startswith("diffusion_model_blocks_") or str(key).startswith("lora_unet_blocks_")
        for key in tensors.keys()
    )


def infer_lora_max_rank(tensors: dict) -> int:
    ranks: list[int] = []
    for key, tensor in tensors.items():
        if "lora_down" not in str(key):
            continue
        shape = getattr(tensor, "shape", None)
        if shape and len(shape) >= 1:
            ranks.append(int(shape[0]))
    if not ranks:
        return 8
    return max(1, min(64, max(ranks)))


def fuse_anima_loras_relaxed(
    paths: list[Path],
    normalized_weights: list[float],
    output_path: Path,
    output_rank: int,
) -> None:
    for _message in fuse_anima_loras_relaxed_steps(paths, normalized_weights, output_path, output_rank, "JP", "", None):
        pass


def fuse_anima_loras_relaxed_steps(
    paths: list[Path],
    normalized_weights: list[float],
    output_path: Path,
    output_rank: int,
    language: str | None,
    output_label: str,
    input_ranks: list[int] | None = None,
):
    yield ui_text(language, "fusion_progress_scripts")
    _, merge_module, resize_module = import_anima_scripts()
    work_dir = settings.RUNS_DIR / "fusion_work" / (time.strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:8])
    work_dir.mkdir(parents=True, exist_ok=True)
    temp_merge = work_dir / (output_path.stem + "_concat_raw.safetensors")

    try:
        yield ui_text(language, "fusion_progress_concat").format(label=output_label)
        if input_ranks is None or len(input_ranks) != len(paths):
            from safetensors.torch import load_file

            input_ranks = [infer_lora_max_rank(load_file(str(path), device="cpu")) for path in paths]
        rank_sum = max(1, sum(max(1, int(rank)) for rank in input_ranks))
        # sd-scripts concat stores one alpha for the concatenated rank. Compensate by input rank
        # so a 1:1:1 fusion means equal full-delta contribution, not equal factor scaling.
        concat_ratios = [
            math.sqrt(max(0.0, weight) * rank_sum / max(1, int(rank)))
            for weight, rank in zip(normalized_weights, input_ranks)
        ]
        merge_args = argparse.Namespace(
            save_precision="bf16",
            precision="float",
            sd_model=None,
            save_to=str(temp_merge),
            models=[str(path) for path in paths],
            no_metadata=False,
            ratios=concat_ratios,
            concat=True,
            shuffle=True,
            lbws=[],
            v2=False,
        )
        merge_module.merge(merge_args)

        try:
            import torch

            device = "cuda" if torch.cuda.is_available() else "cpu"
        except Exception:
            device = "cpu"

        yield ui_text(language, "fusion_progress_resize").format(label=output_label, rank=output_rank)
        resize_args = argparse.Namespace(
            save_precision="bf16",
            new_rank=int(output_rank),
            new_conv_rank=int(output_rank),
            save_to=str(output_path),
            model=str(temp_merge),
            device=device,
            verbose=True,
            dynamic_param=None,
            dynamic_method=None,
            svd_lowrank_niter=2,
            lbws=[],
        )
        resize_module.resize(resize_args)
    finally:
        try:
            temp_merge.unlink(missing_ok=True)
            work_dir.rmdir()
        except Exception:
            pass


def fuse_loras(
    selected_loras,
    weight_1,
    weight_2,
    weight_3,
    weight_4,
    fusion_output_name,
    fusion_output_dims,
    lora_output_dir,
    language,
):
    log_lines: list[str] = []

    def log_text() -> str:
        return "\n".join(log_lines)

    def output_paths_text(records: list[tuple[str, Path, int | None]]) -> str:
        return "\n".join(str(path) for _, path, _ in records)

    selected = [str(path) for path in (selected_loras or []) if str(path).strip()]
    if not 2 <= len(selected) <= 4:
        message = ui_text(language, "fusion_select_error")
        yield message, ""
        return

    weights = [float(value or 0.0) for value in (weight_1, weight_2, weight_3, weight_4)][: len(selected)]
    if any(weight < 0 for weight in weights) or sum(weights) <= 0:
        message = ui_text(language, "fusion_weight_error")
        yield message, ""
        return
    weight_sum = sum(weights)
    normalized_weights = [weight / weight_sum for weight in weights]
    output_dim_choices = normalize_fusion_output_dims(fusion_output_dims, language)
    if not output_dim_choices:
        message = ui_text(language, "fusion_dim_error")
        yield message, ""
        return

    paths = [Path(path) for path in selected]
    weights_text = ", ".join(f"{path.name}: {weight:.2f}" for path, weight in zip(paths, normalized_weights))
    source_text = ", ".join(path.name for path in paths)
    log_lines.append(ui_text(language, "fusion_progress_start"))
    log_lines.append(ui_text(language, "fusion_progress_selected").format(sources=source_text))
    log_lines.append(f"{ui_text(language, 'weights')}: {weights_text}")
    yield log_text(), ""

    try:
        from safetensors.torch import load_file, save_file
    except Exception as exc:
        message = f"{ui_text(language, 'fusion_failed')}\n{exc}"
        log_lines.append(message)
        yield log_text(), ""
        return

    missing = [str(path) for path in paths if not path.exists()]
    if missing:
        message = ui_text(language, "fusion_missing") + "\n" + "\n".join(missing)
        log_lines.append(message)
        yield log_text(), ""
        return

    try:
        log_lines.append(ui_text(language, "fusion_progress_loading"))
        yield log_text(), ""
        loaded = [load_file(str(path), device="cpu") for path in paths]
        log_lines.append(ui_text(language, "fusion_progress_loaded").format(count=len(loaded)))
        yield log_text(), ""

        output_name = safe_output_name(fusion_output_name or "fusion_lora")
        output_dir = settings.normalize_user_path(lora_output_dir, settings.get_lora_output_dir())
        output_dir.mkdir(parents=True, exist_ok=True)

        log_lines.append(ui_text(language, "fusion_progress_checking"))
        yield log_text(), ""
        metadata_by_path = [read_lora_metadata(path) for path in paths]
        anima_flags = [lora_tensors_look_anima(tensors, metadata) for tensors, metadata in zip(loaded, metadata_by_path)]
        relaxed_note = ""
        output_records: list[tuple[str, Path, int | None]] = []
        if any(anima_flags):
            if not all(anima_flags):
                message = ui_text(language, "fusion_mixed_model_error")
                log_lines.append(message)
                yield log_text(), ""
                return
            input_ranks = [infer_lora_max_rank(tensors) for tensors in loaded]
            auto_rank = max(input_ranks)
            log_lines.append(ui_text(language, "fusion_progress_anima").format(rank=auto_rank))
            yield log_text(), ""

            output_count = len(output_dim_choices)
            planned_outputs: list[tuple[int | None, int, str, Path]] = []
            for output_index, output_dim in enumerate(output_dim_choices, start=1):
                output_rank = auto_rank if output_dim is None else int(output_dim)
                output_suffix = "" if output_dim is None else f"_dim{output_rank}"
                output_label = ui_text(language, "fusion_dim_auto") if output_dim is None else f"dim {output_rank}"
                output_path = output_dir / f"{output_name}{output_suffix}.safetensors"
                planned_outputs.append((output_dim, output_rank, output_label, output_path))
            existing_outputs = [path for _, _, _, path in planned_outputs if path.exists()]
            if existing_outputs:
                existing_text = "\n".join(str(path) for path in existing_outputs)
                popup_message = ui_text(language, "fusion_output_exists_popup")
                try:
                    gr.Warning(popup_message)
                except Exception:
                    pass
                log_lines.append(ui_text(language, "fusion_output_exists_error").format(path=existing_text))
                yield log_text(), existing_text
                return

            for output_index, (_output_dim, output_rank, output_label, output_path) in enumerate(planned_outputs, start=1):
                progress_label = f"{output_label} ({output_index}/{output_count})"
                for progress_message in fuse_anima_loras_relaxed_steps(
                    paths,
                    normalized_weights,
                    output_path,
                    output_rank,
                    language,
                    progress_label,
                    input_ranks,
                ):
                    log_lines.append(progress_message)
                    yield log_text(), output_paths_text(output_records)
                log_lines.append(ui_text(language, "fusion_progress_metadata").format(label=progress_label))
                yield log_text(), output_paths_text(output_records)
                write_lora_metadata(
                    output_path,
                    {
                        "copita_fusion_sources": json.dumps([path.name for path in paths], ensure_ascii=False),
                        "copita_fusion_weights": json.dumps(normalized_weights),
                        "copita_fusion_method": "anima_concat_resize_rank_compensated_average",
                        "copita_fusion_output_rank": str(output_rank),
                        "ss_base_model_version": "anima",
                        "ss_network_module": metadata_by_path[0].get("ss_network_module") or "networks.lora",
                    },
                )
                output_records.append((output_label, output_path, output_rank))
                log_lines.append(ui_text(language, "fusion_progress_saved").format(label=progress_label, path=output_path))
                yield log_text(), output_paths_text(output_records)
            if len(output_records) == 1:
                relaxed_note = "\n" + ui_text(language, "fusion_relaxed_anima").format(rank=output_records[0][2] or auto_rank)
            else:
                relaxed_note = "\n" + ui_text(language, "fusion_relaxed_anima_multi")
        else:
            log_lines.append(ui_text(language, "fusion_progress_strict"))
            yield log_text(), ""
            output_path = output_dir / f"{output_name}.safetensors"
            if output_path.exists():
                existing_text = str(output_path)
                popup_message = ui_text(language, "fusion_output_exists_popup")
                try:
                    gr.Warning(popup_message)
                except Exception:
                    pass
                log_lines.append(ui_text(language, "fusion_output_exists_error").format(path=existing_text))
                yield log_text(), existing_text
                return
            base_keys = set(loaded[0].keys())
            for tensors in loaded[1:]:
                if set(tensors.keys()) != base_keys:
                    message = ui_text(language, "fusion_structure_error")
                    log_lines.append(message)
                    yield log_text(), ""
                    return

            log_lines.append(ui_text(language, "fusion_progress_average"))
            yield log_text(), ""
            fused = {}
            for key in sorted(base_keys):
                base_tensor = loaded[0][key]
                total = base_tensor * normalized_weights[0]
                for tensors, weight in zip(loaded[1:], normalized_weights[1:]):
                    tensor = tensors[key]
                    if tuple(tensor.shape) != tuple(base_tensor.shape):
                        message = f"{ui_text(language, 'fusion_shape_error')}\n{key}"
                        log_lines.append(message)
                        yield log_text(), ""
                        return
                    total = total + tensor.to(dtype=base_tensor.dtype) * weight
                fused[key] = total.contiguous()

            metadata = metadata_by_path[0]
            metadata["copita_fusion_sources"] = json.dumps([path.name for path in paths], ensure_ascii=False)
            metadata["copita_fusion_weights"] = json.dumps(normalized_weights)
            metadata["copita_fusion_method"] = "strict_tensor_average"
            log_lines.append(ui_text(language, "fusion_progress_save"))
            yield log_text(), ""
            save_file(fused, str(output_path), metadata=metadata)
            output_records.append(("", output_path, None))
            log_lines.append(ui_text(language, "fusion_progress_saved").format(label=output_name, path=output_path))
            yield log_text(), output_paths_text(output_records)
    except Exception as exc:
        message = f"{ui_text(language, 'fusion_failed')}\n{exc}"
        log_lines.append(message)
        yield log_text(), output_paths_text(output_records) if "output_records" in locals() else ""
        return

    if len(output_records) == 1:
        outputs_text = str(output_records[0][1])
    else:
        outputs_text = "\n" + "\n".join(f"- {label}: {path}" for label, path, _ in output_records)
    message = (
        f"{ui_text(language, 'fusion_complete')}\n"
        f"{ui_text(language, 'saved_to')}: {outputs_text}\n"
        f"{ui_text(language, 'weights')}: {weights_text}"
        f"{relaxed_note}"
    )
    log_lines.append("")
    log_lines.append(message)
    yield log_text(), output_paths_text(output_records)


def pick_folder(current: str, title: str) -> str:
    current_text = str(current or "").strip()
    current_path = settings.normalize_user_path(current_text, settings.WORKSPACE_DIR)
    initial_dir = current_path if current_path.exists() else current_path.parent
    if not initial_dir.exists():
        initial_dir = settings.WORKSPACE_DIR
    try:
        import tkinter as tk
        from tkinter import filedialog

        root = tk.Tk()
        root.withdraw()
        root.attributes("-topmost", True)
        selected = filedialog.askdirectory(title=title, initialdir=str(initial_dir))
        root.destroy()
        return selected or current_text
    except Exception:
        return current_text


def pick_file(current: str, title: str) -> str:
    current_text = str(current or "").strip()
    current_path = settings.normalize_user_path(current_text, settings.WORKSPACE_DIR)
    initial_dir = current_path.parent if current_path.suffix else current_path
    if not initial_dir.exists():
        initial_dir = settings.get_models_root_dir()
    if not initial_dir.exists():
        initial_dir = settings.WORKSPACE_DIR
    try:
        import tkinter as tk
        from tkinter import filedialog

        root = tk.Tk()
        root.withdraw()
        root.attributes("-topmost", True)
        selected = filedialog.askopenfilename(
            title=title,
            initialdir=str(initial_dir),
            filetypes=[
                ("Model files", "*.safetensors *.ckpt *.pth *.pt *.bin"),
                ("All files", "*.*"),
            ],
        )
        root.destroy()
        return selected or current_text
    except Exception:
        return current_text


def pick_anima_model_file(current: str, language: str = "JP"):
    selected = pick_file(current, ui_text(language, "pick_anima_model_title"))
    if selected:
        settings.save_user_settings({"anima_dit_path": selected})
        return gr.update(value=selected), f"{ui_text(language, 'picked_anima_model')}\n{selected}"
    return gr.update(), ""


def pick_qwen3_file(current: str, language: str = "JP"):
    selected = pick_file(current, ui_text(language, "pick_qwen3_title"))
    if selected:
        settings.save_user_settings({"anima_qwen3_path": selected})
        return gr.update(value=selected), f"{ui_text(language, 'picked_qwen3')}\n{selected}"
    return gr.update(), ""


def pick_vae_file(current: str, language: str = "JP"):
    selected = pick_file(current, ui_text(language, "pick_vae_title"))
    if selected:
        settings.save_user_settings({"anima_vae_path": selected})
        return gr.update(value=selected), f"{ui_text(language, 'picked_vae')}\n{selected}"
    return gr.update(), ""


def pick_models_root_folder(models_root: str, language: str = "JP"):
    selected = pick_folder(models_root, ui_text(language, "pick_models_title"))
    return save_models_root(selected, language)


def save_output_dir(lora_output_dir: str, language: str = "JP"):
    if not str(lora_output_dir or "").strip():
        return (
            "",
            path_display_html("", ui_text(language, "output_placeholder")),
            path_display_html("", ui_text(language, "fusion_lora_folder_placeholder")),
            ui_text(language, "output_required"),
        )
    path = settings.normalize_user_path(lora_output_dir, settings.OUTPUT_DIR)
    try:
        path.mkdir(parents=True, exist_ok=True)
        settings.save_user_settings({"lora_output_dir": str(path)})
        if language == "EN":
            return (
                str(path),
                path_display_html(str(path), ui_text(language, "output_placeholder")),
                path_display_html(str(path), ui_text(language, "fusion_lora_folder_placeholder")),
                f"LoRA output folder saved.\n{path}",
            )
        return (
            str(path),
            path_display_html(str(path), ui_text(language, "output_placeholder")),
            path_display_html(str(path), ui_text(language, "fusion_lora_folder_placeholder")),
            f"LoRA保存先を記憶しました。\n{path}",
        )
    except Exception as exc:
        if language == "EN":
            return (
                str(path),
                path_display_html(str(path), ui_text(language, "output_placeholder")),
                path_display_html(str(path), ui_text(language, "fusion_lora_folder_placeholder")),
                f"Could not create or save the output folder.\n{path}\n\n{exc}",
            )
        return (
            str(path),
            path_display_html(str(path), ui_text(language, "output_placeholder")),
            path_display_html(str(path), ui_text(language, "fusion_lora_folder_placeholder")),
            f"保存先を作成または保存できませんでした。\n{path}\n\n{exc}",
        )


def pick_output_folder(lora_output_dir: str, language: str = "JP"):
    selected = pick_folder(lora_output_dir, ui_text(language, "pick_output_title"))
    return save_output_dir(selected, language)


def pick_fusion_lora_folder(lora_output_dir: str, filter_text: str = "", selected_loras=None, language: str = "JP"):
    selected = pick_folder(lora_output_dir, ui_text(language, "pick_output_title"))
    saved_dir, output_display, fusion_display, save_message = save_output_dir(selected, language)
    choices, lora_update, list_message, observed_filter, applied_filter = refresh_fusion_loras(
        saved_dir,
        filter_text,
        selected_loras,
        language,
    )
    return (
        saved_dir,
        output_display,
        fusion_display,
        choices,
        lora_update,
        "\n\n".join(part for part in (save_message, list_message) if part),
        observed_filter,
        applied_filter,
    )


def upload_path(value) -> str:
    if not value:
        return ""
    if isinstance(value, (list, tuple)):
        return upload_path(value[0]) if value else ""
    if isinstance(value, (str, Path)):
        return str(value)
    if isinstance(value, dict):
        for key in ("path", "name", "orig_name"):
            if value.get(key):
                return str(value[key])
    for key in ("path", "name"):
        candidate = getattr(value, key, None)
        if candidate:
            return str(candidate)
    return str(value)


def preview_html(file_value, empty_text: str, language: str | None = "JP") -> str:
    path_text = upload_path(file_value)
    if not path_text:
        if not empty_text:
            return ""
        escaped = html_utils.escape(empty_text)
        return f'<div class="preview-box preview-empty-box"><div class="preview-empty">{escaped}</div></div>'

    path = Path(path_text)
    try:
        with Image.open(path) as image:
            original_size = image.size
            preview = image.convert("RGB")
            preview.thumbnail((900, 900), Image.Resampling.LANCZOS)
            buffer = io.BytesIO()
            preview.save(buffer, format="JPEG", quality=90, optimize=True)
        encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
        name = html_utils.escape(path.name)
        meta = f"{name} / {original_size[0]} x {original_size[1]}"
        return (
            '<div class="preview-box preview-loaded">'
            f'<img alt="{name}" src="data:image/jpeg;base64,{encoded}">'
            "</div>"
            f'<div class="preview-meta">{html_utils.escape(meta)}</div>'
        )
    except Exception as exc:
        message = html_utils.escape(f"{ui_text(language, 'image_display_error')}: {exc}")
        return f'<div class="preview-box preview-empty-box"><div class="preview-empty">{message}</div></div>'


def preview_style_image(file_value, language: str | None = "JP"):
    return preview_html(file_value, ui_text(language, "style_preview_empty"), language)


def preview_comparison_image(file_value, language: str | None = "JP"):
    return preview_html(file_value, ui_text(language, "comparison_preview_empty"), language)


def receive_style_image(file_value):
    path = upload_path(file_value)
    return path, preview_style_image(path)


def receive_comparison_image(file_value):
    path = upload_path(file_value)
    return path, preview_comparison_image(path)


def store_style_upload(file_value):
    return upload_path(file_value)


def receive_style_upload(file_value, comparison_state, reference_style, language="JP"):
    style_path = upload_path(file_value)
    comparison_path = upload_path(comparison_state)
    return (style_path, *flow_updates(style_path, comparison_path, reference_style, language))


def store_comparison_upload(file_value):
    return upload_path(file_value)


def refresh_flow_from_state(style_state, comparison_state, reference_style, language="JP"):
    return flow_updates(style_state, comparison_state, reference_style, language)


def receive_comparison_upload(file_value, style_state, reference_style, language="JP"):
    comparison_path = upload_path(file_value)
    style_path = upload_path(style_state)
    return (comparison_path, *flow_updates(style_path, comparison_path, reference_style, language))


def clear_style_upload(reference_style, language="JP"):
    return ("", "", gr.update(value=None), gr.update(value=None), *flow_updates("", "", reference_style, language))


def clear_comparison_upload(style_image, reference_style, language="JP"):
    return ("", gr.update(value=None), *flow_updates(style_image, "", reference_style, language))


def train_button_waiting(language: str | None = "JP"):
    return gr.update(
        value=ui_text(language, "train_wait"),
        visible=True,
        interactive=False,
        variant="secondary",
    )


def train_button_ready(language: str | None = "JP"):
    return gr.update(
        value=ui_text(language, "train_start"),
        visible=True,
        interactive=True,
        variant="primary",
    )


def train_button_from_captions(target_caption: str, comparison_caption: str, language: str | None = "JP"):
    if (target_caption or "").strip() and (comparison_caption or "").strip():
        return train_button_ready(language)
    return train_button_waiting(language)


def tag_button_waiting(language: str | None = "JP") -> gr.update:
    return gr.update(
        value=ui_text(language, "tag_wait"),
        visible=True,
        interactive=False,
        variant="secondary",
    )


def tag_button_ready(language: str | None = "JP") -> gr.update:
    return gr.update(
        value=ui_text(language, "tag_ready"),
        visible=True,
        interactive=True,
        variant="primary",
    )


def detailed_tag_button_waiting(language: str | None = "JP") -> gr.update:
    return gr.update(
        value=ui_text(language, "tag_ready_detailed"),
        visible=True,
        interactive=False,
        variant="secondary",
    )


def detailed_tag_button_ready(language: str | None = "JP") -> gr.update:
    return gr.update(
        value=ui_text(language, "tag_ready_detailed"),
        visible=True,
        interactive=True,
        variant="secondary",
    )


def tag_button_pair_updates(ready: bool, language: str | None = "JP") -> tuple:
    if ready:
        return tag_button_ready(language), detailed_tag_button_ready(language)
    return tag_button_waiting(language), detailed_tag_button_waiting(language)


def flow_updates(style_image, comparison_image, reference_style, language: str | None = "JP"):
    style_path = upload_path(style_image)
    comparison_path = upload_path(comparison_image)

    if style_path:
        style_message = ui_text(language, "style_loaded")
    else:
        style_message = ui_text(language, "style_wait")

    if not style_path:
        return (
            gr.update(value="", visible=False),
            gr.update(visible=False),
            gr.update(value="", visible=False),
            gr.update(visible=bool(comparison_path)),
            style_message,
            ui_text(language, "comparison_intro"),
            gr.update(value="", visible=True),
            gr.update(visible=False),
            "",
            "",
            "",
            *tag_button_pair_updates(False, language),
            gr.update(value="", visible=False),
            gr.update(value="", visible=False),
            gr.update(value="", visible=False),
            train_button_waiting(language),
        )

    if comparison_path:
        comparison_message = ui_text(language, "comparison_loaded")
        return (
            gr.update(value="", visible=False),
            gr.update(visible=True),
            gr.update(value="", visible=False),
            gr.update(visible=True),
            style_message,
            ui_text(language, "comparison_intro_ready"),
            gr.update(value=comparison_message, visible=True),
            gr.update(visible=False),
            "",
            "",
            "",
            *tag_button_pair_updates(True, language),
            gr.update(value="", visible=True),
            gr.update(value="", visible=True),
            gr.update(value="", visible=False),
            train_button_waiting(language),
        )

    normal_prompt, special_prompt, concept_prompt = build_gpt_prompt_choices(style_path, language)

    return (
        gr.update(value="", visible=False),
        gr.update(visible=True),
        gr.update(value="", visible=False),
        gr.update(visible=False),
        style_message,
        ui_text(language, "comparison_intro_ready"),
        gr.update(value="", visible=True),
        gr.update(visible=True),
        normal_prompt,
        special_prompt,
        concept_prompt,
        *tag_button_pair_updates(False, language),
        gr.update(value="", visible=False),
        gr.update(value="", visible=False),
        gr.update(value="", visible=False),
        train_button_waiting(language),
    )


def update_reference_course(style_image, comparison_image, reference_style, language: str | None = "JP"):
    style_path = upload_path(style_image)
    comparison_path = upload_path(comparison_image)
    if not style_path or comparison_path:
        return (
            gr.update(visible=False),
            "",
            "",
            "",
        )

    normal_prompt, special_prompt, concept_prompt = build_gpt_prompt_choices(style_path, language)
    return (
        gr.update(visible=True),
        normal_prompt,
        special_prompt,
        concept_prompt,
    )


def tagging_started(language: str | None = "JP", detailed: bool = False):
    active_label = "tagging_detailed" if detailed else "tagging"
    return (
        gr.update(
            value=ui_text(language, "tagging_report"),
            visible=True,
        ),
        gr.update(
            value=ui_text(language, active_label) if not detailed else ui_text(language, "tag_ready"),
            interactive=False,
            variant="secondary",
        ),
        gr.update(
            value=ui_text(language, active_label) if detailed else ui_text(language, "tag_ready_detailed"),
            interactive=False,
            variant="secondary",
        ),
    )


def make_captions_for_pair(style_image, comparison_image, reference_style, language: str | None = "JP", detailed: bool = False):
    style_image = upload_path(style_image)
    comparison_image = upload_path(comparison_image)
    if not style_image or not comparison_image:
        return (
            gr.update(value="", visible=False),
            gr.update(value="", visible=False),
            gr.update(value=ui_text(language, "tag_wait"), visible=True),
            train_button_waiting(language),
            *tag_button_pair_updates(False, language),
        )

    target_caption, _, report, _ = auto_caption_pair(style_image, comparison_image, detailed=detailed)
    shared_caption = format_caption_for_display(target_caption)
    return (
        gr.update(value=shared_caption, visible=True),
        gr.update(value=shared_caption, visible=True),
        gr.update(value=ui_text(language, "tag_done"), visible=True),
        train_button_ready(language),
        *tag_button_pair_updates(True, language),
    )


def make_captions_for_pair_from_ui(style_state, comparison_state, reference_style, language: str | None = "JP"):
    style_image = upload_path(style_state)
    comparison_image = upload_path(comparison_state)
    return make_captions_for_pair(style_image, comparison_image, reference_style, language)


def tagging_started_standard(language: str | None = "JP"):
    return tagging_started(language, detailed=False)


def tagging_started_detailed(language: str | None = "JP"):
    return tagging_started(language, detailed=True)


def make_detailed_captions_for_pair_from_ui(style_state, comparison_state, reference_style, language: str | None = "JP"):
    style_image = upload_path(style_state)
    comparison_image = upload_path(comparison_state)
    return make_captions_for_pair(style_image, comparison_image, reference_style, language, detailed=True)


def save_sdxl_path(base_model: str, language: str | None = "JP"):
    settings.save_user_settings({"sdxl_base_model_path": base_model or ""})
    return ui_text(language, "sdxl_saved")


def save_anima_paths(anima_model: str, anima_qwen3: str, anima_vae: str, language: str | None = "JP"):
    settings.save_user_settings(
        {
            "anima_dit_path": anima_model or "",
            "anima_qwen3_path": anima_qwen3 or "",
            "anima_vae_path": anima_vae or "",
            "anima_llm_adapter_path": "",
        }
    )
    return ui_text(language, "anima_saved")


def make_prompt_bundle(style_image, reference_style, language: str | None = "JP"):
    style_image = upload_path(style_image)
    if not style_image:
        return "", "", build_pair_prompt_local(reference_style, language=language), "", "", ui_text(language, "style_wait")
    return build_reference_prompt_bundle_local(style_image, reference_style, language)


def make_captions(style_image, comparison_image, reference_style, language: str | None = "JP"):
    style_image = upload_path(style_image)
    comparison_image = upload_path(comparison_image)
    if not style_image:
        return "", "", build_pair_prompt_local(reference_style, language=language), "", "", ui_text(language, "style_wait")

    target_caption, comparison_caption, report, pair_prompt = auto_caption_pair(style_image, comparison_image)
    target_caption = format_caption_for_display(target_caption)
    if comparison_image:
        comparison_caption = format_caption_for_display(comparison_caption or target_caption)
    _, _, instruction, danbooru_prompt, negative_prompt, prompt_report = build_reference_prompt_bundle_local(
        style_image,
        reference_style,
        language,
    )
    return target_caption, comparison_caption, instruction or pair_prompt, danbooru_prompt, negative_prompt, "\n".join([report, "", prompt_report])


def training_preset_values(training_type: str, speed_preset: str):
    model_type = "Anima" if training_type == "Anima" else "SDXL"
    preset_name = backend_speed_name(speed_preset)
    if preset_name not in settings.SPEED_PRESET_LABELS:
        preset_name = settings.DEFAULT_SPEED_PRESET
    presets = settings.SPEED_PRESETS.get(model_type, {})
    defaults = presets.get(preset_name)
    if defaults is None:
        defaults = settings.DEFAULT_ANIMA_TRAIN_SETTINGS if model_type == "Anima" else settings.DEFAULT_TRAIN_SETTINGS
    return (
        defaults["max_train_steps"],
        defaults["learning_rate"],
        defaults["train_batch_size"],
        defaults["network_dim"],
        defaults["network_alpha"],
        defaults["merge_strength"],
    )


def switch_training_type(
    training_type: str,
    speed_preset: str,
    language: str | None = "JP",
    target_caption: str | None = None,
    comparison_caption: str | None = None,
):
    model_type = "Anima" if training_type == "Anima" else "SDXL"
    speed_value = frontend_speed_name(speed_preset, language, model_type)
    preset_values = training_preset_values(model_type, speed_preset)
    return (
        model_type,
        gr.update(visible=model_type == "SDXL"),
        gr.update(visible=model_type == "Anima"),
        f"### {speed_heading_text(language, model_type)}",
        gr.update(
            choices=localized_speed_choices(language, model_type),
            value=speed_value,
        ),
        *preset_values,
        preset_description_update(speed_preset, language, model_type),
        gr.update(value=color_focus_for_speed_preset(speed_preset)),
        gr.update(value=low_vram_for_speed_preset(speed_preset)),
        gr.update(value=gradient_checkpointing_for_speed_preset(speed_preset)),
        gr.update(value=format_caption_for_display(target_caption)),
        gr.update(value=format_caption_for_display(comparison_caption)),
    )


def apply_speed_preset(training_type: str, speed_preset: str, language: str | None = "JP"):
    return (
        *training_preset_values(training_type, speed_preset),
        preset_description_update(speed_preset, language, training_type),
        gr.update(value=color_focus_for_speed_preset(speed_preset)),
        gr.update(value=low_vram_for_speed_preset(speed_preset)),
        gr.update(value=gradient_checkpointing_for_speed_preset(speed_preset)),
    )


def switch_workspace_mode(mode_name: str | None, language: str | None = "JP"):
    return (
        gr.update(visible=True),
        gr.update(visible=True),
        gr.update(visible=True),
    )


def sync_training_settings_visibility_after_mode_switch(
    selected_training_type: str | None,
    speed_preset: str | None,
    language: str | None = "JP",
):
    model_type = "Anima" if selected_training_type == "Anima" else "SDXL"
    speed_value = frontend_speed_name(speed_preset, language, model_type)
    return (
        model_type,
        gr.update(visible=model_type == "SDXL"),
        gr.update(visible=model_type == "Anima"),
        f"### {speed_heading_text(language, model_type)}",
        gr.update(
            choices=localized_speed_choices(language, model_type),
            value=speed_value,
        ),
        preset_description_update(speed_value, language, model_type),
    )


def sync_training_flow_after_mode_switch(
    mode_name: str | None,
    style_state,
    comparison_state,
    reference_style,
    target_caption: str | None,
    comparison_caption: str | None,
    language: str | None = "JP",
):
    if backend_mode_name(mode_name) != MODE_LABELS_JP[0]:
        return tuple(gr.update() for _ in range(11))

    style_path = upload_path(style_state)
    comparison_path = upload_path(comparison_state)
    target_caption_display = format_caption_for_display(target_caption)
    comparison_caption_display = format_caption_for_display(comparison_caption)
    captions_visible = bool(target_caption_display.strip() or comparison_caption_display.strip() or (style_path and comparison_path))
    train_update = train_button_from_captions(target_caption_display, comparison_caption_display, language)

    if not style_path:
        return (
            ui_text(language, "comparison_intro"),
            gr.update(value="", visible=True),
            gr.update(visible=False),
            gr.update(value=""),
            gr.update(value=""),
            gr.update(value=""),
            *tag_button_pair_updates(False, language),
            gr.update(value=target_caption_display, visible=bool(target_caption_display.strip())),
            gr.update(value=comparison_caption_display, visible=bool(comparison_caption_display.strip())),
            train_update,
        )

    if comparison_path:
        return (
            ui_text(language, "comparison_intro_ready"),
            gr.update(value=ui_text(language, "comparison_loaded"), visible=True),
            gr.update(visible=False),
            gr.update(value=""),
            gr.update(value=""),
            gr.update(value=""),
            *tag_button_pair_updates(True, language),
            gr.update(value=target_caption_display, visible=captions_visible),
            gr.update(value=comparison_caption_display, visible=captions_visible),
            train_update,
        )

    normal_prompt, special_prompt, concept_prompt = build_gpt_prompt_choices(style_path, language)
    return (
        ui_text(language, "comparison_intro_ready"),
        gr.update(value="", visible=True),
        gr.update(visible=True),
        gr.update(value=normal_prompt),
        gr.update(value=special_prompt),
        gr.update(value=concept_prompt),
        *tag_button_pair_updates(False, language),
        gr.update(value=target_caption_display, visible=bool(target_caption_display.strip())),
        gr.update(value=comparison_caption_display, visible=bool(comparison_caption_display.strip())),
        train_update,
    )


def switch_ui_language(
    language,
    mode_name,
    style_state,
    comparison_state,
    reference_style,
    target_caption,
    comparison_caption,
    training_type,
    speed_preset,
    is_training,
    lora_output_dir_value,
    selected_fusion_loras,
    fusion_weight_value_1,
    fusion_weight_value_2,
    fusion_weight_value_3,
    fusion_weight_value_4,
    history_page,
):
    language = "EN" if language == "EN" else "JP"
    style_path = upload_path(style_state)
    comparison_path = upload_path(comparison_state)
    reference_backend = backend_style_name(reference_style)
    reference_value = frontend_style_name(reference_backend, language)
    mode_backend = backend_mode_name(mode_name)
    mode_value = frontend_mode_name(mode_backend, language)
    target_caption_display = format_caption_for_display(target_caption)
    comparison_caption_display = format_caption_for_display(comparison_caption)
    lora_dir_text = str(lora_output_dir_value or "")

    if style_path:
        style_message = ui_text(language, "style_loaded")
    else:
        style_message = ui_text(language, "style_wait")

    if not style_path:
        comparison_intro_text = ui_text(language, "comparison_intro")
        comparison_message = ""
        guide_visible = False
        normal_prompt = ""
        special_prompt = ""
        concept_prompt = ""
        comparison_preview_update = gr.update(value="", visible=False)
        comparison_preview_wrap_update = gr.update(visible=False)
        comparison_clear_update = gr.update(visible=False)
    elif comparison_path:
        comparison_intro_text = ui_text(language, "comparison_intro_ready")
        comparison_message = ui_text(language, "comparison_loaded")
        guide_visible = False
        normal_prompt = ""
        special_prompt = ""
        concept_prompt = ""
        comparison_preview_update = gr.update(value="", visible=False)
        comparison_preview_wrap_update = gr.update(visible=False)
        comparison_clear_update = gr.update(visible=False)
    else:
        comparison_intro_text = ui_text(language, "comparison_intro_ready")
        comparison_message = ""
        guide_visible = True
        normal_prompt, special_prompt, concept_prompt = build_gpt_prompt_choices(style_path, language)
        comparison_preview_update = gr.update(value="", visible=False)
        comparison_preview_wrap_update = gr.update(visible=False)
        comparison_clear_update = gr.update(visible=False)

    tag_updates = tag_button_pair_updates(bool(style_path and comparison_path), language)
    train_update = train_button_from_captions(target_caption_display, comparison_caption_display, language)
    model_type = "Anima" if training_type == "Anima" else "SDXL"
    speed_value = frontend_speed_name(speed_preset, language, model_type)
    progress_update = gr.update()
    if not is_training:
        progress_update = gr.update(value=progress_html(LiveLogBuffer(language), running=False, language=language))
    history_page_payload = history_page_outputs(history_page, language)
    fusion_weight_outputs = list(
        fusion_weight_display_outputs(
            selected_fusion_loras,
            language,
            fusion_weight_value_1,
            fusion_weight_value_2,
            fusion_weight_value_3,
            fusion_weight_value_4,
        )
    )
    fusion_weight_outputs[0] = gr.update(
        label=ui_text(language, "fusion_loras"),
        value=normalized_selected_lora_values(selected_fusion_loras, FUSION_LORA_MAX_SELECTION)[0],
    )

    return (
        subtitle_with_links(language),
        gr.update(choices=localized_mode_choices(language), value=mode_value),
        gr.update(visible=True),
        gr.update(visible=True),
        gr.update(visible=True),
        ui_text(language, "style_heading"),
        ui_text(language, "style_intro"),
        gr.update(label=ui_text(language, "style_upload")),
        gr.update(value="", visible=False),
        gr.update(visible=False),
        style_message,
        gr.update(
            label=ui_text(language, "style_caption_label"),
            placeholder=ui_text(language, "style_caption_placeholder"),
            value=target_caption_display,
            visible=bool(target_caption_display.strip() or (style_path and comparison_path)),
        ),
        ui_text(language, "comparison_heading"),
        comparison_intro_text,
        gr.update(label=ui_text(language, "comparison_upload"), interactive=True),
        comparison_preview_update,
        comparison_preview_wrap_update,
        comparison_clear_update,
        gr.update(value=comparison_message, visible=True),
        gr.update(
            label=ui_text(language, "comparison_caption_label"),
            placeholder=ui_text(language, "comparison_caption_placeholder"),
            value=comparison_caption_display,
            visible=bool(comparison_caption_display.strip() or (style_path and comparison_path)),
        ),
        gr.update(visible=guide_visible),
        ui_text(language, "guide_heading"),
        ui_text(language, "guide_note"),
        gr.update(
            label=ui_text(language, "direction"),
            choices=localized_style_choices(language),
            value=reference_value,
        ),
        ui_text(language, "normal_gpt_prompt"),
        gr.update(value=normal_prompt),
        gr.update(value=ui_text(language, "copy_prompt")),
        ui_text(language, "special_gpt_prompt"),
        gr.update(value=special_prompt),
        gr.update(value=ui_text(language, "copy_prompt")),
        ui_text(language, "concept_slider_gpt_prompt"),
        gr.update(value=concept_prompt),
        gr.update(value=ui_text(language, "copy_prompt")),
        *tag_updates,
        gr.update(),
        ui_text(language, "settings_heading"),
        ui_text(language, "settings_note"),
        ui_text(language, "models_heading"),
        ui_text(language, "models_note"),
        gr.update(placeholder=ui_text(language, "models_placeholder")),
        gr.update(value=ui_text(language, "load_models")),
        gr.update(value=ui_text(language, "save_models")),
        ui_text(language, "base_heading"),
        ui_text(language, "base_note"),
        gr.update(label=ui_text(language, "sdxl_model")),
        gr.update(value="", visible=False),
        gr.update(value=ui_text(language, "save_sdxl")),
        gr.update(label=ui_text(language, "anima_model")),
        gr.update(label=ui_text(language, "qwen3")),
        gr.update(label=ui_text(language, "vae")),
        gr.update(value=ui_text(language, "save_anima")),
        gr.update(value="", visible=False),
        ui_text(language, "save_heading"),
        gr.update(label=ui_text(language, "output_name")),
        ui_text(language, "save_folder"),
        gr.update(show_label=False),
        gr.update(value=ui_text(language, "save_output")),
        f"### {speed_heading_text(language, model_type)}",
        ui_text(language, "speed_note"),
        gr.update(
            choices=localized_speed_choices(language, model_type),
            value=speed_value,
        ),
        preset_description_update(speed_value, language, model_type),
        gr.update(label=ui_text(language, "advanced")),
        gr.update(label=ui_text(language, "save_step_variants")),
        gr.update(label=ui_text(language, "color_focus_grayscale_reference")),
        ui_text(language, "steps_label"),
        ui_text(language, "lr_label"),
        ui_text(language, "batch_label"),
        ui_text(language, "dim_label"),
        ui_text(language, "alpha_label"),
        ui_text(language, "merge_strength"),
        gr.update(),
        ui_text(language, "vram_saving"),
        gr.update(label=ui_text(language, "low_vram_label")),
        gr.update(label=ui_text(language, "gradient_checkpointing_label")),
        ui_text(language, "start_heading"),
        ui_text(language, "start_note"),
        train_update,
        ui_text(language, "log_heading"),
        progress_update,
        gr.update(label=ui_text(language, "log_label")),
        gr.update(label=ui_text(language, "output_file")),
        ui_text(language, "fusion_heading"),
        ui_text(language, "fusion_note"),
        ui_text(language, "fusion_lora_folder_heading"),
        ui_text(language, "fusion_lora_folder_note"),
        path_display_html(lora_dir_text, ui_text(language, "fusion_lora_folder_placeholder")),
        gr.update(value=ui_text(language, "folder_open")),
        gr.update(value=ui_text(language, "fusion_save_folder")),
        fusion_weight_outputs[0],
        f"### {ui_text(language, 'fusion_selected_heading')}",
        *fusion_weight_outputs[1:6],
        gr.update(label=ui_text(language, "fusion_lora_filter")),
        gr.update(value=ui_text(language, "refresh_loras")),
        *fusion_weight_outputs[6:],
        gr.update(label=ui_text(language, "fusion_output_name")),
        gr.update(
            label=ui_text(language, "fusion_output_dims"),
            choices=localized_fusion_dim_choices(language),
            value=["8"],
        ),
        gr.update(value=ui_text(language, "fusion_start")),
        gr.update(label=ui_text(language, "fusion_result")),
        gr.update(label=ui_text(language, "output_file")),
        ui_text(language, "history_heading"),
        ui_text(language, "history_note"),
        *history_page_payload,
        gr.update(visible=model_type == "SDXL"),
        gr.update(visible=model_type == "Anima"),
    )


class TrainingJob:
    def __init__(self, job_id: str, process: subprocess.Popen, result_path: Path, log_path: Path):
        self.job_id = job_id
        self.process = process
        self.result_path = result_path
        self.log_path = log_path
        self.stop_requested = False


PHASE_LINE_RE = re.compile(r"工程\s+(\d+)\s*/\s*(\d+):\s*(.+)")
STEP_PROGRESS_RE = re.compile(r"(?<![\d.])(\d+)\s*/\s*(\d+)(?![\d.])")
TQDM_STEP_RE = re.compile(
    r"steps:\s*\d+%\|.*?\|\s*(\d+)\s*/\s*(\d+)\s*\[([^<\]]+)<([^,\]]+)"
)
STEP_LOSS_RE = re.compile(r"avr_loss=([^\],\s]+)")
CHECKPOINT_RE = re.compile(r"saving checkpoint:\s*(.+?)(?:\s+Failed to get git commit hash|$)")
SETUP_DETAIL_PREFIXES = (
    "Using DreamBooth method.",
    "prepare images.",
    "get image size from name of cache files",
    "set image size from cache files",
    "found directory ",
    "read caption:",
    "no regularization images",
    "0 reg images",
    "[Dataset ",
    "[Subset ",
    "[Prepare dataset ",
    "loading image sizes.",
    "make buckets",
    "min_bucket_reso and max_bucket_reso are ignored",
    "number of images (including repeats)",
    "train images with repeats.",
    "bucket ",
    "mean ar error",
    "batch_size:",
    "resolution:",
    "skip_image_resolution:",
    "resize_interpolation:",
    "enable_bucket:",
    "min_bucket_reso:",
    "max_bucket_reso:",
    "bucket_reso_steps:",
    "bucket_no_upscale:",
    "image_dir:",
    "image_count:",
    "num_repeats:",
    "shuffle_caption:",
    "keep_tokens:",
    "caption_dropout_rate:",
    "caption_dropout_every_n_epochs:",
    "caption_tag_dropout_rate:",
    "caption_prefix:",
    "caption_suffix:",
    "color_aug:",
    "flip_aug:",
    "face_crop_aug_range:",
    "random_crop:",
    "token_warmup_min:",
    "token_warmup_step:",
    "alpha_mask:",
    "custom_attributes:",
    "is_reg:",
    "class_tokens:",
    "caption_extension:",
    "loading model for process",
    "building U-Net",
    "loading U-Net from checkpoint",
    "U-Net: <",
    "building text encoders",
    "loading text encoders from checkpoint",
    "text encoder ",
    "building VAE",
    "loading VAE from checkpoint",
    "VAE: <",
    "caching latents",
    "checking cache validity",
    "caching Text Encoder outputs",
    "move vae",
    "move text encoder",
    "import network module:",
    "create LoRA for Text Encoder",
    "create LoRA for U-Net",
    "create LoRA for Text Encoder:",
    "enable LoRA for U-Net",
    "prepare optimizer, data loader etc.",
    "use 8-bit AdamW optimizer",
    "use AdamW optimizer",
    "enable fp8 training",
    "set U-Net weight dtype",
    "unet dtype:",
    "text_encoder ",
    "num train images",
    "num validation images",
    "num reg images",
    "num batches per epoch",
    "num epochs",
    "batch size per device",
    "gradient accumulation steps",
    "total optimization steps",
    "epoch is incremented.",
)
SETUP_DETAIL_EXACT_LINES = {
    "preparing accelerator",
    "running training / 学習開始",
    "epoch 1/1",
    "fatal: not a git repository (or any of the parent directories): .git",
}
IMPORTANT_LOG_MARKERS = (
    "エラー",
    "Traceback",
    "Exception",
    "Error",
    "ImportError",
    "RuntimeError",
    "saving checkpoint",
    "model saved.",
    "saved",
    "保存",
)


class LiveLogBuffer:
    def __init__(self, language: str | None = "JP"):
        self.language = language
        self.lines: list[str] = []
        self.partial = ""
        self.live_line = ""
        self.suppressed_module_lines = 0
        self.suppressed_resize_lines = 0
        self.suppressed_setup_lines = 0
        self.suppressed_step_lines = 0
        self.phase_index = 0
        self.phase_count = 0
        self.phase_label = ""
        self.step_current = 0
        self.step_total = 0
        self.step_elapsed = ""
        self.step_eta = ""
        self.step_loss = ""
        self.started_at = time.perf_counter()
        self.last_checkpoint = ""
        self.last_logged_progress_step = -1

    def _suppress_kind(self, text: str) -> str:
        line = text.strip()
        if not line:
            return ""
        if any(marker in line for marker in IMPORTANT_LOG_MARKERS):
            return ""
        if line.startswith("steps:") and "|" in line and "[" in line:
            return "step"
        if "Failed to get git commit hash, using fallback" in line:
            return "setup"
        if line in SETUP_DETAIL_EXACT_LINES:
            return "setup"
        if line.startswith(SETUP_DETAIL_PREFIXES):
            return "setup"
        if line.startswith("Module ") and " matched with regex " in line:
            return "module"
        return ""

    def _observe_progress(self, text: str) -> None:
        line = text.strip()
        if not line:
            return

        phase_match = PHASE_LINE_RE.search(line)
        if phase_match:
            self.phase_index = max(1, int(phase_match.group(1)))
            self.phase_count = max(1, int(phase_match.group(2)))
            self.phase_label = phase_match.group(3).strip()
            self.step_current = 0
            self.step_total = 0
            return

        step_matches = STEP_PROGRESS_RE.findall(line)
        valid_steps: list[tuple[int, int]] = []
        for current_text, total_text in step_matches:
            current_int = int(current_text)
            total_int = int(total_text)
            if total_int >= 10 and current_int <= total_int:
                valid_steps.append((current_int, total_int))
        if valid_steps and self.phase_index != 3:
            self.step_current, self.step_total = valid_steps[-1]
            step_match = TQDM_STEP_RE.search(line)
            if step_match:
                self.step_elapsed = step_match.group(3).strip()
                self.step_eta = step_match.group(4).strip()
            loss_match = STEP_LOSS_RE.search(line)
            if loss_match:
                self.step_loss = loss_match.group(1).strip()

        checkpoint_match = CHECKPOINT_RE.search(line)
        if checkpoint_match:
            self.last_checkpoint = Path(checkpoint_match.group(1).strip()).name

    def _step_progress_text(self) -> str:
        if self.step_total <= 0:
            return ""
        pct = int(self.step_current * 100 / max(1, self.step_total))
        if self.language == "EN":
            text = f"Progress: {self.step_current}/{self.step_total} ({pct}%)"
            if self.step_loss:
                text += f" / loss {self.step_loss}"
            if self.step_eta:
                text += f" / ETA {self.step_eta}"
            return text

        text = f"進捗: {self.step_current}/{self.step_total} ({pct}%)"
        if self.step_loss:
            text += f" / loss {self.step_loss}"
        if self.step_eta:
            text += f" / 残り約 {self.step_eta}"
        return text

    def _progress_log_interval(self) -> int:
        if self.step_total <= 0:
            return 25
        return max(10, self.step_total // 20)

    def _suppressed_notice(self, kind: str) -> str:
        if kind == "resize":
            return ui_text(self.language, "resize_log_omitted")
        if kind == "setup":
            return ui_text(self.language, "training_setup_log_omitted")
        if kind == "step":
            return ui_text(self.language, "training_step_log_omitted")
        return ui_text(self.language, "module_log_omitted")

    def _suppressed_live_text(self, kind: str, count: int) -> str:
        if kind == "resize":
            return f"{ui_text(self.language, 'resize_log_live')} {count}"
        return f"{ui_text(self.language, 'module_log_live')} {count}"

    def _record_suppressed(self, kind: str) -> None:
        if kind == "step":
            self.suppressed_step_lines += 1
            progress_line = self._step_progress_text()
            if progress_line:
                self.live_line = progress_line
                interval = self._progress_log_interval()
                should_log = (
                    self.step_current > 0
                    and (
                        self.step_current in {1, self.step_total}
                        or self.step_current % interval == 0
                    )
                )
                if should_log and self.step_current != self.last_logged_progress_step:
                    self.lines.append(progress_line)
                    self.last_logged_progress_step = self.step_current
            elif self.suppressed_step_lines == 1:
                self.lines.append(self._suppressed_notice(kind))
            return

        if kind == "setup":
            attr = f"suppressed_{kind}_lines"
            count = getattr(self, attr) + 1
            setattr(self, attr, count)
            if count == 1:
                self.lines.append(self._suppressed_notice(kind))
            return

        if kind == "resize":
            self.suppressed_resize_lines += 1
            count = self.suppressed_resize_lines
        else:
            self.suppressed_module_lines += 1
            count = self.suppressed_module_lines

        if count == 1:
            self.lines.append(self._suppressed_notice(kind))
        elif count % 100 == 0:
            self.live_line = self._suppressed_live_text(kind, count)

    def add_text(self, text: str) -> None:
        for char in text:
            if char == "\r":
                self._update_live_line()
                self.partial = ""
            elif char == "\n":
                if self.partial.strip():
                    self.add_line(self.partial.rstrip())
                self.partial = ""
                self.live_line = ""
            else:
                self.partial += char
                if "%" in self.partial or "|" in self.partial:
                    self._update_live_line()

    def add_line(self, text: str) -> None:
        if not text.strip():
            return
        self._observe_progress(text)
        suppress_kind = self._suppress_kind(text)
        if suppress_kind:
            self._record_suppressed(suppress_kind)
            return
        self.lines.append(text.rstrip())

    def _update_live_line(self) -> None:
        if self.partial.strip():
            self._observe_progress(self.partial)
            suppress_kind = self._suppress_kind(self.partial)
            if suppress_kind:
                self._record_suppressed(suppress_kind)
                return
            self.live_line = self.partial.strip()

    def full_text(self) -> str:
        lines = list(self.lines)
        current = self.live_line or self.partial.strip()
        if current and self._suppress_kind(current):
            current = ""
        if current:
            lines.append(current)
        return "\n".join(lines) or ui_text(self.language, "log_idle")

    def text(self, max_lines: int = 5000, max_chars: int = 500000) -> str:
        lines = self.lines[-max_lines:]
        current = self.live_line or self.partial.strip()
        if current and self._suppress_kind(current):
            current = ""
        if current:
            lines = [*lines, current]
        text = "\n".join(lines)
        if len(text) > max_chars:
            text = f"{ui_text(self.language, 'log_omitted')}\n" + text[-max_chars:]
        return text or ui_text(self.language, "log_idle")

    def latest(self) -> str:
        if self.live_line:
            if self._suppress_kind(self.live_line):
                return ""
            return self.live_line
        if self.partial.strip():
            if self._suppress_kind(self.partial):
                return ""
            return self.partial.strip()
        for line in reversed(self.lines):
            if line.strip():
                return line.strip()
        return ""


ACTIVE_JOB_LOCK = threading.Lock()
ACTIVE_JOB: TrainingJob | None = None


def drain_log_queue(log_queue: queue.Queue[str]) -> str:
    chunks: list[str] = []
    while True:
        try:
            chunks.append(log_queue.get_nowait())
        except queue.Empty:
            break
    return "".join(chunks)


def display_phase_text(phase_index: int, fallback: str, language: str | None = "JP") -> str:
    phase_map = {
        1: ui_text(language, "phase_1"),
        2: ui_text(language, "phase_2"),
        3: ui_text(language, "phase_3"),
    }
    return phase_map.get(phase_index) or fallback


def display_phase_metric_text(phase_index: int, fallback: str, language: str | None = "JP") -> str:
    phase_map = {
        1: ui_text(language, "progress_phase_1_short"),
        2: ui_text(language, "progress_phase_2_short"),
        3: ui_text(language, "progress_phase_3_short"),
    }
    return phase_map.get(phase_index) or fallback


def progress_html(
    log_buffer: LiveLogBuffer,
    running: bool,
    stopped: bool = False,
    completed: bool = False,
    language: str | None = "JP",
) -> str:
    latest = log_buffer.latest()
    pct: int | None = None
    step_text = ""
    phase_text = ""
    phase_metric_text = ""
    phase_index = log_buffer.phase_index
    phase_count = log_buffer.phase_count
    if phase_index:
        phase_text = display_phase_text(phase_index, log_buffer.phase_label, language)
        phase_metric_text = display_phase_metric_text(phase_index, log_buffer.phase_label, language)

    if log_buffer.step_total > 0:
        current = log_buffer.step_current
        total = log_buffer.step_total
        step_text = f"{current}/{total}"
        local_pct = current / max(1, total)
    else:
        local_pct = 0.0

    if phase_count == 3 and phase_index:
        if phase_index == 1:
            pct = int(local_pct * 48)
        elif phase_index == 2:
            pct = 48 + int(local_pct * 48)
        else:
            pct = 96
            step_text = ""
        pct = max(0, min(99, pct))
    elif log_buffer.step_total > 0:
        pct = int(local_pct * 100)

    if completed:
        pct = 100
        step_text = ""
        phase_metric_text = ui_text(language, "progress_phase_finished")
    elif pct is None:
        pct = 0
    if stopped:
        pct = min(pct, 99)

    title = ui_text(language, "progress_training") if running else ui_text(language, "progress_waiting")
    if stopped:
        title = ui_text(language, "progress_stopped")
    elif completed:
        title = ui_text(language, "progress_complete")
    elif not running and latest:
        title = ui_text(language, "progress_finished")
    right = f"{pct}%"
    if step_text:
        right += f" / {step_text}"
    blank = ui_text(language, "progress_blank")
    elapsed_text = blank
    if running or stopped or completed or log_buffer.phase_index:
        elapsed_text = format_elapsed(time.perf_counter() - log_buffer.started_at, language)
    metrics = [
        (ui_text(language, "progress_phase"), phase_metric_text or phase_text or blank, ""),
        (ui_text(language, "progress_step"), step_text or blank, ""),
        (ui_text(language, "progress_loss"), log_buffer.step_loss or blank, ""),
        (ui_text(language, "progress_elapsed"), elapsed_text, ""),
        (ui_text(language, "progress_eta"), log_buffer.step_eta or blank, ""),
        (ui_text(language, "progress_checkpoint"), log_buffer.last_checkpoint or blank, " progress-checkpoint"),
    ]
    metrics_html = "".join(
        (
            f'<div class="progress-metric{extra_class}">'
            f'<div class="progress-metric-label">{html_utils.escape(label)}</div>'
            f'<div class="progress-metric-value" title="{html_utils.escape(value)}">{html_utils.escape(value)}</div>'
            "</div>"
        )
        for label, value, extra_class in metrics
    )
    return (
        '<div class="progress-panel">'
        '<div class="progress-row">'
        f"<span>{html_utils.escape(title)}</span>"
        f"<span>{html_utils.escape(right)}</span>"
        "</div>"
        '<div class="progress-bar">'
        f'<div class="progress-fill" style="width: {pct}%"></div>'
        "</div>"
        f'<div class="progress-details">{metrics_html}</div>'
        "</div>"
    )


def write_training_job(
    target_image: str,
    pair_image: str,
    options: TrainOptions,
) -> tuple[Path, Path, Path]:
    job_dir = settings.RUNS_DIR / "ui_jobs"
    job_dir.mkdir(parents=True, exist_ok=True)
    job_id = time.strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:8]
    job_path = job_dir / f"{job_id}.json"
    result_path = job_dir / f"{job_id}.result.json"
    log_path = job_dir / f"{job_id}.log.txt"
    payload = {
        "target_image": target_image,
        "pair_image": pair_image,
        "result_path": str(result_path),
        "log_path": str(log_path),
        "options": options.__dict__,
    }
    job_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
    return job_path, result_path, log_path


def append_training_log_file(log_path: Path, text: str) -> None:
    if not text:
        return
    try:
        with log_path.open("a", encoding="utf-8", errors="replace") as handle:
            handle.write(text)
    except Exception:
        pass


def terminate_process_tree(process: subprocess.Popen) -> None:
    if process.poll() is not None:
        return
    try:
        if os.name == "nt":
            subprocess.run(
                ["taskkill", "/PID", str(process.pid), "/T", "/F"],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                check=False,
            )
        else:
            process.terminate()
            try:
                process.wait(timeout=5)
            except subprocess.TimeoutExpired:
                process.kill()
    except Exception:
        try:
            process.kill()
        except Exception:
            pass


def stop_active_training(language: str | None = "JP"):
    with ACTIVE_JOB_LOCK:
        job = ACTIVE_JOB
        if job is None or job.process.poll() is not None:
            empty_log = LiveLogBuffer(language)
            empty_log.add_line(ui_text(language, "no_training_to_stop"))
            return (
                empty_log.text(),
                progress_html(empty_log, running=False, stopped=True, language=language),
                "",
                gr.update(value=ui_text(language, "train_start"), variant="primary", interactive=True),
                False,
            )
        job.stop_requested = True
        terminate_process_tree(job.process)

    stop_log = LiveLogBuffer(language)
    stop_log.add_line(ui_text(language, "stop_requested"))
    return (
        stop_log.text(),
        progress_html(stop_log, running=False, stopped=True, language=language),
        "",
        gr.update(value=ui_text(language, "train_start"), variant="primary", interactive=True),
        False,
    )


def start_training(
    style_image,
    comparison_image,
    training_type,
    base_model,
    anima_model,
    anima_qwen3,
    anima_vae,
    anima_llm_adapter,
    output_name,
    target_caption,
    comparison_caption,
    lora_output_dir,
    max_train_steps,
    save_step_variants,
    grayscale_reference_for_color,
    learning_rate,
    train_batch_size,
    network_dim,
    network_alpha,
    merge_strength,
    low_vram,
    gradient_checkpointing,
    speed_preset,
    language,
    is_training,
):
    global ACTIVE_JOB

    if is_training:
        stop_status, stop_progress, stop_output, stop_button, stop_flag = stop_active_training(language)
        yield stop_status, stop_progress, stop_output, stop_button, stop_flag, ""
        return

    style_image = upload_path(style_image)
    comparison_image = upload_path(comparison_image)
    output_dir = settings.normalize_user_path(lora_output_dir, settings.get_lora_output_dir())
    safe_name = safe_output_name(output_name or "style_diff_lora")
    log_buffer = LiveLogBuffer(language)
    training_started_at = time.perf_counter()
    preview_options = TrainOptions(
        mode="external",
        model_type=training_type or "SDXL",
        base_model="",
        anima_qwen3="",
        anima_vae="",
        anima_llm_adapter="",
        output_name=safe_name,
        target_caption="",
        pair_caption="",
        lora_output_dir=str(output_dir),
        max_train_steps=int(max_train_steps),
        save_step_variants=bool(save_step_variants),
        grayscale_reference_for_color=bool(grayscale_reference_for_color),
        learning_rate=float(learning_rate),
        train_batch_size=int(train_batch_size),
        network_dim=int(network_dim),
        network_alpha=float(network_alpha),
        merge_strength=float(merge_strength),
        low_vram=bool(low_vram),
        gradient_checkpointing=True,
        speed_preset=backend_speed_name(speed_preset),
    )
    existing_outputs = [path for path in planned_output_paths(output_dir, safe_name, preview_options) if path.exists()]
    if existing_outputs:
        existing_text = "\n".join(str(path) for path in existing_outputs)
        popup_message = ui_text(language, "output_exists_popup")
        try:
            gr.Warning(popup_message)
        except Exception:
            pass
        log_buffer.add_line(ui_text(language, "output_exists_error").format(path=existing_text))
        yield (
            log_buffer.text(),
            progress_html(log_buffer, running=False, language=language),
            "",
            gr.update(value=ui_text(language, "train_start"), variant="primary", interactive=True),
            False,
            "",
        )
        return

    log_buffer.add_line(ui_text(language, "preparing"))
    yield (
        log_buffer.text(),
        progress_html(log_buffer, running=True, language=language),
        "",
        gr.update(value=ui_text(language, "starting"), variant="secondary", interactive=False),
        True,
        "",
    )

    if style_image and (not target_caption or not comparison_caption):
        log_buffer.add_line(ui_text(language, "auto_captioning"))
        yield (
            log_buffer.text(),
            progress_html(log_buffer, running=True, language=language),
            "",
            gr.update(value=ui_text(language, "starting"), variant="secondary", interactive=False),
            True,
            "",
        )
        generated_target, generated_comparison, _, _ = auto_caption_pair(style_image, comparison_image)
        target_caption = target_caption or generated_target
        comparison_caption = comparison_caption or generated_comparison or target_caption

    model_type = training_type or "SDXL"
    save_values = {"lora_output_dir": str(output_dir)}
    if model_type == "Anima":
        base_model = anima_model or ""
        save_values.update(
            {
                "anima_dit_path": anima_model or "",
                "anima_qwen3_path": anima_qwen3 or "",
                "anima_vae_path": anima_vae or "",
                "anima_llm_adapter_path": "",
            }
        )
    else:
        save_values["sdxl_base_model_path"] = base_model or ""
    settings.save_user_settings(save_values)

    options = TrainOptions(
        mode="external",
        model_type=model_type,
        base_model=base_model or "",
        anima_qwen3=anima_qwen3 or "",
        anima_vae=anima_vae or "",
        anima_llm_adapter=anima_llm_adapter or "",
        output_name=safe_name,
        target_caption=target_caption or "",
        pair_caption=comparison_caption or target_caption or "",
        lora_output_dir=str(output_dir),
        max_train_steps=int(max_train_steps),
        save_step_variants=bool(save_step_variants),
        grayscale_reference_for_color=bool(grayscale_reference_for_color),
        learning_rate=float(learning_rate),
        train_batch_size=int(train_batch_size),
        network_dim=int(network_dim),
        network_alpha=float(network_alpha),
        merge_strength=float(merge_strength),
        low_vram=bool(low_vram),
        gradient_checkpointing=True,
        speed_preset=backend_speed_name(speed_preset),
    )

    log_buffer.add_line(ui_text(language, "training_launch"))
    log_buffer.add_line(f"{ui_text(language, 'training_type')}: {model_type}")
    log_buffer.add_line(f"{ui_text(language, 'training_style_image')}: {style_image or '-'}")
    log_buffer.add_line(f"{ui_text(language, 'training_comparison_image')}: {comparison_image or '-'}")
    log_buffer.add_line(f"{ui_text(language, 'training_output')}: {options.lora_output_dir}")
    log_buffer.add_line(
        f"{ui_text(language, 'training_speed')}: {frontend_speed_name(options.speed_preset, language, model_type) or ui_text(language, 'training_standard')}"
    )
    log_buffer.add_line(
        f"steps: {options.max_train_steps} / batch: {options.train_batch_size} / dim: {options.network_dim} / alpha: {options.network_alpha} / LR: {options.learning_rate}"
    )
    log_buffer.add_line(
        f"{ui_text(language, 'training_vram_saving')}: lowram={options.low_vram} / gradient_checkpointing={options.gradient_checkpointing}"
    )
    if options.save_step_variants:
        log_buffer.add_line(ui_text(language, "save_step_variants"))
    if options.grayscale_reference_for_color:
        log_buffer.add_line(ui_text(language, "training_color_focus"))

    job_path, result_path, log_path = write_training_job(style_image, comparison_image, options)
    log_buffer.add_line(f"{ui_text(language, 'training_log_file')}: {log_path}")
    try:
        log_path.write_text(log_buffer.full_text() + "\n", encoding="utf-8", errors="replace")
    except Exception:
        pass

    child_env = os.environ.copy()
    child_env["PYTHONIOENCODING"] = "utf-8"
    child_env["PYTHONUTF8"] = "1"
    process = subprocess.Popen(
        [sys.executable, "-u", str(ROOT / "training_worker.py"), str(job_path)],
        cwd=str(ROOT),
        env=child_env,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        stdin=subprocess.DEVNULL,
        text=True,
        encoding="utf-8",
        errors="replace",
        bufsize=1,
    )
    job = TrainingJob(job_path.stem, process, result_path, log_path)
    with ACTIVE_JOB_LOCK:
        if ACTIVE_JOB is not None and ACTIVE_JOB.process.poll() is None:
            terminate_process_tree(process)
            log_buffer.add_line(ui_text(language, "already_running"))
            yield (
                log_buffer.text(),
                progress_html(log_buffer, running=False, language=language),
                "",
                gr.update(value=ui_text(language, "train_start"), variant="primary", interactive=True),
                False,
                "",
            )
            return
        ACTIVE_JOB = job

    log_queue: queue.Queue[str] = queue.Queue()

    def reader() -> None:
        if process.stdout is None:
            return
        while True:
            chunk = process.stdout.read(1)
            if not chunk:
                break
            log_queue.put(chunk)

    reader_thread = threading.Thread(target=reader, daemon=True)
    reader_thread.start()
    time.sleep(1.0)
    yield (
        log_buffer.text(),
        progress_html(log_buffer, running=True, language=language),
        "",
        gr.update(value=ui_text(language, "train_stop"), variant="stop", interactive=True),
        True,
        "",
    )

    try:
        last_output_time = time.time()
        while process.poll() is None:
            chunk = drain_log_queue(log_queue)
            if chunk:
                append_training_log_file(log_path, chunk)
                log_buffer.add_text(chunk)
                last_output_time = time.time()
            else:
                quiet_seconds = int(time.time() - last_output_time)
                if quiet_seconds >= 20:
                    log_buffer.live_line = f"{ui_text(language, 'training_heartbeat')} ({quiet_seconds}s)"
            yield (
                log_buffer.text(),
                progress_html(log_buffer, running=True, language=language),
                "",
                gr.update(value=ui_text(language, "train_stop"), variant="stop", interactive=True),
                True,
                "",
            )
            time.sleep(0.5)

        reader_thread.join(timeout=2)
        chunk = drain_log_queue(log_queue)
        if chunk:
            append_training_log_file(log_path, chunk)
            log_buffer.add_text(chunk)

        stopped = job.stop_requested
        message = ""
        output_path = None
        if result_path.exists():
            try:
                result = json.loads(result_path.read_text(encoding="utf-8"))
                message = result.get("message", "")
                output_path = result.get("output_file")
            except Exception:
                message = ui_text(language, "result_read_error")
        elif stopped:
            message = ui_text(language, "training_stopped")
        elif process.returncode:
            message = f"{ui_text(language, 'process_exit')}: {process.returncode}"
        else:
            message = ui_text(language, "process_no_result")

        if message:
            current_log = log_buffer.full_text()
            if message.strip() not in current_log:
                log_buffer.add_line("")
                log_buffer.add_line(message)
                append_training_log_file(log_path, "\n" + message + "\n")
        elapsed_line = f"{ui_text(language, 'training_elapsed')}: {format_elapsed(time.perf_counter() - training_started_at, language)}"
        log_buffer.add_line(elapsed_line)
        append_training_log_file(log_path, elapsed_line + "\n")
        if output_path and not stopped:
            settings.save_user_settings({"last_lora_output_name": safe_name})
        history_event = make_training_history_event(
            "stopped" if stopped else "completed",
            output_path if output_path and not stopped else "",
            output_dir,
            safe_name,
        ) if (stopped or output_path) else ""

        yield (
            log_buffer.text(),
            progress_html(
                log_buffer,
                running=False,
                stopped=stopped,
                completed=bool(output_path and not stopped),
                language=language,
            ),
            output_path or "",
            gr.update(value=ui_text(language, "train_start"), variant="primary", interactive=True),
            False,
            history_event,
        )
    finally:
        if process.poll() is None:
            terminate_process_tree(process)
        with ACTIVE_JOB_LOCK:
            if ACTIVE_JOB is job:
                ACTIVE_JOB = None


def build_ui() -> gr.Blocks:
    startup_log("画面を準備中...")
    user_settings = settings.load_user_settings()
    models_root_value = initial_models_root_text()
    models_root_scan_value = models_root_value or str(settings.get_models_root_dir())
    bundle = scan_models_bundle(models_root_scan_value, user_settings)
    output_dir_value = initial_output_dir_text(user_settings)
    output_name_value = safe_output_name(user_settings.get("last_lora_output_name", "mylora") or "mylora")
    lora_scan_started_at = time.perf_counter()
    startup_log(f"LoRA一覧を検索中: {output_dir_value}")
    lora_choices = scan_lora_files(output_dir_value)
    initial_fusion_lora_choices, _ = filtered_fusion_lora_choices(lora_choices, "", [])
    startup_log(f"LoRA一覧: {len(lora_choices)}件 ({time.perf_counter() - lora_scan_started_at:.1f}秒)")
    ensure_history_dirs()
    initial_history_records = load_history_records()
    initial_history_page = "1"
    initial_history_page_records = history_records_for_page(initial_history_records, initial_history_page)
    style_names = localized_style_choices("JP")
    default_reference_style = style_names[0]
    initial_train_defaults = settings.SPEED_PRESETS["SDXL"][settings.DEFAULT_SPEED_PRESET]

    startup_log("画面部品を組み立て中...")
    with gr.Blocks(title=APP_TITLE) as demo:
        with gr.Column(elem_classes=["title-stack"]):
            gr.Markdown(f"# {APP_TITLE}", elem_classes=["app-title"])
            subtitle_markdown = gr.Markdown(subtitle_with_links("JP"), elem_classes=["app-subtitle"])
        language_toggle = gr.Radio(
            choices=["JP", "EN"],
            value="JP",
            show_label=False,
            interactive=True,
            elem_classes=["language-toggle"],
        )

        mode_selector = gr.Radio(
            choices=localized_mode_choices("JP"),
            value=ui_text("JP", "mode_style"),
            show_label=False,
            interactive=True,
            elem_classes=["mode-tabs"],
        )

        with gr.Column(
            visible=True,
            elem_id="copita_training_workspace",
            elem_classes=["mode-workspace", "copita-workspace-visible"],
        ) as training_workspace:
            reference_style = gr.Radio(
                label=ui_text("JP", "direction"),
                choices=style_names,
                value=default_reference_style,
                show_label=False,
                interactive=False,
                visible=False,
                elem_classes=["course-tabs"],
            )

            with gr.Row(equal_height=True, elem_classes=["pair-grid", "flow-section"]):
                with gr.Column(scale=1, min_width=300, elem_classes=["pair-column"]):
                    with gr.Column(elem_classes=["pair-card"]):
                        style_heading_markdown = gr.Markdown(ui_text("JP", "style_heading"))
                        style_intro_markdown = gr.Markdown(ui_text("JP", "style_intro"), elem_classes=["mini-note", "pair-note"])
                        style_image = gr.Image(
                            label=ui_text("JP", "style_upload"),
                            type="filepath",
                            sources=["upload"],
                            interactive=True,
                            height=520,
                            buttons=[],
                            elem_id="style_image_input",
                            elem_classes=["image-input"],
                        )
                        with gr.Column(visible=False, elem_classes=["image-preview-wrap"]):
                            style_clear_button = gr.Button(
                                "×",
                                visible=False,
                                elem_classes=["preview-clear-button"],
                            )
                            style_preview = gr.HTML(preview_style_image(None, "JP"))
                        style_message = gr.Markdown(
                            ui_text("JP", "style_wait"),
                            elem_classes=["mini-note", "pair-status-message"],
                        )
                        target_caption = gr.Textbox(
                            label=ui_text("JP", "style_caption_label"),
                            lines=5,
                            value="",
                            placeholder=ui_text("JP", "style_caption_placeholder"),
                            interactive=True,
                            visible=False,
                            elem_classes=["tag-textbox"],
                        )

                with gr.Column(scale=1, min_width=300, elem_classes=["pair-column"]):
                    with gr.Column(elem_classes=["pair-card"]):
                        comparison_heading_markdown = gr.Markdown(ui_text("JP", "comparison_heading"))
                        comparison_intro_markdown = gr.Markdown(
                            ui_text("JP", "comparison_intro"),
                            elem_classes=["mini-note", "pair-note"],
                        )
                        comparison_image = gr.Image(
                            label=ui_text("JP", "comparison_upload"),
                            type="filepath",
                            sources=["upload"],
                            interactive=True,
                            height=520,
                            buttons=[],
                            elem_id="comparison_image_input",
                            elem_classes=["image-input"],
                        )
                        with gr.Column(visible=False, elem_classes=["image-preview-wrap", "comparison-preview"]) as comparison_preview_wrap:
                            comparison_clear_button = gr.Button(
                                "×",
                                visible=False,
                                elem_classes=["preview-clear-button"],
                            )
                            comparison_preview = gr.HTML(preview_comparison_image(None, "JP"))
                        comparison_message = gr.Markdown(
                            "",
                            visible=True,
                            elem_classes=["mini-note", "pair-status-message"],
                        )
                        with gr.Column(visible=False, elem_classes=["guide-panel", "prompt-choice-panel"]) as comparison_guide:
                            guide_heading_markdown = gr.Markdown(ui_text("JP", "guide_heading"))
                            guide_note_markdown = gr.Markdown(
                                ui_text("JP", "guide_note"),
                                elem_classes=["mini-note", "prompt-choice-note"],
                            )
                            with gr.Row(elem_classes=["prompt-header-row"]):
                                normal_prompt_label = gr.Markdown(
                                    ui_text("JP", "normal_gpt_prompt"),
                                    elem_classes=["prompt-inline-label"],
                                )
                                copy_normal_prompt_button = gr.Button(
                                    ui_text("JP", "copy_prompt"),
                                    variant="secondary",
                                    min_width=0,
                                    elem_classes=["prompt-copy-button"],
                                )
                            normal_gpt_prompt = gr.Textbox(
                                show_label=False,
                                lines=3,
                                value="",
                                interactive=False,
                            )
                            with gr.Row(elem_classes=["prompt-header-row"]):
                                special_prompt_label = gr.Markdown(
                                    ui_text("JP", "special_gpt_prompt"),
                                    elem_classes=["prompt-inline-label"],
                                )
                                copy_special_prompt_button = gr.Button(
                                    ui_text("JP", "copy_prompt"),
                                    variant="secondary",
                                    min_width=0,
                                    elem_classes=["prompt-copy-button"],
                                )
                            special_gpt_prompt = gr.Textbox(
                                show_label=False,
                                lines=3,
                                value="",
                                interactive=False,
                            )
                            with gr.Row(elem_classes=["prompt-header-row"]):
                                concept_prompt_label = gr.Markdown(
                                    ui_text("JP", "concept_slider_gpt_prompt"),
                                    elem_classes=["prompt-inline-label"],
                                )
                                copy_concept_prompt_button = gr.Button(
                                    ui_text("JP", "copy_prompt"),
                                    variant="secondary",
                                    min_width=0,
                                    elem_classes=["prompt-copy-button"],
                                )
                            concept_slider_gpt_prompt = gr.Textbox(
                                show_label=False,
                                lines=3,
                                value="",
                                interactive=False,
                            )
                        comparison_caption = gr.Textbox(
                            label=ui_text("JP", "comparison_caption_label"),
                            lines=5,
                            value="",
                            placeholder=ui_text("JP", "comparison_caption_placeholder"),
                            interactive=True,
                            visible=False,
                            elem_classes=["tag-textbox"],
                        )

            with gr.Row(elem_classes=["tag-action-row"]):
                tag_button = gr.Button(
                    ui_text("JP", "tag_wait"),
                    variant="secondary",
                    visible=True,
                    interactive=False,
                )
                detailed_tag_button = gr.Button(
                    ui_text("JP", "tag_ready_detailed"),
                    variant="secondary",
                    visible=True,
                    interactive=False,
                )
            report = gr.Markdown("", visible=False, elem_classes=["tag-report-panel"])

            with gr.Column(elem_classes=["settings-panel"]):
                settings_heading_markdown = gr.Markdown(ui_text("JP", "settings_heading"))
                settings_note_markdown = gr.Markdown(ui_text("JP", "settings_note"), elem_classes=["mini-note"])
                training_type = gr.State("SDXL")
                model_type_selector = gr.Radio(
                    choices=["SDXL", "Anima"],
                    value="SDXL",
                    label="モデル種別",
                    show_label=False,
                    interactive=True,
                    elem_classes=["model-type-tabs"],
                )

                with gr.Row(equal_height=True, elem_classes=["settings-grid"]):
                    with gr.Column(scale=1, min_width=280):
                        with gr.Column(elem_classes=["settings-card"]):
                            models_heading_markdown = gr.Markdown(ui_text("JP", "models_heading"))
                            models_note_markdown = gr.Markdown(ui_text("JP", "models_note"), elem_classes=["mini-note"])
                            with gr.Row(elem_classes=["path-row"]):
                                models_root_dir = gr.Textbox(
                                    value=models_root_value,
                                    show_label=False,
                                    visible=False,
                                )
                                models_root_display = gr.HTML(
                                    path_display_html(models_root_value, ui_text("JP", "models_placeholder")),
                                    elem_classes=["path-display-wrap"],
                                )
                                models_pick_button = gr.Button(ui_text("JP", "folder_open"), elem_classes=["folder-button"], min_width=48)
                            with gr.Row(elem_classes=["button-row"]):
                                refresh_models_button = gr.Button(ui_text("JP", "load_models"), variant="secondary")
                                save_models_button = gr.Button(ui_text("JP", "save_models"), variant="secondary")
                            model_status = gr.Markdown(
                                format_model_status(bundle, "JP"),
                                elem_classes=["status-note", "model-status-box"],
                                visible=False,
                            )

                    with gr.Column(scale=1, min_width=280):
                        with gr.Column(elem_classes=["settings-card"]):
                            base_heading_markdown = gr.Markdown(ui_text("JP", "base_heading"))
                            base_note_markdown = gr.Markdown(ui_text("JP", "base_note"), elem_classes=["mini-note"])
                            with gr.Group(visible=True) as sdxl_model_group:
                                base_model = gr.Dropdown(
                                    label=ui_text("JP", "sdxl_model"),
                                    choices=bundle["checkpoint_choices"],
                                    value=bundle["sdxl_model"],
                                    allow_custom_value=True,
                                )
                                sdxl_note_markdown = gr.Markdown("", visible=False, elem_classes=["mini-note"])
                                save_sdxl_paths_button = gr.Button(ui_text("JP", "save_sdxl"), variant="secondary")
                            with gr.Group(visible=False) as anima_model_group:
                                with gr.Row(elem_classes=["model-file-row"]):
                                    anima_model = gr.Dropdown(
                                        label=ui_text("JP", "anima_model"),
                                        choices=bundle["anima_choices"],
                                        value=bundle["anima_model"],
                                        allow_custom_value=True,
                                        elem_classes=["model-path-dropdown"],
                                        scale=12,
                                    )
                                    anima_model_pick_button = gr.Button(
                                        ui_text("JP", "folder_open"),
                                        elem_classes=["folder-button"],
                                        min_width=48,
                                        scale=1,
                                    )
                                with gr.Row(elem_classes=["model-file-row"]):
                                    anima_qwen3 = gr.Dropdown(
                                        label=ui_text("JP", "qwen3"),
                                        choices=bundle["qwen3_choices"],
                                        value=bundle["anima_qwen3"],
                                        allow_custom_value=True,
                                        elem_classes=["model-path-dropdown"],
                                        scale=12,
                                    )
                                    qwen3_pick_button = gr.Button(
                                        ui_text("JP", "folder_open"),
                                        elem_classes=["folder-button"],
                                        min_width=48,
                                        scale=1,
                                    )
                                with gr.Row(elem_classes=["model-file-row"]):
                                    anima_vae = gr.Dropdown(
                                        label=ui_text("JP", "vae"),
                                        choices=bundle["vae_choices"],
                                        value=bundle["anima_vae"],
                                        allow_custom_value=True,
                                        elem_classes=["model-path-dropdown"],
                                        scale=12,
                                    )
                                    vae_pick_button = gr.Button(
                                        ui_text("JP", "folder_open"),
                                        elem_classes=["folder-button"],
                                        min_width=48,
                                        scale=1,
                                    )
                                save_anima_paths_button = gr.Button(ui_text("JP", "save_anima"), variant="secondary")
                                anima_note_markdown = gr.Markdown("", visible=False, elem_classes=["mini-note"])

                    with gr.Column(scale=1, min_width=280):
                        with gr.Column(elem_classes=["save-card"]):
                            save_heading_markdown = gr.Markdown(ui_text("JP", "save_heading"))
                            output_name = gr.Textbox(
                                label=ui_text("JP", "output_name"),
                                value=output_name_value,
                                elem_classes=["compact-field"],
                            )
                            save_folder_label_markdown = gr.Markdown(ui_text("JP", "save_folder"), elem_classes=["path-label"])
                            with gr.Row(elem_classes=["path-row"]):
                                lora_output_dir = gr.Textbox(
                                    value=output_dir_value,
                                    show_label=False,
                                    visible=False,
                                )
                                lora_output_display = gr.HTML(
                                    path_display_html(output_dir_value, ui_text("JP", "output_placeholder")),
                                    elem_classes=["path-display-wrap"],
                                )
                                output_pick_button = gr.Button(ui_text("JP", "folder_open"), elem_classes=["folder-button"], min_width=48)
                            save_output_button = gr.Button(ui_text("JP", "save_output"), variant="secondary")

                settings_message = gr.Markdown("", elem_classes=["status-note"])
                anima_llm_adapter = gr.Textbox(value="", visible=False, show_label=False)

                with gr.Row(equal_height=True, elem_classes=["settings-grid", "training-action-grid"]):
                    with gr.Column(scale=1, min_width=360):
                        with gr.Column(elem_classes=["settings-card", "speed-card"]):
                            speed_heading_markdown = gr.Markdown(
                                f"### {speed_heading_text('JP', 'SDXL')}",
                                elem_classes=["preset-heading"],
                            )
                            speed_note_markdown = gr.Markdown(ui_text("JP", "speed_note"), elem_classes=["mini-note"])
                            speed_preset = gr.Radio(
                                choices=localized_speed_choices("JP", "SDXL"),
                                value=frontend_speed_name(settings.DEFAULT_SPEED_PRESET, "JP", "SDXL"),
                                show_label=False,
                                interactive=True,
                                elem_classes=["preset-tabs"],
                            )
                            preset_description_markdown = gr.Markdown(
                                preset_description(settings.DEFAULT_SPEED_PRESET, "JP", "SDXL"),
                                elem_classes=["preset-description"],
                            )

                            with gr.Accordion(ui_text("JP", "advanced"), open=False) as advanced_accordion:
                                with gr.Row(elem_classes=["field-help-row"]):
                                    steps_label_markdown = gr.Markdown(ui_text("JP", "steps_label"), elem_classes=["field-help-label"])
                                max_train_steps = gr.Slider(
                                    show_label=False,
                                    minimum=100,
                                    maximum=1600,
                                    step=100,
                                    value=initial_train_defaults["max_train_steps"],
                                )
                                save_step_variants = gr.Checkbox(
                                    label=ui_text("JP", "save_step_variants"),
                                    value=False,
                                )
                                grayscale_reference_for_color = gr.Checkbox(
                                    label=ui_text("JP", "color_focus_grayscale_reference"),
                                    value=False,
                                )
                                with gr.Row(elem_classes=["field-help-row"]):
                                    lr_label_markdown = gr.Markdown(ui_text("JP", "lr_label"), elem_classes=["field-help-label"])
                                learning_rate = gr.Number(
                                    show_label=False,
                                    value=initial_train_defaults["learning_rate"],
                                    precision=6,
                                )
                                with gr.Row(elem_classes=["field-help-row"]):
                                    batch_label_markdown = gr.Markdown(ui_text("JP", "batch_label"), elem_classes=["field-help-label"])
                                train_batch_size = gr.Slider(
                                    show_label=False,
                                    minimum=1,
                                    maximum=4,
                                    step=1,
                                    value=initial_train_defaults["train_batch_size"],
                                )
                                with gr.Row(elem_classes=["field-help-row"]):
                                    dim_label_markdown = gr.Markdown(ui_text("JP", "dim_label"), elem_classes=["field-help-label"])
                                network_dim = gr.Slider(
                                    show_label=False,
                                    minimum=4,
                                    maximum=64,
                                    step=4,
                                    value=initial_train_defaults["network_dim"],
                                )
                                with gr.Row(elem_classes=["field-help-row"]):
                                    alpha_label_markdown = gr.Markdown(ui_text("JP", "alpha_label"), elem_classes=["field-help-label"])
                                network_alpha = gr.Slider(
                                    show_label=False,
                                    minimum=1,
                                    maximum=64,
                                    step=1,
                                    value=initial_train_defaults["network_alpha"],
                                )
                                with gr.Row(elem_classes=["field-help-row"]):
                                    merge_label_markdown = gr.Markdown(ui_text("JP", "merge_strength"), elem_classes=["field-help-label"])
                                merge_strength = gr.Slider(
                                    show_label=False,
                                    minimum=0.6,
                                    maximum=1.8,
                                    step=0.05,
                                    value=initial_train_defaults["merge_strength"],
                                )
                                vram_saving_title_markdown = gr.Markdown(
                                    ui_text("JP", "vram_saving"),
                                    elem_classes=["vram-saving-title"],
                                )
                                low_vram = gr.Checkbox(
                                    label=ui_text("JP", "low_vram_label"),
                                    value=low_vram_for_speed_preset(settings.DEFAULT_SPEED_PRESET),
                                )
                                gradient_checkpointing = gr.Checkbox(
                                    label=ui_text("JP", "gradient_checkpointing_label"),
                                    value=gradient_checkpointing_for_speed_preset(settings.DEFAULT_SPEED_PRESET),
                                    visible=False,
                                )

                    with gr.Column(scale=1, min_width=360):
                        with gr.Column(elem_classes=["start-panel"]):
                            start_heading_markdown = gr.Markdown(ui_text("JP", "start_heading"))
                            start_note_markdown = gr.Markdown(ui_text("JP", "start_note"), elem_classes=["mini-note", "training-flow-note"])
                            train_button = gr.Button(
                                ui_text("JP", "train_wait"),
                                variant="secondary",
                                elem_classes=["main-action", "train-action"],
                                visible=True,
                                interactive=False,
                            )

            with gr.Column(elem_classes=["log-shell"]):
                with gr.Column(elem_classes=["wide-log-panel"]):
                    log_heading_markdown = gr.Markdown(ui_text("JP", "log_heading"))
                    progress_view = gr.HTML(progress_html(LiveLogBuffer("JP"), running=False, language="JP"))
                    status = gr.Textbox(
                        label=ui_text("JP", "log_label"),
                        lines=28,
                        max_lines=28,
                        value=ui_text("JP", "log_ready"),
                        autoscroll=True,
                        elem_id="training_log_text",
                    )
                    output_file = gr.Textbox(
                        label=ui_text("JP", "output_file"),
                        value="",
                        lines=1,
                        interactive=False,
                        elem_classes=["compact-field"],
                    )
                    training_history_event = gr.Textbox(value="", visible=False)

        with gr.Column(
            visible=True,
            elem_id="copita_fusion_workspace",
            elem_classes=["mode-workspace", "copita-workspace-hidden"],
        ) as fusion_workspace:
            with gr.Column(elem_classes=["fusion-panel"]):
                with gr.Row(elem_classes=["fusion-top-grid"]):
                    with gr.Column(scale=1, min_width=420, elem_classes=["fusion-top-card", "fusion-info-card"]):
                        fusion_heading_markdown = gr.Markdown(ui_text("JP", "fusion_heading"))
                        fusion_note_markdown = gr.Markdown(ui_text("JP", "fusion_note"))
                    with gr.Column(scale=1, min_width=420, elem_classes=["fusion-top-card", "fusion-folder-card"]):
                        fusion_lora_folder_heading_markdown = gr.Markdown(ui_text("JP", "fusion_lora_folder_heading"))
                        fusion_lora_folder_note_markdown = gr.Markdown(
                            ui_text("JP", "fusion_lora_folder_note"),
                            visible=False,
                            elem_classes=["mini-note"],
                        )
                        with gr.Row(elem_classes=["path-row"]):
                            fusion_lora_folder_display = gr.HTML(
                                path_display_html(output_dir_value, ui_text("JP", "fusion_lora_folder_placeholder")),
                                elem_classes=["path-display-wrap"],
                            )
                            fusion_output_pick_button = gr.Button(ui_text("JP", "folder_open"), elem_classes=["folder-button"], min_width=48)
                        with gr.Row(elem_classes=["button-row"]):
                            fusion_save_output_button = gr.Button(ui_text("JP", "fusion_save_folder"), variant="secondary")
                            refresh_fusion_button = gr.Button(ui_text("JP", "refresh_loras"), variant="secondary")
                fusion_lora_filter = gr.Textbox(
                    label=ui_text("JP", "fusion_lora_filter"),
                    value="",
                    elem_classes=["compact-field"],
                )
                fusion_loras = gr.CheckboxGroup(
                    label=ui_text("JP", "fusion_loras"),
                    choices=initial_fusion_lora_choices,
                    value=[],
                    interactive=True,
                    elem_classes=["lora-choice-list"],
                )
                with gr.Column(elem_classes=["fusion-weight-card"]):
                    fusion_weight_heading = gr.Markdown(
                        f"### {ui_text('JP', 'fusion_selected_heading')}",
                        elem_classes=["fusion-weight-heading"],
                    )
                    fusion_selection_notice = gr.Markdown(
                        ui_text("JP", "fusion_selected_empty"),
                        visible=True,
                        elem_classes=["fusion-selected-empty-note"],
                    )
                    with gr.Row(elem_classes=["fusion-weights"]):
                        with gr.Column(elem_classes=["fusion-weight-slot-wrap"]):
                            fusion_weight_slot_1 = gr.HTML(fusion_weight_slot_html([], 0, "JP"))
                            fusion_weight_1 = gr.Number(show_label=False, value=None, precision=3, interactive=False)
                        with gr.Column(elem_classes=["fusion-weight-slot-wrap"]):
                            fusion_weight_slot_2 = gr.HTML(fusion_weight_slot_html([], 1, "JP"))
                            fusion_weight_2 = gr.Number(show_label=False, value=None, precision=3, interactive=False)
                        with gr.Column(elem_classes=["fusion-weight-slot-wrap"]):
                            fusion_weight_slot_3 = gr.HTML(fusion_weight_slot_html([], 2, "JP"))
                            fusion_weight_3 = gr.Number(show_label=False, value=None, precision=3, interactive=False)
                        with gr.Column(elem_classes=["fusion-weight-slot-wrap"]):
                            fusion_weight_slot_4 = gr.HTML(fusion_weight_slot_html([], 3, "JP"))
                            fusion_weight_4 = gr.Number(show_label=False, value=None, precision=3, interactive=False)
                fusion_output_name = gr.Textbox(
                    label=ui_text("JP", "fusion_output_name"),
                    value="copita_fusion",
                    elem_classes=["compact-field"],
                )
                fusion_output_dims = gr.CheckboxGroup(
                    label=ui_text("JP", "fusion_output_dims"),
                    choices=localized_fusion_dim_choices("JP"),
                    value=["8"],
                    interactive=True,
                )
                fusion_button = gr.Button(ui_text("JP", "fusion_start"), variant="primary", elem_classes=["main-action"])
                fusion_status = gr.Textbox(label=ui_text("JP", "fusion_result"), lines=14, interactive=False)
                fusion_output_file = gr.Textbox(
                    label=ui_text("JP", "output_file"),
                    value="",
                    lines=6,
                    interactive=False,
                    elem_classes=["compact-field"],
                )

        with gr.Column(
            visible=True,
            elem_id="copita_history_workspace",
            elem_classes=["mode-workspace", "copita-workspace-hidden"],
        ) as history_workspace:
            with gr.Column(elem_classes=["history-panel"]):
                history_heading_markdown = gr.Markdown(ui_text("JP", "history_heading"))
                history_note_markdown = gr.Markdown(ui_text("JP", "history_note"), elem_classes=["mini-note"])
                history_status = gr.Markdown(
                    history_page_status_text(initial_history_records, initial_history_page, "JP"),
                    elem_classes=["status-note"],
                )
                history_slot_rows = []
                history_slot_ids = []
                history_slot_infos = []
                history_slot_style_images = []
                history_slot_comparison_images = []
                history_slot_result_images = []
                history_slot_delete_buttons = []
                for slot_index in range(HISTORY_PAGE_SIZE):
                    initial_record = initial_history_page_records[slot_index] if slot_index < len(initial_history_page_records) else None
                    slot_initial = history_initial_slot(initial_record, "JP")
                    with gr.Row(
                        visible=bool(slot_initial["visible"]),
                        equal_height=True,
                        elem_classes=["history-list-card"],
                    ) as history_slot_row:
                        history_slot_id = gr.State(slot_initial["id"])
                        with gr.Column(scale=3, min_width=280, elem_classes=["history-list-info"]):
                            history_slot_info = gr.HTML(slot_initial["info"])
                            history_slot_delete_button = gr.Button(
                                ui_text("JP", "history_delete"),
                                variant="secondary",
                                elem_classes=["history-delete-button"],
                            )
                        with gr.Column(scale=4, min_width=420):
                            with gr.Row(elem_classes=["history-list-images"]):
                                history_slot_style = gr.Image(
                                    label=ui_text("JP", "history_image_a"),
                                    value=slot_initial["style"],
                                    type="filepath",
                                    interactive=False,
                                    height=240,
                                )
                                history_slot_comparison = gr.Image(
                                    label=ui_text("JP", "history_image_b"),
                                    value=slot_initial["comparison"],
                                    type="filepath",
                                    interactive=False,
                                    height=240,
                                )
                                history_slot_result = gr.Image(
                                    label=ui_text("JP", "history_result_short"),
                                    value=slot_initial["result"],
                                    type="filepath",
                                    sources=["upload"],
                                    interactive=True,
                                    height=240,
                                    placeholder=ui_text("JP", "history_result_drop_short"),
                                    elem_classes=["history-result-drop"],
                                )
                    history_slot_rows.append(history_slot_row)
                    history_slot_ids.append(history_slot_id)
                    history_slot_infos.append(history_slot_info)
                    history_slot_style_images.append(history_slot_style)
                    history_slot_comparison_images.append(history_slot_comparison)
                    history_slot_result_images.append(history_slot_result)
                    history_slot_delete_buttons.append(history_slot_delete_button)
                history_page_selector = gr.Radio(
                    label=ui_text("JP", "history_page"),
                    choices=history_page_choices(initial_history_records),
                    value=initial_history_page,
                    interactive=True,
                    visible=history_page_count(initial_history_records) > 1,
                    elem_classes=["history-page-selector", "history-page-selector-bottom"],
                )
                history_delete_pending_id = gr.State("")
                with gr.Column(visible=False, elem_classes=["history-delete-overlay"]) as history_delete_overlay:
                    with gr.Column(elem_classes=["history-delete-dialog"]):
                        history_delete_message = gr.Markdown(ui_text("JP", "history_delete_confirm"))
                        with gr.Row(elem_classes=["history-delete-actions"]):
                            history_delete_yes_button = gr.Button(
                                ui_text("JP", "history_delete_yes"),
                                variant="primary",
                                elem_classes=["history-delete-confirm-button"],
                            )
                            history_delete_no_button = gr.Button(
                                ui_text("JP", "history_delete_no"),
                                variant="secondary",
                                elem_classes=["history-delete-cancel-button"],
                            )

        gr.Markdown(f"{APP_TITLE} {APP_VERSION}", elem_classes=["app-version-footer"])

        style_image_path = gr.State("")
        comparison_image_path = gr.State("")
        is_training = gr.State(False)
        fusion_all_loras_state = gr.State(lora_choices)
        fusion_filter_observed_state = gr.State("")
        fusion_filter_applied_state = gr.State("")
        fusion_filter_timer = gr.Timer(value=1.0, active=True)
        refresh_outputs = [
            models_root_dir,
            models_root_display,
            base_model,
            anima_model,
            anima_qwen3,
            anima_vae,
            lora_output_dir,
            lora_output_display,
            fusion_lora_folder_display,
            model_status,
            settings_message,
        ]
        history_page_output_components = [history_page_selector, history_status]
        for slot_index in range(HISTORY_PAGE_SIZE):
            history_page_output_components.extend(
                [
                    history_slot_rows[slot_index],
                    history_slot_ids[slot_index],
                    history_slot_infos[slot_index],
                    history_slot_style_images[slot_index],
                    history_slot_comparison_images[slot_index],
                    history_slot_result_images[slot_index],
                ]
            )
        mode_selector.change(
            fn=None,
            inputs=[mode_selector, language_toggle],
            outputs=None,
            js=MODE_SWITCH_JS,
            queue=False,
            show_progress="hidden",
        )
        mode_event = mode_selector.change(
            fn=switch_workspace_mode,
            inputs=[mode_selector, language_toggle],
            outputs=[training_workspace, fusion_workspace, history_workspace],
            queue=False,
            show_progress="hidden",
            trigger_mode="always_last",
        )
        mode_event.then(
            fn=None,
            inputs=[mode_selector, language_toggle],
            outputs=None,
            js=MODE_READY_JS,
            queue=False,
            show_progress="hidden",
        )
        mode_event.then(
            fn=sync_training_flow_after_mode_switch,
            inputs=[
                mode_selector,
                style_image_path,
                comparison_image_path,
                reference_style,
                target_caption,
                comparison_caption,
                language_toggle,
            ],
            outputs=[
                comparison_intro_markdown,
                comparison_message,
                comparison_guide,
                normal_gpt_prompt,
                special_gpt_prompt,
                concept_slider_gpt_prompt,
                tag_button,
                detailed_tag_button,
                target_caption,
                comparison_caption,
                train_button,
            ],
            queue=False,
            show_progress="hidden",
        )
        mode_event.then(
            fn=sync_training_settings_visibility_after_mode_switch,
            inputs=[model_type_selector, speed_preset, language_toggle],
            outputs=[
                training_type,
                sdxl_model_group,
                anima_model_group,
                speed_heading_markdown,
                speed_preset,
                preset_description_markdown,
            ],
            queue=False,
            show_progress="hidden",
        )
        mode_event.then(
            fn=refresh_history_after_mode_switch,
            inputs=[mode_selector, history_page_selector, language_toggle],
            outputs=history_page_output_components,
            queue=False,
            show_progress="hidden",
        )
        mode_event.then(
            fn=cancel_history_delete,
            inputs=[language_toggle],
            outputs=[history_delete_pending_id, history_delete_overlay, history_delete_message],
            queue=False,
            show_progress="hidden",
        )
        history_page_selector.change(
            fn=change_history_page,
            inputs=[history_page_selector, language_toggle],
            outputs=history_page_output_components,
        )
        for slot_index in range(HISTORY_PAGE_SIZE):
            history_slot_result_images[slot_index].upload(
                fn=attach_history_result_image_to_slot,
                inputs=[
                    history_slot_ids[slot_index],
                    history_slot_result_images[slot_index],
                    history_page_selector,
                    language_toggle,
                ],
                outputs=[
                    history_slot_result_images[slot_index],
                    history_slot_infos[slot_index],
                    history_status,
                ],
            )
            history_slot_result_images[slot_index].clear(
                fn=clear_history_result_image_from_slot,
                inputs=[
                    history_slot_ids[slot_index],
                    history_page_selector,
                    language_toggle,
                ],
                outputs=[
                    history_slot_result_images[slot_index],
                    history_slot_infos[slot_index],
                    history_status,
                ],
            )
            history_slot_delete_buttons[slot_index].click(
                fn=show_history_delete_confirm,
                inputs=[history_slot_ids[slot_index], language_toggle],
                outputs=[history_delete_pending_id, history_delete_overlay, history_delete_message],
            )
        history_delete_no_button.click(
            fn=cancel_history_delete,
            inputs=[language_toggle],
            outputs=[history_delete_pending_id, history_delete_overlay, history_delete_message],
        )
        history_delete_yes_button.click(
            fn=confirm_history_delete,
            inputs=[history_delete_pending_id, history_page_selector, language_toggle],
            outputs=[
                history_delete_pending_id,
                history_delete_overlay,
                history_delete_message,
                *history_page_output_components,
            ],
        )
        refresh_models_event = refresh_models_button.click(
            fn=save_models_root,
            inputs=[models_root_dir, language_toggle],
            outputs=refresh_outputs,
        )
        refresh_models_event.then(
            fn=refresh_fusion_loras,
            inputs=[lora_output_dir, fusion_lora_filter, fusion_loras, language_toggle],
            outputs=[
                fusion_all_loras_state,
                fusion_loras,
                fusion_status,
                fusion_filter_observed_state,
                fusion_filter_applied_state,
            ],
        )
        save_models_event = save_models_button.click(
            fn=save_models_root,
            inputs=[models_root_dir, language_toggle],
            outputs=refresh_outputs,
        )
        save_models_event.then(
            fn=refresh_fusion_loras,
            inputs=[lora_output_dir, fusion_lora_filter, fusion_loras, language_toggle],
            outputs=[
                fusion_all_loras_state,
                fusion_loras,
                fusion_status,
                fusion_filter_observed_state,
                fusion_filter_applied_state,
            ],
        )
        models_pick_event = models_pick_button.click(
            fn=pick_models_root_folder,
            inputs=[models_root_dir, language_toggle],
            outputs=refresh_outputs,
        )
        models_pick_event.then(
            fn=refresh_fusion_loras,
            inputs=[lora_output_dir, fusion_lora_filter, fusion_loras, language_toggle],
            outputs=[
                fusion_all_loras_state,
                fusion_loras,
                fusion_status,
                fusion_filter_observed_state,
                fusion_filter_applied_state,
            ],
        )
        save_output_event = save_output_button.click(
            fn=save_output_dir,
            inputs=[lora_output_dir, language_toggle],
            outputs=[lora_output_dir, lora_output_display, fusion_lora_folder_display, settings_message],
        )
        save_output_event.then(
            fn=refresh_fusion_loras,
            inputs=[lora_output_dir, fusion_lora_filter, fusion_loras, language_toggle],
            outputs=[
                fusion_all_loras_state,
                fusion_loras,
                fusion_status,
                fusion_filter_observed_state,
                fusion_filter_applied_state,
            ],
        )
        output_pick_event = output_pick_button.click(
            fn=pick_output_folder,
            inputs=[lora_output_dir, language_toggle],
            outputs=[lora_output_dir, lora_output_display, fusion_lora_folder_display, settings_message],
        )
        output_pick_event.then(
            fn=refresh_fusion_loras,
            inputs=[lora_output_dir, fusion_lora_filter, fusion_loras, language_toggle],
            outputs=[
                fusion_all_loras_state,
                fusion_loras,
                fusion_status,
                fusion_filter_observed_state,
                fusion_filter_applied_state,
            ],
        )
        anima_model_pick_button.click(
            fn=pick_anima_model_file,
            inputs=[anima_model, language_toggle],
            outputs=[anima_model, settings_message],
        )
        qwen3_pick_button.click(
            fn=pick_qwen3_file,
            inputs=[anima_qwen3, language_toggle],
            outputs=[anima_qwen3, settings_message],
        )
        vae_pick_button.click(
            fn=pick_vae_file,
            inputs=[anima_vae, language_toggle],
            outputs=[anima_vae, settings_message],
        )
        refresh_fusion_button.click(
            fn=refresh_fusion_loras,
            inputs=[lora_output_dir, fusion_lora_filter, fusion_loras, language_toggle],
            outputs=[
                fusion_all_loras_state,
                fusion_loras,
                fusion_status,
                fusion_filter_observed_state,
                fusion_filter_applied_state,
            ],
        )
        fusion_save_output_event = fusion_save_output_button.click(
            fn=save_output_dir,
            inputs=[lora_output_dir, language_toggle],
            outputs=[lora_output_dir, lora_output_display, fusion_lora_folder_display, fusion_status],
        )
        fusion_save_output_event.then(
            fn=refresh_fusion_loras,
            inputs=[lora_output_dir, fusion_lora_filter, fusion_loras, language_toggle],
            outputs=[
                fusion_all_loras_state,
                fusion_loras,
                fusion_status,
                fusion_filter_observed_state,
                fusion_filter_applied_state,
            ],
        )
        fusion_output_pick_button.click(
            fn=pick_fusion_lora_folder,
            inputs=[lora_output_dir, fusion_lora_filter, fusion_loras, language_toggle],
            outputs=[
                lora_output_dir,
                lora_output_display,
                fusion_lora_folder_display,
                fusion_all_loras_state,
                fusion_loras,
                fusion_status,
                fusion_filter_observed_state,
                fusion_filter_applied_state,
            ],
        )
        fusion_filter_timer.tick(
            fn=update_fusion_lora_filter_after_idle,
            inputs=[
                fusion_lora_filter,
                fusion_loras,
                fusion_all_loras_state,
                fusion_filter_observed_state,
                fusion_filter_applied_state,
            ],
            outputs=[fusion_loras, fusion_filter_observed_state, fusion_filter_applied_state],
            queue=False,
            show_progress="hidden",
        )
        fusion_loras.change(
            fn=fusion_weight_display_outputs,
            inputs=[
                fusion_loras,
                language_toggle,
                fusion_weight_1,
                fusion_weight_2,
                fusion_weight_3,
                fusion_weight_4,
            ],
            outputs=[
                fusion_loras,
                fusion_selection_notice,
                fusion_weight_slot_1,
                fusion_weight_slot_2,
                fusion_weight_slot_3,
                fusion_weight_slot_4,
                fusion_weight_1,
                fusion_weight_2,
                fusion_weight_3,
                fusion_weight_4,
            ],
            queue=False,
            show_progress="hidden",
        )
        fusion_button.click(
            fn=fuse_loras,
            inputs=[
                fusion_loras,
                fusion_weight_1,
                fusion_weight_2,
                fusion_weight_3,
                fusion_weight_4,
                fusion_output_name,
                fusion_output_dims,
                lora_output_dir,
                language_toggle,
            ],
            outputs=[fusion_status, fusion_output_file],
        )
        save_sdxl_paths_button.click(
            fn=save_sdxl_path,
            inputs=[base_model, language_toggle],
            outputs=[settings_message],
        )
        save_anima_paths_button.click(
            fn=save_anima_paths,
            inputs=[anima_model, anima_qwen3, anima_vae, language_toggle],
            outputs=[settings_message],
        )
        copy_normal_prompt_button.click(
            fn=None,
            inputs=[normal_gpt_prompt],
            outputs=None,
            js=COPY_PROMPT_JS,
            queue=False,
            show_progress="hidden",
        )
        copy_special_prompt_button.click(
            fn=None,
            inputs=[special_gpt_prompt],
            outputs=None,
            js=COPY_PROMPT_JS,
            queue=False,
            show_progress="hidden",
        )
        copy_concept_prompt_button.click(
            fn=None,
            inputs=[concept_slider_gpt_prompt],
            outputs=None,
            js=COPY_PROMPT_JS,
            queue=False,
            show_progress="hidden",
        )
        flow_outputs = [
            style_preview,
            style_clear_button,
            comparison_preview,
            comparison_clear_button,
            style_message,
            comparison_intro_markdown,
            comparison_message,
            comparison_guide,
            normal_gpt_prompt,
            special_gpt_prompt,
            concept_slider_gpt_prompt,
            tag_button,
            detailed_tag_button,
            target_caption,
            comparison_caption,
            report,
            train_button,
        ]
        language_toggle.change(
            fn=switch_ui_language,
            inputs=[
                language_toggle,
                mode_selector,
                style_image_path,
                comparison_image_path,
                reference_style,
                target_caption,
                comparison_caption,
                training_type,
                speed_preset,
                is_training,
                lora_output_dir,
                fusion_loras,
                fusion_weight_1,
                fusion_weight_2,
                fusion_weight_3,
                fusion_weight_4,
                history_page_selector,
            ],
            outputs=[
                subtitle_markdown,
                mode_selector,
                training_workspace,
                fusion_workspace,
                history_workspace,
                style_heading_markdown,
                style_intro_markdown,
                style_image,
                style_preview,
                style_clear_button,
                style_message,
                target_caption,
                comparison_heading_markdown,
                comparison_intro_markdown,
                comparison_image,
                comparison_preview,
                comparison_preview_wrap,
                comparison_clear_button,
                comparison_message,
                comparison_caption,
                comparison_guide,
                guide_heading_markdown,
                guide_note_markdown,
                reference_style,
                normal_prompt_label,
                normal_gpt_prompt,
                copy_normal_prompt_button,
                special_prompt_label,
                special_gpt_prompt,
                copy_special_prompt_button,
                concept_prompt_label,
                concept_slider_gpt_prompt,
                copy_concept_prompt_button,
                tag_button,
                detailed_tag_button,
                report,
                settings_heading_markdown,
                settings_note_markdown,
                models_heading_markdown,
                models_note_markdown,
                models_root_dir,
                refresh_models_button,
                save_models_button,
                base_heading_markdown,
                base_note_markdown,
                base_model,
                sdxl_note_markdown,
                save_sdxl_paths_button,
                anima_model,
                anima_qwen3,
                anima_vae,
                save_anima_paths_button,
                anima_note_markdown,
                save_heading_markdown,
                output_name,
                save_folder_label_markdown,
                lora_output_dir,
                save_output_button,
                speed_heading_markdown,
                speed_note_markdown,
                speed_preset,
                preset_description_markdown,
                advanced_accordion,
                save_step_variants,
                grayscale_reference_for_color,
                steps_label_markdown,
                lr_label_markdown,
                batch_label_markdown,
                dim_label_markdown,
                alpha_label_markdown,
                merge_label_markdown,
                merge_strength,
                vram_saving_title_markdown,
                low_vram,
                gradient_checkpointing,
                start_heading_markdown,
                start_note_markdown,
                train_button,
                log_heading_markdown,
                progress_view,
                status,
                output_file,
                fusion_heading_markdown,
                fusion_note_markdown,
                fusion_lora_folder_heading_markdown,
                fusion_lora_folder_note_markdown,
                fusion_lora_folder_display,
                fusion_output_pick_button,
                fusion_save_output_button,
                fusion_loras,
                fusion_weight_heading,
                fusion_selection_notice,
                fusion_weight_slot_1,
                fusion_weight_slot_2,
                fusion_weight_slot_3,
                fusion_weight_slot_4,
                fusion_lora_filter,
                refresh_fusion_button,
                fusion_weight_1,
                fusion_weight_2,
                fusion_weight_3,
                fusion_weight_4,
                fusion_output_name,
                fusion_output_dims,
                fusion_button,
                fusion_status,
                fusion_output_file,
                history_heading_markdown,
                history_note_markdown,
                *history_page_output_components,
                sdxl_model_group,
                anima_model_group,
            ],
        )
        style_image.change(
            fn=receive_style_upload,
            inputs=[style_image, comparison_image_path, reference_style, language_toggle],
            outputs=[style_image_path, *flow_outputs],
        )
        comparison_image.change(
            fn=receive_comparison_upload,
            inputs=[comparison_image, style_image_path, reference_style, language_toggle],
            outputs=[comparison_image_path, *flow_outputs],
        )
        style_clear_button.click(
            fn=clear_style_upload,
            inputs=[reference_style, language_toggle],
            outputs=[style_image_path, comparison_image_path, style_image, comparison_image, *flow_outputs],
        )
        comparison_clear_button.click(
            fn=clear_comparison_upload,
            inputs=[style_image_path, reference_style, language_toggle],
            outputs=[comparison_image_path, comparison_image, *flow_outputs],
        )
        tag_event = tag_button.click(
            fn=tagging_started_standard,
            inputs=[language_toggle],
            outputs=[report, tag_button, detailed_tag_button],
        )
        tag_event.then(
            fn=make_captions_for_pair_from_ui,
            inputs=[style_image_path, comparison_image_path, reference_style, language_toggle],
            outputs=[target_caption, comparison_caption, report, train_button, tag_button, detailed_tag_button],
        )
        detailed_tag_event = detailed_tag_button.click(
            fn=tagging_started_detailed,
            inputs=[language_toggle],
            outputs=[report, tag_button, detailed_tag_button],
        )
        detailed_tag_event.then(
            fn=make_detailed_captions_for_pair_from_ui,
            inputs=[style_image_path, comparison_image_path, reference_style, language_toggle],
            outputs=[target_caption, comparison_caption, report, train_button, tag_button, detailed_tag_button],
        )
        target_caption.input(
            fn=train_button_from_captions,
            inputs=[target_caption, comparison_caption, language_toggle],
            outputs=[train_button],
        )
        comparison_caption.input(
            fn=train_button_from_captions,
            inputs=[target_caption, comparison_caption, language_toggle],
            outputs=[train_button],
        )
        model_type_selector.input(
            fn=switch_training_type,
            inputs=[model_type_selector, speed_preset, language_toggle, target_caption, comparison_caption],
            outputs=[
                training_type,
                sdxl_model_group,
                anima_model_group,
                speed_heading_markdown,
                speed_preset,
                max_train_steps,
                learning_rate,
                train_batch_size,
                network_dim,
                network_alpha,
                merge_strength,
                preset_description_markdown,
                grayscale_reference_for_color,
                low_vram,
                gradient_checkpointing,
                target_caption,
                comparison_caption,
            ],
        )
        speed_preset.input(
            fn=apply_speed_preset,
            inputs=[training_type, speed_preset, language_toggle],
            outputs=[
                max_train_steps,
                learning_rate,
                train_batch_size,
                network_dim,
                network_alpha,
                merge_strength,
                preset_description_markdown,
                grayscale_reference_for_color,
                low_vram,
                gradient_checkpointing,
            ],
        )
        train_button.click(
            fn=start_training,
            inputs=[
                style_image_path,
                comparison_image_path,
                training_type,
                base_model,
                anima_model,
                anima_qwen3,
                anima_vae,
                anima_llm_adapter,
                output_name,
                target_caption,
                comparison_caption,
                lora_output_dir,
                max_train_steps,
                save_step_variants,
                grayscale_reference_for_color,
                learning_rate,
                train_batch_size,
                network_dim,
                network_alpha,
                merge_strength,
                low_vram,
                gradient_checkpointing,
                speed_preset,
                language_toggle,
                is_training,
            ],
            outputs=[status, progress_view, output_file, train_button, is_training, training_history_event],
            trigger_mode="multiple",
            concurrency_limit=None,
        )
        training_history_event.change(
            fn=record_training_event_to_history,
            inputs=[
                training_history_event,
                style_image_path,
                comparison_image_path,
                training_type,
                output_name,
                target_caption,
                comparison_caption,
                lora_output_dir,
                max_train_steps,
                save_step_variants,
                grayscale_reference_for_color,
                learning_rate,
                train_batch_size,
                network_dim,
                network_alpha,
                merge_strength,
                speed_preset,
                language_toggle,
            ],
            outputs=history_page_output_components,
        )

    startup_log("画面の準備が完了しました。")
    return demo


if __name__ == "__main__":
    startup_log("作業フォルダを確認中...")
    settings.RUNS_DIR.mkdir(parents=True, exist_ok=True)
    settings.OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    ensure_history_dirs()
    port = int(os.environ.get("STYLE_DIFF_PORT", "7865"))
    inbrowser = os.environ.get("STYLE_DIFF_INBROWSER", "1") != "0"
    startup_log(f"ローカルサーバーを起動します: http://127.0.0.1:{port}")
    demo = build_ui().queue()
    startup_log("ブラウザを開く準備ができました。")
    demo.launch(
        server_name="127.0.0.1",
        server_port=port,
        inbrowser=inbrowser,
        theme=THEME,
        css=CSS,
        show_error=True,
        footer_links=[],
        allowed_paths=[str(TERMS_PATH), str(settings.HISTORY_DIR)],
        js=COPITA_GLOBAL_JS,
        head=GRADIO_CHROME_FIX_HEAD,
    )
