"""
HTTP endpoints facade — `_legacy` modülü import edildiğinde mevcut
@PromptServer.routes decorator'ları zaten kaydoluyor. Bu modül ayrıca
Sprint 1+ (W12+) için yeni endpoint'leri burada register eder, böylece
`_legacy.py` büyümez (W10 modülerleştirme yönü).

Mevcut rotalar (_legacy.py içinden):
  - /fikoyt_mvd/projects, /project/{name}, /project/{name}/versions
  - /fikoyt_mvd/characters, /character/{cid}/*
  - /fikoyt_mvd/wizard_apply, /wizard_apply_video, /regen_prompt, /video_ai_chat
  - /fikoyt_mvd/loras, /train_lora, /lora_presets
  - /fikoyt_mvd/sys_config, /memory_state, /free_comfy_vram, /free_ollama
  - /fikoyt_mvd/ollama_pull, /local_llm/*
  - /fikoyt_mvd/check_models, /check_pulid, /install_pulid, /download_models
  - /fikoyt_mvd/upload_image, /upload_reference, /inpaint_segment
  - /fikoyt_mvd/regional_2char_generate
  - /fikoyt_mvd/assets[/{aid}], /assets/export, /assets/import
  - /fikoyt_mvd/save_project, /migrate_workflow, /check_workflow
  - /fikoyt_mvd/inject_references, /inject_capabilities

Yeni rotalar (Sprint 1 — W12):
  - /fikoyt_mvd/check_instantid, /install_instantid, /install_instantid_status
  - /fikoyt_mvd/check_ipadapter_v2, /install_ipadapter_v2, /install_ipadapter_v2_status
"""
import os
import sys
import time
import json
import asyncio
import datetime
import shutil
import threading

from . import _legacy  # noqa: F401  # legacy route registrasyonu import zamanında çalışır


# Sprint 27: tüm prompt_queue.put çağrıları için ortak validate + put yardımcısı
# - validate_prompt → workflow hatalarını sessiz fail yerine raise eder
# - valid[2] → outputs_to_execute (SaveImage/SaveVideo node ID'leri); [] verirsek
#   ComfyUI hiçbir output node'u çağırmaz, "Prompt executed in 0.01s" diye sessiz biter
# - create_time + 6-eleman tuple format
async def _validated_put(server_instance, wf, client_id="fmvd", prompt_id=None):
    """Workflow'u validate edip queue'ya put eder.
    Dönüş: {"ok": bool, "prompt_id": str, "error": str?, "node_errors": dict?}"""
    import uuid as _uuid, time as _tm
    pid = prompt_id or str(_uuid.uuid4())
    try:
        import execution as _exec
        valid = await _exec.validate_prompt(pid, wf, None)
        if not valid[0]:
            err = str(valid[1]) if len(valid) > 1 else "validation fail"
            nerrs = valid[3] if len(valid) > 3 else {}
            print(f"[FikoYT] WF validation fail ({client_id}): {err}")
            print(f"   node_errors: {nerrs}")
            return {"ok": False, "prompt_id": pid, "error": err, "node_errors": nerrs}
        outputs_to_exec = valid[2] if len(valid) > 2 else []
        server_instance.prompt_queue.put((
            0, pid, wf,
            {"client_id": client_id, "create_time": int(_tm.time() * 1000)},
            outputs_to_exec, {}
        ))
        return {"ok": True, "prompt_id": pid, "outputs": outputs_to_exec}
    except Exception as e:
        import traceback; traceback.print_exc()
        return {"ok": False, "prompt_id": pid, "error": str(e)}


# =============================================================================
# Sprint 1 (W12) — InstantID + IPAdapter v2 check & install endpoints
# =============================================================================
try:
    from server import PromptServer
    from aiohttp import web
    import folder_paths

    _server = getattr(PromptServer, "instance", None)
    if _server is None:
        raise RuntimeError("PromptServer.instance henüz hazır değil")

    _install_lock = threading.Lock()
    _instantid_install_state = {"in_progress": False, "log": "", "done": False, "error": ""}
    _ipadapter_v2_install_state = {"in_progress": False, "log": "", "done": False, "error": ""}

    # -------------------------------------------------------------------------
    # Ortak install helper'ı — git clone + pip install + HF model download
    # PuLID install (_legacy.py:7103) pattern'inin generic versiyonu.
    # Sprint 5'te `nodes/common.py:_generic_hf_install` haline refaktör edilecek
    # ve PuLID install bunu çağıracak.
    # -------------------------------------------------------------------------
    async def _stream_subprocess(cmd_args, state):
        """Subprocess çalıştır, stdout'u state['log']'a stream et."""
        proc = await asyncio.create_subprocess_exec(
            *cmd_args,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.STDOUT,
        )
        while True:
            line = await proc.stdout.readline()
            if not line:
                break
            state["log"] += line.decode("utf-8", errors="replace")
        return await proc.wait()

    async def _git_clone_node(repo_url, state, label="custom node"):
        """custom_nodes altına git clone."""
        # Sprint 32 HOTFIX: 3 dirname (custom_nodes/) — 2 yanlıştı (plugin root)
        # _legacy.py: .../custom_nodes/ComfyUI-MVD/nodes/_legacy.py
        # dirname×3 → .../custom_nodes/
        cn_dir = os.path.dirname(os.path.dirname(os.path.dirname(
            os.path.abspath(_legacy.__file__))))
        repo_name = repo_url.rstrip("/").split("/")[-1].replace(".git", "")
        target = os.path.join(cn_dir, repo_name)
        if os.path.isdir(target):
            state["log"] += f"\n[skip] {repo_name} zaten var\n"
            return target
        git_bin = shutil.which("git")
        if not git_bin:
            raise RuntimeError("git bulunamadı (PATH'te değil)")
        state["log"] += f"\n[git] clone {label} → {repo_name}\n"
        rc = await _stream_subprocess([git_bin, "clone", repo_url, target], state)
        if rc != 0:
            raise RuntimeError(f"git clone exit {rc}")
        # requirements.txt
        req = os.path.join(target, "requirements.txt")
        if os.path.isfile(req):
            state["log"] += f"\n[pip] install -r requirements.txt ({repo_name})\n"
            await _stream_subprocess(
                [sys.executable, "-s", "-m", "pip", "install", "-r", req], state)
        return target

    def _hf_download_sync(url, dest_path, state, min_size=1_000_000):
        """Sync urlretrieve + Sprint 35 progress tracking (current_pct, MB)."""
        import urllib.request
        if os.path.isfile(dest_path) and os.path.getsize(dest_path) > min_size:
            state["log"] += f"  [cached] {os.path.basename(dest_path)}\n"
            return
        fname = os.path.basename(dest_path)
        state["log"] += f"  [dl] {fname} ← {url}\n"
        state["current_file"] = fname
        state["current_pct"] = 0
        state["downloaded_mb"] = 0
        state["total_mb"] = 0

        # Progress hook — state'i hem MB hem % olarak günceller
        last_log_pct = -10
        def _hook(blocks, block_size, total):
            nonlocal last_log_pct
            if total > 0:
                downloaded = blocks * block_size
                pct = min(100, int(downloaded * 100 / total))
                state["current_pct"] = pct
                state["downloaded_mb"] = round(downloaded / 1_048_576, 1)
                state["total_mb"] = round(total / 1_048_576, 1)
                # Her %5'te log'a yaz (log spam'i önle ama görünür kalsın)
                if pct >= last_log_pct + 5:
                    state["log"] += (f"     {pct}% · "
                                     f"{state['downloaded_mb']:.0f}/{state['total_mb']:.0f} MB\n")
                    last_log_pct = pct
        try:
            urllib.request.urlretrieve(url, dest_path, reporthook=_hook)
            state["log"] += f"  ✓ {fname} indirildi ({state['total_mb']:.0f} MB)\n"
        except Exception as e:
            state["log"] += f"  ❌ {fname}: {e}\n"
            if os.path.isfile(dest_path):
                try: os.remove(dest_path)
                except Exception: pass
            raise
        finally:
            state["current_pct"] = 0
            state["current_file"] = ""

    async def _hf_download(url, dest_path, state, min_size=1_000_000):
        await asyncio.get_event_loop().run_in_executor(
            None, _hf_download_sync, url, dest_path, state, min_size)

    # =========================================================================
    # InstantID
    # =========================================================================
    INSTANTID_MODELS = {
        # (relative path under models_dir, HF URL, min_size_bytes)
        "instantid_ipadapter": (
            "instantid/ip-adapter.bin",
            "https://huggingface.co/InstantX/InstantID/resolve/main/ip-adapter.bin",
            500_000,
        ),
        "instantid_controlnet": (
            "controlnet/instantid_diffusion_pytorch_model.safetensors",
            "https://huggingface.co/InstantX/InstantID/resolve/main/ControlNetModel/diffusion_pytorch_model.safetensors",
            500_000_000,
        ),
    }

    def _instantid_status_dict():
        from nodes import NODE_CLASS_MAPPINGS as _NCM
        ok_nodes = all(_NCM.get(n) for n in
                       ["InstantIDModelLoader", "InstantIDFaceAnalysis"]) and \
                   (_NCM.get("ApplyInstantID") or _NCM.get("ApplyInstantIDAdvanced"))
        models = {}
        models_dir = folder_paths.models_dir
        for key, (rel, _url, min_sz) in INSTANTID_MODELS.items():
            p = os.path.join(models_dir, rel)
            models[key] = os.path.isfile(p) and os.path.getsize(p) >= min_sz
        ins_dir = os.path.join(models_dir, "insightface", "models", "antelopev2")
        antelope_ok = False
        if os.path.isdir(ins_dir):
            try:
                onnx_files = [f for f in os.listdir(ins_dir)
                              if f.endswith(".onnx") and
                              os.path.getsize(os.path.join(ins_dir, f)) > 1_000_000]
                antelope_ok = len(onnx_files) >= 3
            except Exception:
                pass
        return {
            "node_installed": bool(ok_nodes),
            "models": models,
            "antelopev2": antelope_ok,
            "ready": bool(ok_nodes) and all(models.values()) and antelope_ok,
            "install_cmd": "ComfyUI Manager > 'ComfyUI_InstantID' (cubiq)",
        }

    @_server.routes.get("/fikoyt_mvd/check_instantid")
    async def _check_instantid(request):
        return web.json_response(_instantid_status_dict())

    @_server.routes.post("/fikoyt_mvd/install_instantid")
    async def _install_instantid(request):
        with _install_lock:
            if _instantid_install_state["in_progress"]:
                return web.json_response({"error": "Zaten yükleniyor"}, status=409)
            _instantid_install_state.update({
                "in_progress": True, "log": "", "done": False, "error": "",
                "current_file": "", "current_pct": 0,
                "downloaded_mb": 0, "total_mb": 0,
                "started_at": datetime.datetime.now().isoformat(),
            })

        async def _bg():
            try:
                await _git_clone_node(
                    "https://github.com/cubiq/ComfyUI_InstantID.git",
                    _instantid_install_state,
                    label="InstantID")

                # Ek bağımlılıklar: insightface, onnxruntime, opencv
                _instantid_install_state["log"] += "\n[pip] ek paketler: insightface + onnxruntime\n"
                for pkg in ("insightface", "onnxruntime"):
                    await _stream_subprocess(
                        [sys.executable, "-s", "-m", "pip", "install", pkg],
                        _instantid_install_state)

                # Model dosyaları
                _instantid_install_state["log"] += "\n[hf] InstantID modelleri\n"
                models_dir = folder_paths.models_dir
                for key, (rel, url, min_sz) in INSTANTID_MODELS.items():
                    dest = os.path.join(models_dir, rel)
                    os.makedirs(os.path.dirname(dest), exist_ok=True)
                    try:
                        await _hf_download(url, dest, _instantid_install_state, min_sz)
                    except Exception as e:
                        _instantid_install_state["log"] += f"  ❌ {key}: {e}\n"

                # AntelopeV2 paylaşımlı — PuLID ile aynı (varsa atlanır)
                ins_parent = os.path.join(models_dir, "insightface", "models")
                ins_dir = os.path.join(ins_parent, "antelopev2")
                has_onnx = os.path.isdir(ins_dir) and any(
                    f.endswith(".onnx") and os.path.getsize(os.path.join(ins_dir, f)) > 1_000_000
                    for f in os.listdir(ins_dir))
                if not has_onnx:
                    _instantid_install_state["log"] += "\n[+] AntelopeV2 zip iniyor (~244MB)\n"
                    os.makedirs(ins_parent, exist_ok=True)
                    zip_path = os.path.join(ins_parent, "antelopev2.zip")
                    zip_url = "https://github.com/deepinsight/insightface/releases/download/v0.7/antelopev2.zip"
                    try:
                        def _dl_zip():
                            import urllib.request
                            import zipfile
                            urllib.request.urlretrieve(zip_url, zip_path)
                            with zipfile.ZipFile(zip_path) as z:
                                z.extractall(ins_parent)
                            os.remove(zip_path)
                        await asyncio.get_event_loop().run_in_executor(None, _dl_zip)
                        _instantid_install_state["log"] += "  ✓ AntelopeV2 açıldı.\n"
                    except Exception as e:
                        _instantid_install_state["log"] += f"  ❌ AntelopeV2: {e}\n"

                _instantid_install_state["log"] += "\n✓ InstantID yüklemesi tamam. ComfyUI'yi RESTART et!\n"
                print("[FikoYT] ✓ InstantID yüklendi — restart gerekli")
            except Exception as e:
                _instantid_install_state["error"] = str(e)
                _instantid_install_state["log"] += f"\n❌ HATA: {e}\n"
                print(f"[FikoYT] InstantID install HATA: {e}")
            finally:
                _instantid_install_state["done"] = True
                _instantid_install_state["in_progress"] = False

        asyncio.create_task(_bg())
        return web.json_response({"ok": True})

    @_server.routes.get("/fikoyt_mvd/install_instantid_status")
    async def _install_instantid_status(request):
        return web.json_response(_instantid_install_state)

    # =========================================================================
    # IPAdapter v2 (FaceID-Plus-v2)
    # =========================================================================
    IPADAPTER_V2_MODELS = {
        "ipadapter_faceid_sdxl": (
            "ipadapter/ip-adapter-faceid-plusv2_sdxl.bin",
            "https://huggingface.co/h94/IP-Adapter-FaceID/resolve/main/ip-adapter-faceid-plusv2_sdxl.bin",
            500_000_000,
        ),
        "ipadapter_faceid_sdxl_lora": (
            "loras/ip-adapter-faceid-plusv2_sdxl_lora.safetensors",
            "https://huggingface.co/h94/IP-Adapter-FaceID/resolve/main/ip-adapter-faceid-plusv2_sdxl_lora.safetensors",
            300_000_000,
        ),
        # SD1.5 da bonus olarak indir (opsiyonel)
        "ipadapter_faceid_sd15": (
            "ipadapter/ip-adapter-faceid-plusv2_sd15.bin",
            "https://huggingface.co/h94/IP-Adapter-FaceID/resolve/main/ip-adapter-faceid-plusv2_sd15.bin",
            100_000_000,
        ),
    }

    def _ipadapter_v2_status_dict():
        from nodes import NODE_CLASS_MAPPINGS as _NCM
        ok_nodes = bool(_NCM.get("IPAdapterUnifiedLoader") or _NCM.get("IPAdapterUnifiedLoaderFaceID"))
        models = {}
        models_dir = folder_paths.models_dir
        for key, (rel, _url, min_sz) in IPADAPTER_V2_MODELS.items():
            p = os.path.join(models_dir, rel)
            models[key] = os.path.isfile(p) and os.path.getsize(p) >= min_sz
        # SDXL kritik ikilisi yeterli; SD1.5 opsiyonel
        ready = ok_nodes and models.get("ipadapter_faceid_sdxl") and \
                models.get("ipadapter_faceid_sdxl_lora")
        return {
            "node_installed": ok_nodes,
            "models": models,
            "ready": bool(ready),
            "install_cmd": "ComfyUI Manager > 'ComfyUI_IPAdapter_plus' (cubiq)",
        }

    @_server.routes.get("/fikoyt_mvd/check_ipadapter_v2")
    async def _check_ipadapter_v2(request):
        return web.json_response(_ipadapter_v2_status_dict())

    @_server.routes.post("/fikoyt_mvd/install_ipadapter_v2")
    async def _install_ipadapter_v2(request):
        with _install_lock:
            if _ipadapter_v2_install_state["in_progress"]:
                return web.json_response({"error": "Zaten yükleniyor"}, status=409)
            _ipadapter_v2_install_state.update({
                "in_progress": True, "log": "", "done": False, "error": "",
                "current_file": "", "current_pct": 0,
                "downloaded_mb": 0, "total_mb": 0,
                "started_at": datetime.datetime.now().isoformat(),
            })

        async def _bg():
            try:
                # Custom node — cubiq IPAdapter_plus genelde mevcut, yoksa clone
                await _git_clone_node(
                    "https://github.com/cubiq/ComfyUI_IPAdapter_plus.git",
                    _ipadapter_v2_install_state,
                    label="IPAdapter_plus")

                # FaceID için insightface gerekli
                _ipadapter_v2_install_state["log"] += "\n[pip] insightface + onnxruntime\n"
                for pkg in ("insightface", "onnxruntime"):
                    await _stream_subprocess(
                        [sys.executable, "-s", "-m", "pip", "install", pkg],
                        _ipadapter_v2_install_state)

                _ipadapter_v2_install_state["log"] += "\n[hf] IPAdapter FaceID-Plus-v2 modelleri\n"
                models_dir = folder_paths.models_dir
                for key, (rel, url, min_sz) in IPADAPTER_V2_MODELS.items():
                    dest = os.path.join(models_dir, rel)
                    os.makedirs(os.path.dirname(dest), exist_ok=True)
                    try:
                        await _hf_download(url, dest, _ipadapter_v2_install_state, min_sz)
                    except Exception as e:
                        _ipadapter_v2_install_state["log"] += f"  ❌ {key}: {e}\n"

                _ipadapter_v2_install_state["log"] += "\n✓ IPAdapter v2 yüklemesi tamam. ComfyUI'yi RESTART et!\n"
                print("[FikoYT] ✓ IPAdapter v2 yüklendi — restart gerekli")
            except Exception as e:
                _ipadapter_v2_install_state["error"] = str(e)
                _ipadapter_v2_install_state["log"] += f"\n❌ HATA: {e}\n"
                print(f"[FikoYT] IPAdapter v2 install HATA: {e}")
            finally:
                _ipadapter_v2_install_state["done"] = True
                _ipadapter_v2_install_state["in_progress"] = False

        asyncio.create_task(_bg())
        return web.json_response({"ok": True})

    @_server.routes.get("/fikoyt_mvd/install_ipadapter_v2_status")
    async def _install_ipadapter_v2_status(request):
        return web.json_response(_ipadapter_v2_install_state)

    # =========================================================================
    # Sprint 2 (W13) — DWPose / ControlNet Union install + pose library CRUD
    # =========================================================================
    _dwpose_install_state = {"in_progress": False, "log": "", "done": False, "error": ""}
    _cn_union_install_state = {"in_progress": False, "log": "", "done": False, "error": ""}

    # ControlNet Union model alternatifleri (kullanıcı kendi seçer; UI'da dropdown)
    CN_UNION_MODELS = {
        "flux_union_pro": (
            "controlnet/FLUX.1-dev-Controlnet-Union-Pro.safetensors",
            "https://huggingface.co/Shakker-Labs/FLUX.1-dev-ControlNet-Union-Pro/resolve/main/diffusion_pytorch_model.safetensors",
            1_000_000_000,
        ),
        "flux_union_instantx": (
            "controlnet/FLUX.1-dev-Controlnet-Union.safetensors",
            "https://huggingface.co/InstantX/FLUX.1-dev-Controlnet-Union/resolve/main/diffusion_pytorch_model.safetensors",
            1_000_000_000,
        ),
        "sdxl_union": (
            "controlnet/controlnet-union-sdxl-1.0.safetensors",
            "https://huggingface.co/xinsir/controlnet-union-sdxl-1.0/resolve/main/diffusion_pytorch_model.safetensors",
            1_000_000_000,
        ),
    }
    # Sprint 30: DWPose modelleri için MULTI-PATH desteği — controlnet_aux farklı
    # sürümler farklı yerlere bakar. Hepsine kopyalanır + hepsi tarar.
    DWPOSE_MODELS = {
        "yolox_l": {
            "filename": "yolox_l.onnx",
            "url": "https://huggingface.co/yzd-v/DWPose/resolve/main/yolox_l.onnx",
            "min_size": 100_000_000,
            "paths": [
                "ultralytics/bbox/yolox_l.onnx",
                "dwpose/yolox_l.onnx",
                "yolox/yolox_l.onnx",
            ],
        },
        "dw_pose": {
            "filename": "dw-ll_ucoco_384.onnx",
            "url": "https://huggingface.co/yzd-v/DWPose/resolve/main/dw-ll_ucoco_384.onnx",
            "min_size": 100_000_000,
            "paths": [
                "DWPose/dw-ll_ucoco_384.onnx",
                "dwpose/dw-ll_ucoco_384.onnx",
            ],
        },
    }

    def _find_dwpose_model(key, info):
        """Multi-path: birden fazla yere bak, ilk bulduğunu döner."""
        models_dir = folder_paths.models_dir
        # Ayrıca controlnet_aux'un kendi ckpts/ klasörü
        # Sprint 32: 3 dirname (custom_nodes/) — here zaten nodes/ klasörü
        here = os.path.dirname(os.path.abspath(_legacy.__file__))  # nodes/
        cn_dir = os.path.dirname(os.path.dirname(here))  # nodes/→plugin→custom_nodes/
        cn_aux_ckpts = os.path.join(
            cn_dir, "comfyui_controlnet_aux", "ckpts", "yzd-v",
            "DWPose" if key == "dw_pose" else "yolox")
        extra_paths = [
            os.path.join(cn_aux_ckpts, info["filename"]),
        ]
        for rel in info["paths"]:
            p = os.path.join(models_dir, rel)
            if os.path.isfile(p) and os.path.getsize(p) >= info["min_size"]:
                return p
        for p in extra_paths:
            if os.path.isfile(p) and os.path.getsize(p) >= info["min_size"]:
                return p
        return None

    def _dwpose_status_dict():
        from nodes import NODE_CLASS_MAPPINGS as _NCM
        ok_nodes = bool(_NCM.get("DWPreprocessor"))
        models = {}
        for key, info in DWPOSE_MODELS.items():
            found = _find_dwpose_model(key, info)
            models[key] = bool(found)
        all_models_ok = all(models.values())
        ready = ok_nodes and all_models_ok
        # Net hint mesajları
        if ready:
            hint = "✓ Tamamen hazır — kullanmaya başlayabilirsin"
        elif all_models_ok and not ok_nodes:
            hint = "Modeller indirildi → ComfyUI'yi YENİDEN BAŞLAT (DWPreprocessor node henüz yüklenmemiş)"
        elif ok_nodes and not all_models_ok:
            hint = "Node yüklü ama modeller eksik — /install_dwpose çağır"
        else:
            hint = "Hem node hem modeller eksik — /install_dwpose tüm pipeline kurar"
        return {
            "node_installed": ok_nodes,
            "models": models,
            "ready": ready,
            "needs_restart": all_models_ok and not ok_nodes,
            "hint": hint,
            "install_cmd": "ComfyUI Manager > 'comfyui_controlnet_aux'",
        }

    def _cn_union_status_dict():
        models_dir = folder_paths.models_dir
        models = {}
        any_ready = False
        for key, (rel, _url, min_sz) in CN_UNION_MODELS.items():
            p = os.path.join(models_dir, rel)
            ok = os.path.isfile(p) and os.path.getsize(p) >= min_sz
            models[key] = ok
            if ok:
                any_ready = True
        return {
            "models": models,
            "ready": any_ready,
            "install_cmd": "Manuel: models/controlnet/ altına Union safetensors koy",
        }

    @_server.routes.get("/fikoyt_mvd/check_dwpose")
    async def _check_dwpose(request):
        return web.json_response(_dwpose_status_dict())

    @_server.routes.post("/fikoyt_mvd/install_dwpose")
    async def _install_dwpose(request):
        with _install_lock:
            if _dwpose_install_state["in_progress"]:
                return web.json_response({"error": "Zaten yükleniyor"}, status=409)
            _dwpose_install_state.update({
                "in_progress": True, "log": "", "done": False, "error": "",
                "current_file": "", "current_pct": 0,
                "downloaded_mb": 0, "total_mb": 0,
                "started_at": datetime.datetime.now().isoformat(),
            })

        async def _bg():
            try:
                # comfyui_controlnet_aux custom node
                await _git_clone_node(
                    "https://github.com/Fannovel16/comfyui_controlnet_aux.git",
                    _dwpose_install_state,
                    label="controlnet_aux")
                # Sprint 31: controlnet_aux'un TÜM kritik bağımlılıkları
                # (sadece onnxruntime yetmiyor — paket import fail edip DWPreprocessor
                # registered olmuyor → "RESTART GEREK" sonsuz döngüsü)
                _dwpose_install_state["log"] += "\n[pip] controlnet_aux bağımlılıkları (kritik)\n"
                # NVIDIA GPU için onnxruntime-gpu, CPU için onnxruntime
                deps = [
                    "onnxruntime",          # CPU fallback (zaten kuruluysa skip)
                    "onnxruntime-gpu",      # CUDA hızlandırma
                    "mediapipe",            # face/hand mesh detection
                    "opencv-contrib-python",
                    "scikit-image",
                    "trimesh",
                    "addict",
                    "yapf",
                    "filelock",
                    "omegaconf",
                    "ftfy",
                    "importlib_metadata",
                    "imageio_ffmpeg",
                    "matplotlib",
                ]
                for dep in deps:
                    _dwpose_install_state["log"] += f"  → pip install {dep}\n"
                    try:
                        await _stream_subprocess(
                            [sys.executable, "-s", "-m", "pip", "install",
                             "--upgrade", dep],
                            _dwpose_install_state)
                    except Exception as e:
                        _dwpose_install_state["log"] += f"     ⚠ {e}\n"
                # DWPose modelleri — Sprint 30: multi-path (controlnet_aux farklı sürümler)
                _dwpose_install_state["log"] += "\n[hf] DWPose ONNX modelleri (multi-path)\n"
                models_dir = folder_paths.models_dir
                for key, info in DWPOSE_MODELS.items():
                    # İlk path'e indir
                    primary = os.path.join(models_dir, info["paths"][0])
                    os.makedirs(os.path.dirname(primary), exist_ok=True)
                    try:
                        await _hf_download(info["url"], primary,
                                            _dwpose_install_state, info["min_size"])
                    except Exception as e:
                        _dwpose_install_state["log"] += f"  ❌ {key}: {e}\n"
                        continue
                    # Diğer path'lere COPY (controlnet_aux her sürümü bulsun)
                    if os.path.isfile(primary):
                        for alt in info["paths"][1:]:
                            alt_path = os.path.join(models_dir, alt)
                            if os.path.isfile(alt_path):
                                continue
                            os.makedirs(os.path.dirname(alt_path), exist_ok=True)
                            try:
                                shutil.copy2(primary, alt_path)
                                _dwpose_install_state["log"] += f"  → kopya: {alt}\n"
                            except Exception as e:
                                _dwpose_install_state["log"] += f"  ⚠ copy {alt}: {e}\n"
                _dwpose_install_state["log"] += (
                    "\n✓ DWPose modelleri indirildi (multi-path).\n")

                # Sprint 31: Diagnostic — controlnet_aux import edilebiliyor mu?
                _dwpose_install_state["log"] += "\n[diag] controlnet_aux import test\n"
                try:
                    here = os.path.dirname(os.path.abspath(_legacy.__file__))
                    cn_dir = os.path.dirname(os.path.dirname(here))
                    cn_aux = os.path.join(cn_dir, "comfyui_controlnet_aux")
                    diag_proc = await asyncio.create_subprocess_exec(
                        sys.executable, "-c",
                        f"import sys; sys.path.insert(0, r'{cn_aux}'); "
                        "import custom_controlnet_aux; "
                        "from custom_controlnet_aux.dwpose import DwposeDetector; "
                        "print('IMPORT_OK')",
                        stdout=asyncio.subprocess.PIPE,
                        stderr=asyncio.subprocess.STDOUT,
                    )
                    out, _ = await diag_proc.communicate()
                    out_str = out.decode("utf-8", errors="replace")
                    if "IMPORT_OK" in out_str:
                        _dwpose_install_state["log"] += "  ✓ Paket düzgün import ediyor\n"
                        _dwpose_install_state["log"] += (
                            "\n🎯 KURULUM TAMAM. ComfyUI'yi YENİDEN BAŞLAT:\n"
                            "  1. cmd penceresinde Ctrl+C → kapat\n"
                            "  2. run_nvidia_gpu.bat tekrar çalıştır\n"
                            "  3. Restart sonrası DWPreprocessor node aktif olur\n")
                    else:
                        _dwpose_install_state["log"] += "  ❌ Import HÂLÂ FAIL:\n"
                        _dwpose_install_state["log"] += (
                            "  " + out_str[-1500:].replace("\n", "\n  ") + "\n")
                        _dwpose_install_state["log"] += (
                            "\n⚠ ALTERNATİF YOL: ComfyUI Manager kullan:\n"
                            "  1. ComfyUI restart\n"
                            "  2. ComfyUI Manager > Install Custom Nodes\n"
                            "  3. Ara: 'controlnet_aux' → Install\n"
                            "  4. Restart\n"
                            "  Manager hangi paketler eksik diye söyler.\n")
                except Exception as e:
                    _dwpose_install_state["log"] += f"  ⚠ Diagnostic crash: {e}\n"
                    _dwpose_install_state["log"] += "\nRESTART deneyip Pose Studio'yu tekrar aç.\n"
            except Exception as e:
                _dwpose_install_state["error"] = str(e)
                _dwpose_install_state["log"] += f"\n❌ HATA: {e}\n"
            finally:
                _dwpose_install_state["done"] = True
                _dwpose_install_state["in_progress"] = False

        asyncio.create_task(_bg())
        return web.json_response({"ok": True})

    @_server.routes.get("/fikoyt_mvd/install_dwpose_status")
    async def _install_dwpose_status(request):
        return web.json_response(_dwpose_install_state)

    @_server.routes.get("/fikoyt_mvd/dwpose_diagnose")
    async def _dwpose_diagnose(request):
        """Sprint 31: Detaylı diagnoz — neden DWPreprocessor görünmüyor?"""
        import subprocess as _sp
        here = os.path.dirname(os.path.abspath(_legacy.__file__))
        cn_dir = os.path.dirname(os.path.dirname(here))
        cn_aux = os.path.join(cn_dir, "comfyui_controlnet_aux")
        result = {
            "package_dir_exists": os.path.isdir(cn_aux),
            "package_dir": cn_aux,
        }
        if not result["package_dir_exists"]:
            result["error"] = "comfyui_controlnet_aux klasörü yok → /install_dwpose çağır"
            return web.json_response(result)

        # __init__.py var mı
        init_py = os.path.join(cn_aux, "__init__.py")
        result["init_py_exists"] = os.path.isfile(init_py)

        # Subprocess ile import test
        try:
            proc = _sp.run(
                [sys.executable, "-c",
                 f"import sys; sys.path.insert(0, r'{cn_dir}'); "
                 "import comfyui_controlnet_aux; print('PACKAGE_OK')"],
                capture_output=True, text=True, timeout=30,
            )
            result["package_import"] = {
                "ok": "PACKAGE_OK" in proc.stdout,
                "stdout_tail": proc.stdout[-800:],
                "stderr_tail": proc.stderr[-1500:],
                "exit_code": proc.returncode,
            }
        except Exception as e:
            result["package_import"] = {"error": str(e)}

        # ComfyUI registered node tarama
        from nodes import NODE_CLASS_MAPPINGS as _NCM
        result["nodes_registered"] = {
            "DWPreprocessor": "DWPreprocessor" in _NCM,
            "OpenposePreprocessor": "OpenposePreprocessor" in _NCM,
            "AIO_Preprocessor": "AIO_Preprocessor" in _NCM,
        }

        # Eksik bağımlılıklar
        deps_check = {}
        for dep_name, import_name in [
            ("onnxruntime", "onnxruntime"),
            ("mediapipe", "mediapipe"),
            ("opencv-contrib-python", "cv2"),
            ("scikit-image", "skimage"),
            ("trimesh", "trimesh"),
        ]:
            try:
                _sp.run([sys.executable, "-c", f"import {import_name}"],
                        capture_output=True, timeout=10, check=True)
                deps_check[dep_name] = True
            except Exception:
                deps_check[dep_name] = False
        result["dependencies"] = deps_check
        missing_deps = [k for k, v in deps_check.items() if not v]
        if missing_deps:
            result["fix_hint"] = (
                f"Eksik pip paketleri: {', '.join(missing_deps)}\n"
                f"Manuel kur: {sys.executable} -m pip install {' '.join(missing_deps)}"
            )

        return web.json_response(result)

    @_server.routes.get("/fikoyt_mvd/check_cn_union")
    async def _check_cn_union(request):
        return web.json_response(_cn_union_status_dict())

    @_server.routes.post("/fikoyt_mvd/install_cn_union")
    async def _install_cn_union(request):
        """Body: { variant: "flux_union_pro" | "flux_union_instantx" | "sdxl_union" }"""
        try:
            body = await request.json()
        except Exception:
            body = {}
        variant = body.get("variant", "flux_union_pro")
        if variant not in CN_UNION_MODELS:
            return web.json_response({"error": f"Bilinmeyen variant: {variant}"}, status=400)
        with _install_lock:
            if _cn_union_install_state["in_progress"]:
                return web.json_response({"error": "Zaten yükleniyor"}, status=409)
            _cn_union_install_state.update({
                "in_progress": True, "log": "", "done": False, "error": "",
                "variant": variant,
                "current_file": "", "current_pct": 0,
                "downloaded_mb": 0, "total_mb": 0,
                "started_at": datetime.datetime.now().isoformat(),
            })

        async def _bg():
            try:
                rel, url, min_sz = CN_UNION_MODELS[variant]
                dest = os.path.join(folder_paths.models_dir, rel)
                os.makedirs(os.path.dirname(dest), exist_ok=True)
                _cn_union_install_state["log"] += f"\n[hf] {variant} → {rel}\n"
                await _hf_download(url, dest, _cn_union_install_state, min_sz)
                _cn_union_install_state["log"] += f"\n✓ ControlNet Union ({variant}) tamam.\n"
            except Exception as e:
                _cn_union_install_state["error"] = str(e)
                _cn_union_install_state["log"] += f"\n❌ HATA: {e}\n"
            finally:
                _cn_union_install_state["done"] = True
                _cn_union_install_state["in_progress"] = False

        asyncio.create_task(_bg())
        return web.json_response({"ok": True, "variant": variant})

    @_server.routes.get("/fikoyt_mvd/install_cn_union_status")
    async def _install_cn_union_status(request):
        return web.json_response(_cn_union_install_state)

    # -------------------------------------------------------------------------
    # Pose library — karakter başına pose görsel + keypoints
    # -------------------------------------------------------------------------
    def _char_pose_dir(cid):
        from ._legacy import _char_path
        d = os.path.join(_char_path(cid), "poses")
        os.makedirs(d, exist_ok=True)
        return d

    @_server.routes.get("/fikoyt_mvd/character/{cid}/poses")
    async def _list_poses(request):
        from ._legacy import _load_character, _view_url
        cid = request.match_info["cid"]
        char = _load_character(cid)
        if not char:
            return web.json_response({"error": "Karakter bulunamadı"}, status=404)
        poses = char.get("pose_library") or []
        # URL'leri tazele
        for p in poses:
            if p.get("source_path"):
                p["source_url"] = _view_url(p["source_path"])
            if p.get("pose_path"):
                p["pose_url"] = _view_url(p["pose_path"])
        return web.json_response({"poses": poses})

    @_server.routes.post("/fikoyt_mvd/character/{cid}/poses")
    async def _add_pose(request):
        """Multipart fields:
          - image (file, required) — kaynak resim VEYA hazır iskelet PNG
          - label (text)
          - auto_extract (bool, default true) — false ise resim zaten iskelet kabul edilir
          - keypoints_json (text, optional) — Sprint 8: pose-editor.js'ten gelen
            {kp:[[x,y,v],...], width, height, source:"pose_editor"} formatı.
            Verilirse manifest'e yazılır; ileride tekrar düzenlenebilir.

        auto_extract=true: DWPose ile iskelet çıkarılır (kaynak resim → iskelet)
        auto_extract=false: image zaten iskelet sayılır, pose_path = source_path
                            (pose-editor.js çıktısı bu yoldan girer)"""
        from ._legacy import _load_character, _save_character, _slug, _view_url, _rel_to_output
        import uuid
        cid = request.match_info["cid"]
        char = _load_character(cid)
        if not char:
            return web.json_response({"error": "Karakter bulunamadı"}, status=404)
        try:
            reader = await request.multipart()
            file_bytes = None
            file_ext = "png"
            label = ""
            auto_extract = True
            editor_keypoints = None
            async for field in reader:
                if field.name == "image":
                    file_bytes = await field.read()
                    fn = (field.filename or "pose.png").lower()
                    for e in (".png", ".jpg", ".jpeg", ".webp"):
                        if fn.endswith(e):
                            file_ext = e[1:]; break
                elif field.name == "label":
                    label = (await field.text()).strip()[:60]
                elif field.name == "auto_extract":
                    auto_extract = (await field.text()).strip().lower() in ("1", "true", "yes", "on")
                elif field.name == "keypoints_json":
                    editor_keypoints = (await field.text()).strip()
            if not file_bytes:
                return web.json_response({"error": "image dosyası eksik"}, status=400)
            pid = uuid.uuid4().hex[:10]
            pose_dir = _char_pose_dir(cid)
            src_path = os.path.join(pose_dir, f"{pid}_source.{file_ext}")
            with open(src_path, "wb") as f:
                f.write(file_bytes)

            pose_path = None
            keypoints_json = "{}"
            extract_status = "auto_extract kapalı"
            source_label = "raw"

            if auto_extract:
                # DWPose ile iskelet çıkar
                try:
                    from .visual import FikoYT_MVD_PoseExtractor
                    import torch
                    from PIL import Image as PILImage
                    import numpy as np
                    img = PILImage.open(src_path).convert("RGB")
                    arr = np.array(img).astype(np.float32) / 255.0
                    img_tensor = torch.from_numpy(arr).unsqueeze(0)
                    extractor = FikoYT_MVD_PoseExtractor()
                    pose_tensor, keypoints_json, extract_status = extractor.extract(
                        img_tensor, preferred="dwpose", resolution=512)
                    pose_arr = (pose_tensor[0].cpu().numpy() * 255).clip(0, 255).astype("uint8")
                    pose_pil = PILImage.fromarray(pose_arr)
                    pose_path = os.path.join(pose_dir, f"{pid}_pose.png")
                    pose_pil.save(pose_path)
                    source_label = "dwpose_extract"
                except Exception as e:
                    extract_status = f"Extract hata: {e}"
            else:
                # auto_extract=false → image zaten iskelet (pose-editor.js çıktısı)
                # source_path'i pose_path olarak da kullan
                pose_path = src_path
                extract_status = "skeleton_provided"
                source_label = "drawn" if editor_keypoints else "raw_skeleton"
                # Editor keypoints'i varsa onu keypoints_json yap
                if editor_keypoints:
                    keypoints_json = editor_keypoints

            entry = {
                "id": pid,
                "label": label or f"pose_{pid[:6]}",
                "source_path": src_path,
                "source_rel": _rel_to_output(src_path),
                "pose_path": pose_path,
                "pose_rel": _rel_to_output(pose_path) if pose_path else None,
                "keypoints": keypoints_json,
                "extract_status": extract_status,
                "source_label": source_label,  # raw | drawn | dwpose_extract | raw_skeleton
                "created_at": datetime.datetime.now().isoformat(),
            }
            char.setdefault("pose_library", []).append(entry)
            _save_character(cid, char)
            entry["source_url"] = _view_url(src_path)
            if pose_path:
                entry["pose_url"] = _view_url(pose_path)
            return web.json_response({"ok": True, "pose": entry})
        except Exception as e:
            import traceback; traceback.print_exc()
            return web.json_response({"error": str(e)}, status=500)

    @_server.routes.post("/fikoyt_mvd/character/{cid}/poses/{pid}/extract")
    async def _extract_pose(request):
        """Mevcut bir pose'un source_image'ından DWPose ile iskelet çıkar + kaydet.
        Sprint 33: 'Preprocessor yok' kalan eski raw upload'lar için."""
        from ._legacy import _load_character, _save_character, _view_url, _rel_to_output
        cid = request.match_info["cid"]
        pid = request.match_info["pid"]
        char = _load_character(cid)
        if not char:
            return web.json_response({"error": "Karakter bulunamadı"}, status=404)
        poses = char.get("pose_library") or []
        target = next((p for p in poses if p.get("id") == pid), None)
        if not target:
            return web.json_response({"error": "Pose bulunamadı"}, status=404)
        src = target.get("source_path")
        if not src or not os.path.isfile(src):
            return web.json_response({"error": "source_image yok"}, status=400)

        # DWPose çalıştır
        try:
            from .visual import FikoYT_MVD_PoseExtractor
            import torch
            from PIL import Image as PILImage
            import numpy as np
            img = PILImage.open(src).convert("RGB")
            arr = np.array(img).astype(np.float32) / 255.0
            img_tensor = torch.from_numpy(arr).unsqueeze(0)
            extractor = FikoYT_MVD_PoseExtractor()
            pose_tensor, keypoints_json, extract_status = extractor.extract(
                img_tensor, preferred="dwpose", resolution=512)
            # Sprint 38: defansif tensor handling
            print(f"[FikoYT extract] pose_tensor type: {type(pose_tensor).__name__}, "
                  f"shape: {getattr(pose_tensor, 'shape', '?')}, status: {extract_status}")
            if not hasattr(pose_tensor, "cpu"):
                return web.json_response({
                    "error": f"Pose extractor non-tensor döndü: {type(pose_tensor).__name__}",
                    "status": extract_status,
                }, status=500)
            # Tensor → PNG
            from ._legacy import _char_path
            pose_dir = os.path.join(_char_path(cid), "poses")
            os.makedirs(pose_dir, exist_ok=True)
            pose_path = os.path.join(pose_dir, f"{pid}_pose.png")
            # Shape kontrol: [B,H,W,C] veya [H,W,C]
            if pose_tensor.ndim == 4:
                arr_np = pose_tensor[0].cpu().numpy()
            elif pose_tensor.ndim == 3:
                arr_np = pose_tensor.cpu().numpy()
            else:
                return web.json_response({
                    "error": f"Beklenmedik tensor şekli: {pose_tensor.shape}",
                }, status=500)
            pose_arr = (arr_np * 255).clip(0, 255).astype("uint8")
            PILImage.fromarray(pose_arr).save(pose_path)
            # Pose meta güncelle
            target["pose_path"] = pose_path
            target["pose_rel"] = _rel_to_output(pose_path)
            target["keypoints"] = keypoints_json
            target["extract_status"] = extract_status
            target["source_label"] = "dwpose_extract"
            _save_character(cid, char)
            return web.json_response({
                "ok": True,
                "pose_url": _view_url(pose_path),
                "extract_status": extract_status,
            })
        except Exception as e:
            import traceback; traceback.print_exc()
            return web.json_response({"error": str(e)}, status=500)

    @_server.routes.delete("/fikoyt_mvd/character/{cid}/poses/{pid}")
    async def _del_pose(request):
        from ._legacy import _load_character, _save_character
        cid = request.match_info["cid"]
        pid = request.match_info["pid"]
        char = _load_character(cid)
        if not char:
            return web.json_response({"error": "Karakter bulunamadı"}, status=404)
        poses = char.get("pose_library") or []
        target = next((p for p in poses if p.get("id") == pid), None)
        if not target:
            return web.json_response({"error": "Pose bulunamadı"}, status=404)
        # Dosyaları sil
        for k in ("source_path", "pose_path"):
            p = target.get(k)
            if p and os.path.isfile(p):
                try:
                    os.remove(p)
                except Exception:
                    pass
        char["pose_library"] = [p for p in poses if p.get("id") != pid]
        _save_character(cid, char)
        return web.json_response({"ok": True})

    # =========================================================================
    # Sprint 4 (W15) — LoRA versiyonlama, expression, style transfer, ReActor
    # =========================================================================
    @_server.routes.get("/fikoyt_mvd/character/{cid}/lora_versions")
    async def _list_lora_versions(request):
        from ._legacy import _load_character, _char_path, _view_url
        cid = request.match_info["cid"]
        char = _load_character(cid)
        if not char:
            return web.json_response({"error": "Karakter bulunamadı"}, status=404)
        # Filesystem'i kanonik kaynak yap: _lora_versions/<ts>/
        versions = []
        vdir = os.path.join(_char_path(cid), "_lora_versions")
        if os.path.isdir(vdir):
            for ts in sorted(os.listdir(vdir), reverse=True):
                vpath = os.path.join(vdir, ts)
                if not os.path.isdir(vpath):
                    continue
                lora_files = [f for f in os.listdir(vpath)
                              if f.endswith(".safetensors")]
                if not lora_files:
                    continue
                versions.append({
                    "ts": ts,
                    "path": vpath,
                    "lora_file": lora_files[0],
                    "size_mb": round(os.path.getsize(
                        os.path.join(vpath, lora_files[0])) / 1_048_576, 2),
                })
        return web.json_response({
            "active": char.get("active_lora_version"),
            "versions": versions,
        })

    @_server.routes.post("/fikoyt_mvd/character/{cid}/lora_versions/snapshot")
    async def _snapshot_lora_version(request):
        """Mevcut karaktere bağlı LoRA(lar)'ı _lora_versions/<UTC>/ altına kopyala."""
        from ._legacy import _load_character, _save_character, _char_path
        import folder_paths
        cid = request.match_info["cid"]
        char = _load_character(cid)
        if not char:
            return web.json_response({"error": "Karakter bulunamadı"}, status=404)
        try:
            body = await request.json() if request.body_exists else {}
        except Exception:
            body = {}
        notes = (body.get("notes") or "").strip()[:200]
        loras = char.get("loras") or []
        if not loras:
            return web.json_response({"error": "Bu karaktere bağlı LoRA yok"}, status=400)

        ts = datetime.datetime.utcnow().strftime("%Y%m%d_%H%M%S")
        vdir = os.path.join(_char_path(cid), "_lora_versions", ts)
        os.makedirs(vdir, exist_ok=True)

        try:
            lora_paths = folder_paths.get_folder_paths("loras") or []
        except Exception:
            lora_paths = []
        copied = []
        for entry in loras:
            fname = entry.get("filename") if isinstance(entry, dict) else entry
            if not fname:
                continue
            src = None
            for lp in lora_paths:
                cand = os.path.join(lp, fname)
                if os.path.isfile(cand):
                    src = cand; break
            if not src:
                continue
            dst = os.path.join(vdir, os.path.basename(fname))
            try:
                shutil.copy2(src, dst)
                copied.append(os.path.basename(fname))
            except Exception as e:
                print(f"[FikoYT] LoRA snapshot copy fail {fname}: {e}")

        if not copied:
            shutil.rmtree(vdir, ignore_errors=True)
            return web.json_response({"error": "Hiç LoRA kopyalanamadı"}, status=500)

        # Meta'ya snapshot kaydı
        char.setdefault("lora_versions", []).append({
            "ts": ts, "files": copied, "notes": notes,
        })
        _save_character(cid, char)
        return web.json_response({"ok": True, "ts": ts, "files": copied})

    @_server.routes.post("/fikoyt_mvd/character/{cid}/lora_versions/{ts}/activate")
    async def _activate_lora_version(request):
        from ._legacy import _load_character, _save_character
        cid = request.match_info["cid"]
        ts = request.match_info["ts"]
        char = _load_character(cid)
        if not char:
            return web.json_response({"error": "Karakter bulunamadı"}, status=404)
        char["active_lora_version"] = ts
        _save_character(cid, char)
        return web.json_response({"ok": True, "active": ts})

    # Sprint 57 A19: Delete LoRA version
    @_server.routes.delete("/fikoyt_mvd/character/{cid}/lora_versions/{ts}")
    async def _delete_lora_version(request):
        from ._legacy import _load_character, _save_character
        cid = request.match_info["cid"]
        ts = request.match_info["ts"]
        char = _load_character(cid)
        if not char:
            return web.json_response({"error": "Karakter bulunamadı"}, status=404)
        if char.get("active_lora_version") == ts:
            return web.json_response({"error": "Aktif versiyon silinemez. Önce başka bir versiyon aktive edin."}, status=400)
        versions = char.get("lora_versions") or []
        new_versions = [v for v in versions if v.get("ts") != ts]
        if len(new_versions) == len(versions):
            return web.json_response({"error": "Versiyon bulunamadı"}, status=404)
        char["lora_versions"] = new_versions
        _save_character(cid, char)
        # Disk temizliği — versiyon klasörünü sil
        try:
            char_root = os.path.join(folder_paths.get_output_directory(),
                                      "fikoyt_mvd", "_characters", cid)
            vdir = os.path.join(char_root, "_lora_versions", ts)
            if os.path.isdir(vdir):
                import shutil as _sh
                _sh.rmtree(vdir, ignore_errors=True)
        except Exception as _e:
            print(f"[LoRA] version dir cleanup fail: {_e}")
        return web.json_response({"ok": True, "removed": ts, "remaining": len(new_versions)})

    # Sprint 57 A19: Patch LoRA version notes/label
    @_server.routes.patch("/fikoyt_mvd/character/{cid}/lora_versions/{ts}")
    async def _patch_lora_version(request):
        from ._legacy import _load_character, _save_character
        cid = request.match_info["cid"]
        ts = request.match_info["ts"]
        try:
            body = await request.json()
        except Exception:
            body = {}
        char = _load_character(cid)
        if not char:
            return web.json_response({"error": "Karakter bulunamadı"}, status=404)
        versions = char.get("lora_versions") or []
        target = next((v for v in versions if v.get("ts") == ts), None)
        if not target:
            return web.json_response({"error": "Versiyon bulunamadı"}, status=404)
        if "notes" in body: target["notes"] = str(body["notes"])
        if "id" in body: target["id"] = str(body["id"])
        if "default_strength" in body:
            try: target["default_strength"] = max(0.0, min(float(body["default_strength"]), 2.0))
            except Exception: pass
        _save_character(cid, char)
        return web.json_response({"ok": True, "version": target})

    # Sprint 57 A19: A/B compare 2 LoRA versions (renders both with same seed)
    @_server.routes.post("/fikoyt_mvd/character/{cid}/lora_versions/compare")
    async def _compare_lora_versions(request):
        from ._legacy import _load_character
        cid = request.match_info["cid"]
        try:
            body = await request.json()
        except Exception as e:
            return web.json_response({"error": f"JSON: {e}"}, status=400)
        char = _load_character(cid)
        if not char:
            return web.json_response({"error": "Karakter bulunamadı"}, status=404)
        v1_ts = (body.get("v1") or "").strip()
        v2_ts = (body.get("v2") or "").strip()
        if not (v1_ts and v2_ts):
            return web.json_response({"error": "v1 ve v2 zorunlu"}, status=400)
        prompt = (body.get("prompt") or "").strip()
        if not prompt:
            return web.json_response({"error": "prompt zorunlu"}, status=400)
        # Bilgilendirici response — frontend iki ayrı /quick/image çağrısı yapar (v1/v2 swap)
        # Backend her seferinde character.active_lora_version'u geçici olarak değiştirip 2 job kuyrukla
        return web.json_response({
            "ok": True,
            "instructions": "Frontend iki ayrı /quick/image isteği at: her birinden önce activate endpoint'i çağır",
            "v1": v1_ts, "v2": v2_ts,
            "seed": int(body.get("seed") or 0),
            "prompt": prompt,
        })

    # ----- Outfit referans görseli -----
    @_server.routes.post("/fikoyt_mvd/character/{cid}/outfits/{oid}/reference")
    async def _upload_outfit_ref(request):
        from ._legacy import _load_character, _save_character, _char_path, _view_url, _rel_to_output
        cid = request.match_info["cid"]
        oid = request.match_info["oid"]
        char = _load_character(cid)
        if not char:
            return web.json_response({"error": "Karakter bulunamadı"}, status=404)
        outfits = char.get("outfits") or []
        outfit = next((o for o in outfits if o.get("id") == oid), None)
        if not outfit:
            return web.json_response({"error": "Outfit bulunamadı"}, status=404)
        try:
            reader = await request.multipart()
            file_bytes = None; file_ext = "png"
            async for field in reader:
                if field.name == "image":
                    file_bytes = await field.read()
                    fn = (field.filename or "outfit.png").lower()
                    for e in (".png", ".jpg", ".jpeg", ".webp"):
                        if fn.endswith(e):
                            file_ext = e[1:]; break
            if not file_bytes:
                return web.json_response({"error": "image eksik"}, status=400)
            outfit_dir = os.path.join(_char_path(cid), "outfits")
            os.makedirs(outfit_dir, exist_ok=True)
            ref_path = os.path.join(outfit_dir, f"{oid}_ref.{file_ext}")
            with open(ref_path, "wb") as f:
                f.write(file_bytes)
            outfit["reference_image"] = ref_path
            outfit["reference_rel"] = _rel_to_output(ref_path)
            _save_character(cid, char)
            return web.json_response({"ok": True,
                                       "reference_url": _view_url(ref_path)})
        except Exception as e:
            import traceback; traceback.print_exc()
            return web.json_response({"error": str(e)}, status=500)

    # ----- Expression library -----
    @_server.routes.get("/fikoyt_mvd/character/{cid}/expressions")
    async def _list_expressions(request):
        from ._legacy import _load_character
        cid = request.match_info["cid"]
        char = _load_character(cid)
        if not char:
            return web.json_response({"error": "Karakter bulunamadı"}, status=404)
        return web.json_response({"expressions": char.get("expressions") or []})

    @_server.routes.post("/fikoyt_mvd/character/{cid}/expressions")
    async def _upsert_expression(request):
        """Body: {id?, label, prompt_modifier, pose_ref_id?}"""
        from ._legacy import _load_character, _save_character
        import uuid
        cid = request.match_info["cid"]
        char = _load_character(cid)
        if not char:
            return web.json_response({"error": "Karakter bulunamadı"}, status=404)
        try:
            body = await request.json()
            eid = body.get("id") or uuid.uuid4().hex[:10]
            entry = {
                "id": eid,
                "label": (body.get("label") or "").strip()[:40],
                "prompt_modifier": (body.get("prompt_modifier") or "").strip()[:200],
                "pose_ref_id": body.get("pose_ref_id"),
                "updated_at": datetime.datetime.now().isoformat(),
            }
            exprs = char.setdefault("expressions", [])
            existing = next((x for x in exprs if x.get("id") == eid), None)
            if existing:
                existing.update(entry)
            else:
                exprs.append(entry)
            _save_character(cid, char)
            return web.json_response({"ok": True, "expression": entry})
        except Exception as e:
            return web.json_response({"error": str(e)}, status=500)

    @_server.routes.delete("/fikoyt_mvd/character/{cid}/expressions/{eid}")
    async def _del_expression(request):
        from ._legacy import _load_character, _save_character
        cid = request.match_info["cid"]
        eid = request.match_info["eid"]
        char = _load_character(cid)
        if not char:
            return web.json_response({"error": "Karakter bulunamadı"}, status=404)
        char["expressions"] = [x for x in (char.get("expressions") or [])
                               if x.get("id") != eid]
        _save_character(cid, char)
        return web.json_response({"ok": True})

    # ----- Character style transfer (IPAdapter Plus Face) -----
    @_server.routes.post("/fikoyt_mvd/character/{cid}/style_transfer")
    async def _style_transfer(request):
        """Body: {source_character_id, strength?: float (default 0.4)}.
        Source karakterin ana referansı target karakterin identity'sine
        IPAdapter Plus Face üzerinden bindirilir. Sadece plan/setup döner;
        gerçek render Visual Studio'da segment üretimi sırasında uygulanır."""
        from ._legacy import _load_character, _save_character
        cid = request.match_info["cid"]
        target = _load_character(cid)
        if not target:
            return web.json_response({"error": "Target karakter bulunamadı"}, status=404)
        try:
            body = await request.json()
            sid = (body.get("source_character_id") or "").strip()
            source = _load_character(sid)
            if not source:
                return web.json_response({"error": "Source karakter bulunamadı"}, status=404)
            strength = float(body.get("strength", 0.4))
            if not (0.0 <= strength <= 1.5):
                return web.json_response({"error": "strength 0-1.5 arası"}, status=400)
            target["style_transfer"] = {
                "source_id": sid,
                "source_name": source.get("name", sid),
                "source_image_path": source.get("image_path"),
                "strength": strength,
                "enabled": True,
                "updated_at": datetime.datetime.now().isoformat(),
            }
            _save_character(cid, target)
            return web.json_response({"ok": True,
                                       "style_transfer": target["style_transfer"]})
        except Exception as e:
            return web.json_response({"error": str(e)}, status=500)

    # ----- ReActor face swap status -----
    @_server.routes.get("/fikoyt_mvd/check_reactor")
    async def _check_reactor(request):
        from nodes import NODE_CLASS_MAPPINGS as _NCM
        ok = bool(_NCM.get("ReActorFaceSwap") or _NCM.get("ReActorFaceSwapOpt"))
        return web.json_response({
            "ready": ok,
            "node_installed": ok,
            "install_cmd": "ComfyUI Manager > 'ComfyUI-ReActor-Node'",
            "license_warning": (
                "ReActor lisansı gri alan — kişisel/araştırma kullanım için. "
                "Ticari kullanımda hukuki danışmanlık önerilir.")
        })

    # =========================================================================
    # Sprint 5 (W16) — Lightning preview, Schnell auto-fallback, batch variants
    # =========================================================================
    @_server.routes.get("/fikoyt_mvd/sys/vram_info")
    async def _vram_info(request):
        """VRAM info — Schnell auto-fallback için kullanılır."""
        info = {"total_mb": 0, "free_mb": 0, "device": "unknown"}
        try:
            import torch
            if torch.cuda.is_available():
                info["device"] = torch.cuda.get_device_name(0)
                total = torch.cuda.get_device_properties(0).total_memory
                free, _t = torch.cuda.mem_get_info()
                info["total_mb"] = int(total / 1_048_576)
                info["free_mb"] = int(free / 1_048_576)
                info["should_use_schnell"] = info["total_mb"] < 14_000
        except Exception as e:
            info["error"] = str(e)
        return web.json_response(info)

    @_server.routes.post("/fikoyt_mvd/preview_lightning")
    async def _preview_lightning(request):
        """SDXL Lightning 4-step preview render — placeholder.
        Tam implementasyon Sprint 5'in canlandırılması sırasında ImageGen
        node'unda bir 'preview_mode' bayrağı + LoraLoaderModelOnly Lightning
        LoRA enjeksiyonuyla yapılır. Burada UI hook'u + endpoint kontrolü."""
        try:
            data = await request.json()
        except Exception:
            data = {}
        seg = int(data.get("seg_index", -1))
        # Şu an: sadece kullanıcıya hint döner; ImageGen entegrasyonu için
        # node tarafı _try_apply_lightning helper'ı ekleyecektir (Sprint 5).
        return web.json_response({
            "ok": True,
            "seg_index": seg,
            "hint": ("Lightning preview için workflow'a ⚡ MVD · Lightning Preview "
                     "node'unu ekle ve KSampler steps=4, cfg=1.0, "
                     "sampler=euler, scheduler=sgm_uniform ayarla."),
            "lora_recommended": "sdxl_lightning_4step_lora.safetensors",
        })

    @_server.routes.post("/fikoyt_mvd/generate_variants")
    async def _generate_variants(request):
        """Body: {seg_index, count? (default 4), base_seed?}.
        Aynı prompt'la N seed'li varyant render planı döner. Tam orchestration
        SegmentImageGen node'unda batch_size=N olarak çalıştırılarak yapılır;
        burada endpoint UI tetikleyicisi olarak iş görür."""
        try:
            data = await request.json()
        except Exception:
            data = {}
        seg = int(data.get("seg_index", 0))
        count = max(1, min(int(data.get("count", 4)), 9))
        import random as _r
        base_seed = int(data.get("base_seed") or (_r.randint(1, 0xFFFFFFFF)))
        seeds = [base_seed + i * 1009 for i in range(count)]
        return web.json_response({
            "ok": True,
            "seg_index": seg,
            "count": count,
            "seeds": seeds,
            "hint": ("Variants render için SegmentImageGen.batch_size=N ile çalıştır, "
                     "veya UI tarafından N kez render tetikle (sequential)."),
        })

    # ----- Unified gallery -----
    @_server.routes.get("/fikoyt_mvd/project/{name}/gallery")
    async def _project_gallery(request):
        """Proje altındaki tüm üretilmiş görselleri unified listele."""
        from ._legacy import PROJECT_BASE_DIR, _view_url, _rel_to_output
        name = request.match_info["name"]
        pdir = _legacy.safe_project_dir(name)
        if not os.path.isdir(pdir):
            return web.json_response({"error": "proje bulunamadı"}, status=404)
        items = []
        # images/ + variants/ + albumcover/ vb.
        for sub in ("images", "variants", "_variants", "covers", "refs"):
            sdir = os.path.join(pdir, sub)
            if not os.path.isdir(sdir):
                continue
            for fname in sorted(os.listdir(sdir)):
                fp = os.path.join(sdir, fname)
                if not os.path.isfile(fp):
                    continue
                if not fname.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
                    continue
                try:
                    st = os.stat(fp)
                    items.append({
                        "name": fname,
                        "section": sub,
                        "url": _view_url(fp),
                        "rel": _rel_to_output(fp),
                        "size_kb": round(st.st_size / 1024, 1),
                        "mtime": st.st_mtime,
                    })
                except Exception:
                    pass
        # Favori listesi — proje meta'sında saklanır
        meta_path = os.path.join(pdir, "project.json")
        favorites = []
        if os.path.isfile(meta_path):
            try:
                import json as _json
                with open(meta_path, encoding="utf-8") as f:
                    favorites = _json.load(f).get("gallery_favorites", [])
            except Exception:
                pass
        return web.json_response({
            "items": items,
            "favorites": favorites,
            "total": len(items),
        })

    @_server.routes.post("/fikoyt_mvd/project/{name}/gallery/favorite")
    async def _toggle_favorite(request):
        """Body: {rel_path: str}"""
        from ._legacy import PROJECT_BASE_DIR
        name = request.match_info["name"]
        pdir = _legacy.safe_project_dir(name)
        meta_path = os.path.join(pdir, "project.json")
        if not os.path.isfile(meta_path):
            return web.json_response({"error": "proje meta yok"}, status=404)
        try:
            body = await request.json()
            rel = (body.get("rel_path") or "").strip()
            if not rel:
                return web.json_response({"error": "rel_path eksik"}, status=400)
            import json as _json
            with open(meta_path, encoding="utf-8") as f:
                meta = _json.load(f)
            favs = meta.setdefault("gallery_favorites", [])
            if rel in favs:
                favs.remove(rel)
                action = "removed"
            else:
                favs.append(rel)
                action = "added"
            with open(meta_path, "w", encoding="utf-8") as f:
                _json.dump(meta, f, ensure_ascii=False, indent=2)
            return web.json_response({"ok": True, "action": action, "favorites": favs})
        except Exception as e:
            return web.json_response({"error": str(e)}, status=500)

    # =========================================================================
    # Sprint 21 — Quick wins: karakter klonla + auto backup
    # =========================================================================
    @_server.routes.post("/fikoyt_mvd/character/{cid}/clone")
    async def _clone_character(request):
        """Karakteri yeni bir ID ile klonla. Body: {new_id?, new_name?}.
        Tüm dosyalar (reference, refs, poses, outfits, _lora_versions) kopyalanır."""
        from ._legacy import (_load_character, _save_character, _char_path,
                              _slug, _chars_dir)
        cid = request.match_info["cid"]
        src_char = _load_character(cid)
        if not src_char:
            return web.json_response({"error": "Karakter bulunamadı"}, status=404)
        try:
            body = await request.json()
        except Exception:
            body = {}
        new_id = _slug(body.get("new_id") or f"{cid}_copy", 40)
        new_name = (body.get("new_name") or
                    f"{src_char.get('name', cid)} (kopya)").strip()
        # Çakışma kontrolü
        if _load_character(new_id):
            return web.json_response({"error": f"'{new_id}' zaten var"}, status=409)

        src_dir = _char_path(cid)
        dst_dir = os.path.join(_chars_dir(), new_id)
        try:
            shutil.copytree(src_dir, dst_dir)
        except Exception as e:
            return web.json_response({"error": f"Kopya hata: {e}"}, status=500)

        # Yeni meta'yı oku ve düzenle
        meta_path = os.path.join(dst_dir, "meta.json")
        try:
            with open(meta_path, encoding="utf-8") as f:
                m = json.load(f)
            m["id"] = new_id
            m["name"] = new_name
            m["cloned_from"] = cid
            m["cloned_at"] = datetime.datetime.now().isoformat()
            # image_path düzelt (referansları yeni klasöre yönlendir)
            if m.get("image_path"):
                old = m["image_path"]
                fname = os.path.basename(old)
                m["image_path"] = os.path.join(dst_dir, fname)
            # outfits[*].reference_image yolu düzelt
            for o in (m.get("outfits") or []):
                if o.get("reference_image"):
                    fname = os.path.basename(o["reference_image"])
                    o["reference_image"] = os.path.join(dst_dir, "outfits", fname)
            # pose_library yolları
            for p in (m.get("pose_library") or []):
                for k in ("source_path", "pose_path"):
                    if p.get(k):
                        fname = os.path.basename(p[k])
                        p[k] = os.path.join(dst_dir, "poses", fname)
            with open(meta_path, "w", encoding="utf-8") as f:
                json.dump(m, f, ensure_ascii=False, indent=2)
        except Exception as e:
            shutil.rmtree(dst_dir, ignore_errors=True)
            return web.json_response({"error": f"Meta update: {e}"}, status=500)

        return web.json_response({"ok": True, "new_id": new_id,
                                   "new_name": new_name,
                                   "character": _load_character(new_id)})

    @_server.routes.post("/fikoyt_mvd/characters/backup")
    async def _backup_characters(request):
        """_characters/ klasörünü zip'le _backups/'a koy."""
        from ._legacy import _chars_dir, PROJECT_BASE_DIR
        import zipfile
        src = _chars_dir()
        if not os.path.isdir(src):
            return web.json_response({"error": "_characters klasörü yok"}, status=404)
        backup_dir = os.path.join(PROJECT_BASE_DIR, "_backups")
        os.makedirs(backup_dir, exist_ok=True)
        ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
        zip_path = os.path.join(backup_dir, f"characters_{ts}.zip")
        try:
            with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
                for root, _, files in os.walk(src):
                    for f in files:
                        fp = os.path.join(root, f)
                        rel = os.path.relpath(fp, os.path.dirname(src))
                        zf.write(fp, rel)
            size_mb = round(os.path.getsize(zip_path) / 1_048_576, 1)
            return web.json_response({
                "ok": True, "zip_path": zip_path, "size_mb": size_mb,
                "timestamp": ts,
            })
        except Exception as e:
            if os.path.isfile(zip_path):
                try: os.remove(zip_path)
                except Exception: pass
            return web.json_response({"error": str(e)}, status=500)

    @_server.routes.get("/fikoyt_mvd/characters/backups")
    async def _list_backups(request):
        from ._legacy import PROJECT_BASE_DIR
        backup_dir = os.path.join(PROJECT_BASE_DIR, "_backups")
        if not os.path.isdir(backup_dir):
            return web.json_response({"backups": []})
        backups = []
        for f in sorted(os.listdir(backup_dir), reverse=True):
            if f.startswith("characters_") and f.endswith(".zip"):
                fp = os.path.join(backup_dir, f)
                backups.append({
                    "filename": f, "path": fp,
                    "size_mb": round(os.path.getsize(fp) / 1_048_576, 1),
                    "mtime": os.path.getmtime(fp),
                })
        return web.json_response({"backups": backups, "dir": backup_dir})

    # =========================================================================
    # Sprint 20 — PuLID v2 (Flux II) install
    # =========================================================================
    _pulid_v2_install_state = {"in_progress": False, "log": "", "done": False, "error": ""}
    PULID_V2_URLS = [
        # Birden fazla aday URL — birinin çalışacağı en muhtemel
        ("pulid_flux_v0.9.2.safetensors",
         "https://huggingface.co/guozinan/PuLID/resolve/main/pulid_flux_v0.9.2.safetensors"),
        ("pulid_flux_II.safetensors",
         "https://huggingface.co/guozinan/PuLID/resolve/main/pulid_flux_II.safetensors"),
        ("pulid_flux_v1.0.safetensors",
         "https://huggingface.co/guozinan/PuLID/resolve/main/pulid_flux_v1.0.safetensors"),
    ]

    @_server.routes.get("/fikoyt_mvd/check_pulid_v2")
    async def _check_pulid_v2(request):
        """PuLID v2 model dosyası mevcut mu?"""
        pulid_dir = os.path.join(folder_paths.models_dir, "pulid")
        found = []
        if os.path.isdir(pulid_dir):
            for fn in os.listdir(pulid_dir):
                fp = os.path.join(pulid_dir, fn)
                if (fn.endswith(".safetensors") and "v0.9.2" in fn.lower() or
                        "_ii" in fn.lower() or "v1." in fn.lower()):
                    if os.path.getsize(fp) > 100_000_000:
                        found.append({"filename": fn, "size_mb":
                                       round(os.path.getsize(fp) / 1_048_576, 1)})
        return web.json_response({
            "ready": len(found) > 0,
            "files": found,
            "v1_fallback": os.path.isfile(
                os.path.join(pulid_dir, "pulid_flux_v0.9.1.safetensors")),
            "install_hint": "POST /fikoyt_mvd/install_pulid_v2 → otomatik dene",
        })

    @_server.routes.post("/fikoyt_mvd/install_pulid_v2")
    async def _install_pulid_v2(request):
        with _install_lock:
            if _pulid_v2_install_state["in_progress"]:
                return web.json_response({"error": "Zaten yükleniyor"}, status=409)
            _pulid_v2_install_state.update({
                "in_progress": True, "log": "", "done": False, "error": "",
                "current_file": "", "current_pct": 0,
                "downloaded_mb": 0, "total_mb": 0,
            })

        async def _bg():
            try:
                pulid_dir = os.path.join(folder_paths.models_dir, "pulid")
                os.makedirs(pulid_dir, exist_ok=True)
                _pulid_v2_install_state["log"] += "PuLID v2 model adayları sırayla denenecek:\n"

                success = False
                for fname, url in PULID_V2_URLS:
                    dest = os.path.join(pulid_dir, fname)
                    if os.path.isfile(dest) and os.path.getsize(dest) > 100_000_000:
                        _pulid_v2_install_state["log"] += f"[skip] {fname} mevcut\n"
                        success = True
                        break
                    _pulid_v2_install_state["log"] += f"\n[dl] {fname}\n"
                    try:
                        # Sprint 36: progress'li indirme (state'e pct+MB yaz)
                        def _dl(_u=url, _d=dest, _f=fname):
                            import urllib.request
                            _pulid_v2_install_state["current_file"] = _f
                            _pulid_v2_install_state["current_pct"] = 0
                            last_pct = -10
                            def _hook(blocks, block_size, total):
                                nonlocal last_pct
                                if total > 0:
                                    downloaded = blocks * block_size
                                    pct = min(100, int(downloaded * 100 / total))
                                    _pulid_v2_install_state["current_pct"] = pct
                                    _pulid_v2_install_state["downloaded_mb"] = round(downloaded / 1_048_576, 1)
                                    _pulid_v2_install_state["total_mb"] = round(total / 1_048_576, 1)
                                    if pct >= last_pct + 10:
                                        _pulid_v2_install_state["log"] += (
                                            f"     {pct}% · "
                                            f"{_pulid_v2_install_state['downloaded_mb']:.0f}/"
                                            f"{_pulid_v2_install_state['total_mb']:.0f} MB\n")
                                        last_pct = pct
                            urllib.request.urlretrieve(_u, _d, reporthook=_hook)
                        await asyncio.get_event_loop().run_in_executor(None, _dl)
                        if os.path.isfile(dest) and os.path.getsize(dest) > 100_000_000:
                            _pulid_v2_install_state["log"] += f"  ✓ {fname} indirildi\n"
                            success = True
                            break
                        else:
                            _pulid_v2_install_state["log"] += f"  ⚠ dosya boyutu yetersiz, atlanıyor\n"
                            if os.path.isfile(dest):
                                try: os.remove(dest)
                                except Exception: pass
                    except Exception as e:
                        _pulid_v2_install_state["log"] += f"  ❌ {e}\n"
                        if os.path.isfile(dest):
                            try: os.remove(dest)
                            except Exception: pass

                if success:
                    _pulid_v2_install_state["log"] += "\n✓ PuLID v2 hazır. ComfyUI RESTART!\n"
                else:
                    _pulid_v2_install_state["error"] = "Tüm URL adayları başarısız"
                    _pulid_v2_install_state["log"] += (
                        "\n❌ PuLID v2 indirilemedi. Manuel kurulum: "
                        "https://huggingface.co/guozinan/PuLID/tree/main → "
                        "en yeni .safetensors → models/pulid/\n")
            except Exception as e:
                _pulid_v2_install_state["error"] = str(e)
                _pulid_v2_install_state["log"] += f"\n❌ HATA: {e}\n"
            finally:
                _pulid_v2_install_state["done"] = True
                _pulid_v2_install_state["in_progress"] = False

        asyncio.create_task(_bg())
        return web.json_response({"ok": True})

    @_server.routes.get("/fikoyt_mvd/install_pulid_v2_status")
    async def _install_pulid_v2_status(request):
        return web.json_response(_pulid_v2_install_state)

    # =========================================================================
    # Sprint 17 — Auto Mode tam orkestrasyon: batch video render
    # =========================================================================
    _batch_videos_state = {
        "in_progress": False, "project": "",
        "total": 0, "completed": 0, "failed": [],
        "current_segment": -1, "current_char": "",
        "log": [], "done": False, "prompt_ids": [],
    }

    @_server.routes.post("/fikoyt_mvd/project/{name}/batch_videos")
    async def _project_batch_videos(request):
        """Proje segmentlerini sırayla Wan 2.2-S2V ile render et.
        Her segment'in audio kesimini alıp karakterine bağlar.
        Body: {
          backend?: 'wan_s2v' | 'humo' | 'infinitetalk' (default wan_s2v),
          preset?, prompt_template?, seed_base?, force?,
          segment_indices?: [int]  // sadece bunlar
        }
        """
        from ._legacy import PROJECT_BASE_DIR, _load_character, _slug
        try:
            from wan.s2v_helpers import (s2v_status_dict, WAN_S2V_RES_PRESETS,
                                          WAN_S2V_DEFAULT_CONFIG)
        except ImportError:
            return web.json_response(
                {"error": "wan subsystem yüklü değil"}, status=500)

        name = request.match_info["name"]
        pdir = _legacy.safe_project_dir(name)
        meta_path = os.path.join(pdir, "project.json")
        if not os.path.isfile(meta_path):
            return web.json_response({"error": "proje bulunamadı"}, status=404)

        with _install_lock:
            if _batch_videos_state["in_progress"]:
                return web.json_response(
                    {"error": "Batch video render zaten çalışıyor"}, status=409)

        try:
            body = await request.json()
        except Exception:
            body = {}
        backend = body.get("backend", "wan_s2v")
        if backend not in ("wan_s2v", "humo", "infinitetalk"):
            return web.json_response({"error": f"Bilinmeyen backend: {backend}"}, status=400)
        preset_id = body.get("preset", "1080x1920_portrait_fullhd")
        seed_base = body.get("seed_base", 0)
        force = bool(body.get("force", False))
        seg_filter = body.get("segment_indices")
        prompt_template = body.get("prompt_template") or (
            "the character is performing the song, expressive face, "
            "dynamic motion, music video aesthetic, cinematic")

        # Proje meta + segments
        with open(meta_path, encoding="utf-8") as f:
            meta = json.load(f)
        segments = meta.get("segments") or []
        if not segments:
            return web.json_response({"error": "segment yok"}, status=400)

        # Filter
        if isinstance(seg_filter, list):
            segments_to_render = [(i, s) for i, s in enumerate(segments) if i in seg_filter]
        else:
            segments_to_render = list(enumerate(segments))

        # Audio dosyası
        audio_path, _, _ = _resolve_project_audio(name)
        if not audio_path:
            return web.json_response({"error": "proje audio yok"}, status=400)

        # Wan S2V sistem hazır mı?
        s2v_st = s2v_status_dict()
        if backend == "wan_s2v" and not s2v_st["ready"]:
            return web.json_response({
                "error": "Wan 2.2-S2V hazır değil. Önce /wan/s2v/install çalıştır.",
                "status": s2v_st,
            }, status=400)

        # State init
        _batch_videos_state.update({
            "in_progress": True, "project": name,
            "total": len(segments_to_render), "completed": 0, "failed": [],
            "current_segment": -1, "current_char": "",
            "log": [f"[start] {len(segments_to_render)} segment, backend={backend}"],
            "done": False, "prompt_ids": [],
            "started_at": datetime.datetime.now().isoformat(),
        })

        # Audio kesimi yardımcısı (segment.start_sec → start+duration)
        def _slice_audio(src_path, start_sec, dur_sec, out_path):
            """ffmpeg ile audio segment kesimi."""
            from ._legacy import _ffmpeg_bin
            ffmpeg = _ffmpeg_bin()
            if not ffmpeg:
                raise RuntimeError("ffmpeg yok")
            import subprocess
            r = subprocess.run(
                [ffmpeg, "-y", "-i", src_path,
                 "-ss", str(start_sec), "-t", str(dur_sec),
                 "-c", "copy", out_path],
                capture_output=True, text=True, timeout=60)
            if r.returncode != 0:
                # re-encode fallback
                r = subprocess.run(
                    [ffmpeg, "-y", "-i", src_path,
                     "-ss", str(start_sec), "-t", str(dur_sec),
                     "-acodec", "libmp3lame", "-b:a", "192k", out_path],
                    capture_output=True, text=True, timeout=120)
                if r.returncode != 0:
                    raise RuntimeError(f"ffmpeg slice exit {r.returncode}: {r.stderr[-200:]}")

        async def _orchestrate():
            try:
                import random as _r, time as _t
                audio_seg_dir = os.path.join(pdir, "audio_segments")
                os.makedirs(audio_seg_dir, exist_ok=True)

                # Wan S2V model dosyaları
                if backend == "wan_s2v":
                    diff = s2v_st["diffusion_models"][0]["filename"]
                    umt5 = s2v_st["text_encoders"][0]["filename"]
                    vae_f = s2v_st["vaes"][0]["filename"]
                    audio_enc = s2v_st["audio_encoders"][0]["filename"]
                    w, h, default_frames, _ = WAN_S2V_RES_PRESETS.get(
                        preset_id,
                        (1080, 1920, 121, "default"))

                for idx, (seg_idx, seg) in enumerate(segments_to_render):
                    _batch_videos_state["current_segment"] = seg_idx
                    start_sec = float(seg.get("start_sec", 0))
                    end_sec = float(seg.get("end_sec", start_sec + 5))
                    dur = max(2.0, min(end_sec - start_sec, 8.0))  # Wan max ~8s
                    char_id = (seg.get("character_id") or "").strip()
                    _batch_videos_state["current_char"] = char_id
                    _batch_videos_state["log"].append(
                        f"\n[{idx+1}/{len(segments_to_render)}] segment={seg_idx} "
                        f"char={char_id or '?'} {start_sec:.1f}s-{end_sec:.1f}s")

                    if not char_id:
                        _batch_videos_state["log"].append("  ⚠ karakter atanmamış, atlandı")
                        _batch_videos_state["failed"].append(
                            {"seg_idx": seg_idx, "reason": "no character"})
                        continue
                    char = _load_character(char_id)
                    if not char or not char.get("image_path"):
                        _batch_videos_state["log"].append(
                            f"  ⚠ karakter referansı yok ({char_id})")
                        _batch_videos_state["failed"].append(
                            {"seg_idx": seg_idx, "reason": "no character image"})
                        continue

                    # Audio segment kes
                    seg_audio = os.path.join(
                        audio_seg_dir, f"seg_{seg_idx:02d}_{int(start_sec)}.mp3")
                    if not os.path.isfile(seg_audio) or force:
                        try:
                            await asyncio.get_event_loop().run_in_executor(
                                None, _slice_audio, audio_path, start_sec, dur, seg_audio)
                            _batch_videos_state["log"].append(f"  ✓ audio kes: {dur:.1f}s")
                        except Exception as e:
                            _batch_videos_state["log"].append(f"  ❌ audio slice: {e}")
                            _batch_videos_state["failed"].append(
                                {"seg_idx": seg_idx, "reason": f"audio slice: {e}"})
                            continue

                    # Wan S2V workflow build + queue
                    if backend == "wan_s2v":
                        try:
                            inp = folder_paths.get_input_directory()
                            inp_sub = os.path.join(inp, "fikoyt_mvd_wan_s2v")
                            os.makedirs(inp_sub, exist_ok=True)
                            ts = int(_t.time() * 1000) + idx
                            ref_path = char["image_path"]
                            ref_dest = os.path.join(
                                inp_sub, f"ref_{char_id}_s{seg_idx}_{ts}{os.path.splitext(ref_path)[1]}")
                            audio_dest = os.path.join(
                                inp_sub, f"aud_s{seg_idx}_{ts}.mp3")
                            shutil.copy2(ref_path, ref_dest)
                            shutil.copy2(seg_audio, audio_dest)
                            ref_rel = f"fikoyt_mvd_wan_s2v/{os.path.basename(ref_dest)}"
                            aud_rel = f"fikoyt_mvd_wan_s2v/{os.path.basename(audio_dest)}"

                            # Frames hesabı: süre × fps, max default_frames
                            fps = int(WAN_S2V_DEFAULT_CONFIG["fps"])
                            frames = min(default_frames, int(dur * fps))
                            frames = max(16, (frames // 4) * 4)

                            seed = int(seed_base or _r.randint(1, 0xFFFFFFFF)) + idx * 7

                            # Workflow template
                            wf_tpl = os.path.join(
                                os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
                                "workflow", "wan_s2v_segment.json")
                            with open(wf_tpl, encoding="utf-8") as f:
                                wf_str = f.read()
                            replacements = {
                                "@@MODEL@@": diff, "@@UMT5@@": umt5,
                                "@@WAN_VAE@@": vae_f, "@@AUDIO_ENC@@": audio_enc,
                                "@@IMAGE@@": ref_rel, "@@AUDIO@@": aud_rel,
                                "@@POSITIVE@@": prompt_template.replace('"', '\\"'),
                                "@@NEGATIVE@@": "blurry, deformed, watermark, low quality",
                                "@@WIDTH@@": str(w), "@@HEIGHT@@": str(h),
                                "@@FRAMES@@": str(frames),
                                "@@FPS@@": str(fps),
                                "@@STEPS@@": str(WAN_S2V_DEFAULT_CONFIG["steps"]),
                                "@@CFG@@": str(WAN_S2V_DEFAULT_CONFIG["guidance_scale"]),
                                "@@SEED@@": str(seed),
                                "@@FILENAME_PREFIX@@": (
                                    f"fikoyt_mvd/{_slug(name, 60)}/videos/"
                                    f"seg_{seg_idx:02d}_{char_id}"),
                            }
                            for k, v in replacements.items():
                                wf_str = wf_str.replace(k, v)
                            wf = json.loads(wf_str)

                            # Sprint 27: validated_put
                            res = await _validated_put(
                                _server, wf, client_id="fmvd_batch_videos")
                            if not res["ok"]:
                                _batch_videos_state["log"].append(
                                    f"  ❌ validation: {res['error']}")
                                _batch_videos_state["failed"].append(
                                    {"seg_idx": seg_idx,
                                     "reason": f"validation: {res['error']}",
                                     "node_errors": res.get("node_errors")})
                                continue
                            pid = res["prompt_id"]
                            _batch_videos_state["prompt_ids"].append({
                                "seg_idx": seg_idx, "char_id": char_id,
                                "prompt_id": pid, "frames": frames, "seed": seed,
                            })
                            _batch_videos_state["log"].append(
                                f"  ✓ kuyruğa: pid={pid[:8]} frames={frames}")
                            _batch_videos_state["completed"] += 1
                        except Exception as e:
                            _batch_videos_state["log"].append(f"  ❌ queue: {e}")
                            _batch_videos_state["failed"].append(
                                {"seg_idx": seg_idx, "reason": str(e)})
                    else:
                        _batch_videos_state["log"].append(
                            f"  ⚠ backend={backend} henüz desteklenmiyor (Sprint 17 MVP: wan_s2v)")

                _batch_videos_state["log"].append(
                    f"\n[done] {_batch_videos_state['completed']}/"
                    f"{len(segments_to_render)} kuyruğa eklendi, "
                    f"{len(_batch_videos_state['failed'])} hata")
            except Exception as e:
                _batch_videos_state["log"].append(f"\n❌ orchestrate: {e}")
            finally:
                _batch_videos_state["done"] = True
                _batch_videos_state["in_progress"] = False
                _batch_videos_state["current_segment"] = -1
                _batch_videos_state["current_char"] = ""

        asyncio.create_task(_orchestrate())
        return web.json_response({
            "ok": True, "backend": backend, "total": len(segments_to_render),
            "audio_path": audio_path,
        })

    @_server.routes.get("/fikoyt_mvd/project/{name}/batch_videos_status")
    async def _batch_videos_status(request):
        return web.json_response(_batch_videos_state)

    # =========================================================================
    # Sprint 16 — Flux Kontext (multi-image karakter sürekliliği)
    # =========================================================================
    @_server.routes.get("/fikoyt_mvd/flux_kontext/status")
    async def _kontext_status(request):
        """Flux Kontext kurulum durumu."""
        from nodes import NODE_CLASS_MAPPINGS as _NCM
        nodes_ok = {
            "ReferenceLatent": bool(_NCM.get("ReferenceLatent")),
            "FluxKontextImageRescale": bool(_NCM.get("FluxKontextImageRescale")),
            "VAEEncode": bool(_NCM.get("VAEEncode")),
            "FluxGuidance": bool(_NCM.get("FluxGuidance")),
        }
        # Model dosya tespiti
        kontext_models = []
        try:
            import re
            for folder in ("diffusion_models", "unet", "checkpoints"):
                for f in folder_paths.get_filename_list(folder) or []:
                    if re.search(r"kontext", f, re.IGNORECASE):
                        kontext_models.append({"folder": folder, "filename": f})
        except Exception:
            pass
        return web.json_response({
            "nodes_ready": all(nodes_ok.values()),
            "nodes": nodes_ok,
            "kontext_models": kontext_models,
            "ready": all(nodes_ok.values()) and len(kontext_models) > 0,
            "model_download_hint": (
                "Flux Kontext Dev (12B): "
                "https://huggingface.co/black-forest-labs/FLUX.1-Kontext-dev — "
                "models/diffusion_models/ altına. Aynı T5/CLIP-L/VAE Flux Dev ile paylaşılır."),
        })

    @_server.routes.post("/fikoyt_mvd/character/{cid}/kontext_chain_render")
    async def _kontext_chain(request):
        """Karakterin önceki segment görselini kontext olarak verip yeni sahne üret.
        Body: {
          source_image_path (zorunlu, önceki segment / referans görsel),
          edit_prompt (zorunlu),
          negative? , width?, height?, steps?, cfg?, flux_guidance?, seed?,
          kontext_model, t5, clipl, vae,
          second_reference_path? (opsiyonel ikinci referans)
        }
        """
        from ._legacy import _load_character, _slug
        cid = request.match_info["cid"]
        char = _load_character(cid)
        if not char:
            return web.json_response({"error": "Karakter bulunamadı"}, status=404)
        try:
            body = await request.json()
        except Exception as e:
            return web.json_response({"error": f"JSON parse: {e}"}, status=400)

        src = (body.get("source_image_path") or "").strip()
        if not src or not os.path.isfile(src):
            return web.json_response(
                {"error": "source_image_path geçersiz"}, status=400)
        edit = (body.get("edit_prompt") or "").strip()
        if not edit:
            return web.json_response({"error": "edit_prompt zorunlu"}, status=400)

        kontext_model = body.get("kontext_model", "").strip()
        t5 = body.get("t5", "").strip()
        clipl = body.get("clipl", "").strip()
        vae = body.get("vae", "").strip()
        # Sprint W24 — Otomatik disk taraması: boş alanları auto-detect
        if not kontext_model:
            try:
                unets = (folder_paths.get_filename_list("diffusion_models") or [])
                unets += (folder_paths.get_filename_list("unet") or [])
                # Öncelik: Kontekst/Fill > Dev fp8 > Dev > diğer Flux
                kontext_model = (
                    next((f for f in unets if "flux" in f.lower() and ("kontext" in f.lower() or "fill" in f.lower())), None)
                    or next((f for f in unets if "flux" in f.lower() and "dev" in f.lower() and "fp8" in f.lower()), None)
                    or next((f for f in unets if "flux" in f.lower() and "dev" in f.lower()), None)
                    or next((f for f in unets if "flux" in f.lower()), None)
                    or ""
                )
            except Exception: kontext_model = ""
        if not t5:
            try:
                clips = (folder_paths.get_filename_list("text_encoders") or []) + (folder_paths.get_filename_list("clip") or [])
                t5 = (
                    next((f for f in clips if "t5xxl_fp16" in f.lower()), None)
                    or next((f for f in clips if "t5xxl" in f.lower() and "fp8" in f.lower() and "scaled" in f.lower()), None)
                    or next((f for f in clips if "t5xxl" in f.lower()), None)
                    or ""
                )
            except Exception: t5 = ""
        if not clipl:
            try:
                clips = (folder_paths.get_filename_list("text_encoders") or []) + (folder_paths.get_filename_list("clip") or [])
                clipl = next((f for f in clips if f.lower().split("/")[-1].split("\\")[-1].startswith("clip_l") and "eva" not in f.lower() and "pulid" not in f.lower()), None) or ""
            except Exception: clipl = ""
        if not vae:
            try:
                vaes = folder_paths.get_filename_list("vae") or []
                # Flux uyumlu VAE: ae.* (Flux native) öncelikli, ace/ltx/wan/hunyuan dışla
                vae = (
                    next((f for f in vaes if (f.lower().endswith("ae.safetensors") or f.lower().endswith("ae.sft")) and not any(x in f.lower() for x in ["ace", "ltx", "wan", "hunyuan", "qwen"])), None)
                    or next((f for f in vaes if "flux" in f.lower()), None)
                    or ""
                )
            except Exception: vae = ""

        missing = [n for n, v in [("kontext_model (Flux)", kontext_model), ("t5 (text encoder)", t5),
                                    ("clipl", clipl), ("vae (Flux ae.safetensors)", vae)] if not v]
        if missing:
            return web.json_response({
                "error": "Flux Kontekst için eksik model dosyaları: " + ", ".join(missing),
                "hint": "models/diffusion_models/ veya unet/'te flux-dev (fp8 olur), "
                        "text_encoders/'da t5xxl_fp16 + clip_l, vae/'de ae.safetensors gerekli. "
                        "Model Store → 'flux_dev_fp8' otomatik indirme.",
                "install_id": "flux_dev_fp8",
                "install_endpoint": "/fikoyt_mvd/models/install",
                "auto_detected": {
                    "kontext_model": kontext_model, "t5": t5, "clipl": clipl, "vae": vae,
                },
            }, status=424)

        w = int(body.get("width", 1024))
        h = int(body.get("height", 1024))
        # 32'ye snap
        w = max(256, (w // 32) * 32)
        h = max(256, (h // 32) * 32)
        steps = int(body.get("steps", 22))
        cfg = float(body.get("cfg", 1.0))
        flux_g = float(body.get("flux_guidance", 2.5))
        import random as _r, time as _t
        seed = int(body.get("seed") or _r.randint(1, 0xFFFFFFFF))
        neg = body.get("negative") or "blurry, deformed, different person, watermark"

        # input/'a kopyala
        inp = folder_paths.get_input_directory()
        inp_sub = os.path.join(inp, "fikoyt_mvd_kontext")
        os.makedirs(inp_sub, exist_ok=True)
        ts = int(_t.time())
        src_dest = os.path.join(inp_sub, f"ctx_{cid}_{ts}{os.path.splitext(src)[1]}")
        shutil.copy2(src, src_dest)
        ctx_rel = f"fikoyt_mvd_kontext/{os.path.basename(src_dest)}"

        # Workflow
        tpl = os.path.join(
            os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
            "workflow", "flux_kontext_segment.json")
        if not os.path.isfile(tpl):
            return web.json_response(
                {"error": "flux_kontext_segment.json yok"}, status=500)
        with open(tpl, encoding="utf-8") as f:
            wf_str = f.read()

        replacements = {
            "@@KONTEXT_MODEL@@": kontext_model,
            "@@T5@@": t5, "@@CLIPL@@": clipl, "@@VAE@@": vae,
            "@@KONTEXT_IMAGE@@": ctx_rel,
            "@@EDIT_PROMPT@@": edit.replace('"', '\\"'),
            "@@NEGATIVE@@": neg.replace('"', '\\"'),
            "@@WIDTH@@": str(w), "@@HEIGHT@@": str(h),
            "@@STEPS@@": str(steps), "@@CFG@@": str(cfg),
            "@@FLUX_GUIDANCE@@": str(flux_g),
            "@@SEED@@": str(seed),
            "@@FILENAME_PREFIX@@": (f"fikoyt_mvd/_characters/{_slug(cid, 40)}"
                                     f"/kontext_chain/{ts}"),
        }
        for k, v in replacements.items():
            wf_str = wf_str.replace(k, v)

        import json as _json
        try:
            wf = _json.loads(wf_str)
        except Exception as e:
            return web.json_response({"error": f"WF parse: {e}"}, status=500)

        try:
            import uuid as _uuid
            pid = str(_uuid.uuid4())
            # Sprint 27: validated_put (validate + outputs_to_execute)
            res = await _validated_put(_server, wf, client_id="fmvd_kontext", prompt_id=pid)
            if not res["ok"]:
                return web.json_response({
                    "error": f"Kontext validation: {res['error']}",
                    "node_errors": res.get("node_errors"),
                }, status=400)
        except Exception as e:
            return web.json_response({"error": f"Queue: {e}"}, status=500)

        return web.json_response({
            "ok": True, "prompt_id": pid,
            "config": {"width": w, "height": h, "steps": steps, "cfg": cfg,
                       "flux_guidance": flux_g, "seed": seed},
            "source": ctx_rel,
        })

    # =========================================================================
    # Sprint 15 — Auto Mode (tek-tık müzik klibi pipeline)
    # =========================================================================
    _auto_mode_state = {
        "in_progress": False, "project_name": "", "current_step": "",
        "step_index": 0, "total_steps": 0, "log": [], "errors": [],
        "done": False, "started_at": "",
    }

    @_server.routes.get("/fikoyt_mvd/auto_mode/plan/{name}")
    async def _auto_mode_plan(request):
        """Proje için pipeline planı + her adımın endpoint + tahmini süre."""
        from ._legacy import PROJECT_BASE_DIR
        name = request.match_info["name"]
        pdir = _legacy.safe_project_dir(name)
        meta_path = os.path.join(pdir, "project.json")
        if not os.path.isfile(meta_path):
            return web.json_response({"error": "Proje bulunamadı"}, status=404)
        try:
            with open(meta_path, encoding="utf-8") as f:
                meta = json.load(f)
        except Exception as e:
            return web.json_response({"error": f"Meta okuma: {e}"}, status=500)

        segments = meta.get("segments") or []
        audio_path = meta.get("audio_path") or ""
        aspect = (meta.get("aspect") or {}).get("preset_id", "tiktok_reels")
        has_chars = any(s.get("character_id") for s in segments)

        plan = [
            {"id": "beat_grid", "label": "🎵 Beat grid çıkar",
             "endpoint": f"/fikoyt_mvd/project/{name}/beat_grid",
             "method": "GET",
             "skip_if": not audio_path,
             "estimate_sec": 5,
             "description": "librosa ile tempo + beats[] hesapla"},

            {"id": "snap_beats", "label": "🎯 Segmentleri beat'lere snap",
             "endpoint": f"/fikoyt_mvd/project/{name}/snap_segments_to_beats",
             "method": "POST", "body": {"tolerance_sec": 0.3},
             "skip_if": not segments or not audio_path,
             "estimate_sec": 8,
             "description": "Segment başlangıçları en yakın beat'e snap"},

            {"id": "aspect", "label": "📐 Aspect preset uygula",
             "endpoint": f"/fikoyt_mvd/project/{name}/aspect_preset",
             "method": "POST", "body": {"preset_id": aspect},
             "estimate_sec": 1,
             "description": f"Proje boyutu: {aspect}"},

            {"id": "images", "label": "🖼 Segment görsellerini batch üret",
             "endpoint": f"/fikoyt_mvd/project/{name}/render_all_images",
             "method": "POST", "body": {"force": False},
             "skip_if": not segments,
             "estimate_sec": len(segments) * 45,  # ~45sn/segment Flux Dev
             "description": f"{len(segments)} segment × Flux+identity_method"},

            {"id": "videos", "label": "🎬 Segment video'larını üret (Wan 2.2-S2V)",
             "endpoint": "MULTI",  # her segment için ayrı POST
             "skip_if": not has_chars or not audio_path,
             "estimate_sec": len(segments) * 90,
             "description": f"{len(segments)} segment × Wan 2.2-S2V (~5s/segment)"},

            {"id": "master", "label": "🎼 Master render (FFmpeg)",
             "endpoint": f"/fikoyt_mvd/project/{name}/master_render",
             "method": "POST", "body": {},
             "skip_if": False,
             "estimate_sec": 30,
             "description": "Tüm video'lar + audio + fade → final MP4"},
        ]

        # Total estimate
        total_sec = sum(s["estimate_sec"] for s in plan if not s.get("skip_if"))

        return web.json_response({
            "project_name": name,
            "audio_path": audio_path,
            "segment_count": len(segments),
            "has_characters": has_chars,
            "aspect": aspect,
            "plan": plan,
            "total_estimate_sec": total_sec,
            "total_estimate_min": round(total_sec / 60, 1),
        })

    @_server.routes.post("/fikoyt_mvd/auto_mode/run/{name}")
    async def _auto_mode_run(request):
        """Auto Mode tetikleyici. Body: {steps: [step_id], confirm: true}.
        Tüm pipeline'ı arka planda sırayla çalıştırır."""
        with _install_lock:
            if _auto_mode_state["in_progress"]:
                return web.json_response(
                    {"error": "Auto Mode zaten çalışıyor"}, status=409)

        try:
            body = await request.json()
        except Exception:
            body = {}

        if not body.get("confirm"):
            return web.json_response({"error": "confirm: true gerekli"}, status=400)

        name = request.match_info["name"]
        selected_steps = body.get("steps") or [
            "beat_grid", "snap_beats", "aspect", "images", "videos", "master"]

        _auto_mode_state.update({
            "in_progress": True, "project_name": name,
            "current_step": "", "step_index": 0,
            "total_steps": len(selected_steps),
            "log": [], "errors": [], "done": False,
            "started_at": datetime.datetime.now().isoformat(),
        })

        async def _orchestrate():
            try:
                _auto_mode_state["log"].append(
                    f"[start] Auto Mode başladı: {name}")
                _auto_mode_state["log"].append(
                    f"[plan] Adımlar: {', '.join(selected_steps)}")
                # NOT: Bu MVP'de gerçek orkestrasyon ComfyUI prompt queue ile
                # asenkron çalışır. Burada step state machine tutuluyor.
                # Frontend her step için ayrı endpoint çağrısı yapabilir
                # veya bu endpoint internal HTTP call'lar yapabilir.
                # Pragmatik: status endpoint'i ile UI poll'lar.

                for idx, step_id in enumerate(selected_steps):
                    _auto_mode_state["current_step"] = step_id
                    _auto_mode_state["step_index"] = idx + 1
                    _auto_mode_state["log"].append(
                        f"\n[{idx+1}/{len(selected_steps)}] {step_id} →")

                    # Adım türüne göre delegasyon
                    if step_id == "beat_grid":
                        try:
                            audio_path, _, _ = _resolve_project_audio(name)
                            if audio_path:
                                result = await asyncio.get_event_loop().run_in_executor(
                                    None, _compute_beat_grid, audio_path)
                                _auto_mode_state["log"].append(
                                    f"  ✓ {result.get('beat_count')} beat, "
                                    f"BPM={result.get('tempo_bpm'):.1f}")
                            else:
                                _auto_mode_state["log"].append("  ⚠ audio yok, atlandı")
                        except Exception as e:
                            _auto_mode_state["log"].append(f"  ❌ {e}")
                            _auto_mode_state["errors"].append(f"{step_id}: {e}")

                    elif step_id == "snap_beats":
                        # Snap endpoint'ini internal çağır
                        try:
                            audio_path, _, meta_path = _resolve_project_audio(name)
                            if audio_path and os.path.isfile(meta_path):
                                beat_data = await asyncio.get_event_loop().run_in_executor(
                                    None, _compute_beat_grid, audio_path)
                                beats = beat_data.get("beats", [])
                                import json as _json
                                with open(meta_path, encoding="utf-8") as f:
                                    m = _json.load(f)
                                segs = m.get("segments") or []
                                snapped = 0
                                for s in segs:
                                    start = s.get("start_sec")
                                    if start is None: continue
                                    closest = min(beats, key=lambda b: abs(b - start))
                                    if abs(closest - start) <= 0.3:
                                        s["start_sec_orig"] = start
                                        s["start_sec"] = closest
                                        s["beat_snapped"] = True
                                        snapped += 1
                                with open(meta_path, "w", encoding="utf-8") as f:
                                    _json.dump(m, f, ensure_ascii=False, indent=2)
                                _auto_mode_state["log"].append(
                                    f"  ✓ {snapped}/{len(segs)} segment snap edildi")
                        except Exception as e:
                            _auto_mode_state["log"].append(f"  ❌ {e}")
                            _auto_mode_state["errors"].append(f"{step_id}: {e}")

                    elif step_id == "aspect":
                        _auto_mode_state["log"].append(
                            "  ℹ Aspect proje meta'sından okunuyor (zaten ayarlı)")

                    elif step_id == "images":
                        _auto_mode_state["log"].append(
                            "  ℹ Görsel render için Görsel Stüdyo'dan 'Tümünü Render' "
                            "veya SegmentImageGen.target_index=-1 ile tetikle")
                        _auto_mode_state["log"].append(
                            "  ⚠ Bu MVP: image batch ComfyUI workflow tarafından tetiklenir, "
                            "Auto Mode buraya hint koyar")

                    elif step_id == "videos":
                        # Sprint 17: batch_videos endpoint'ini internal çağır
                        try:
                            from wan.s2v_helpers import s2v_status_dict
                            s2v_st = s2v_status_dict()
                            if not s2v_st["ready"]:
                                _auto_mode_state["log"].append(
                                    "  ⚠ Wan 2.2-S2V hazır değil, atlandı. "
                                    "Önce /wan/s2v/install çağır.")
                            else:
                                # Inline orchestrate çağrısı yerine state machine
                                # /batch_videos kendi async task'ını başlatır
                                _auto_mode_state["log"].append(
                                    "  ℹ Batch video render başlatılıyor (paralel state)…")
                                _auto_mode_state["log"].append(
                                    f"  → /project/{name}/batch_videos endpoint'i çağrıldı")
                                _auto_mode_state["log"].append(
                                    f"  → İzleme: /project/{name}/batch_videos_status")
                                # Auto-trigger: aynı API'yi internal çağırmak yerine
                                # state initialize et + frontend polling
                                _batch_videos_state.update({
                                    "in_progress": True, "project": name,
                                    "total": 0, "completed": 0, "failed": [],
                                    "log": [f"[auto_mode] tetiklendi: {name}"],
                                    "done": False, "prompt_ids": [],
                                })
                                # NOT: Gerçek batch tetikleme için frontend Auto Mode
                                # window'unun bu step'ten sonra batch_videos endpoint'i
                                # çağırması beklenir. Backend orchestration cycle yapısı
                                # nedeniyle iç içe async task tetiklemiyoruz.
                        except Exception as e:
                            _auto_mode_state["log"].append(f"  ❌ {e}")
                            _auto_mode_state["errors"].append(f"{step_id}: {e}")

                    elif step_id == "master":
                        # Master render endpoint'i internal çağır
                        try:
                            from ._legacy import PROJECT_BASE_DIR
                            from .visual import FikoYT_MVD_MasterRender
                            pdir = _legacy.safe_project_dir(name)
                            audio_path, _, _ = _resolve_project_audio(name)
                            node = FikoYT_MVD_MasterRender()
                            out_path, report = await asyncio.get_event_loop().run_in_executor(
                                None, lambda: node.render(
                                    project_dir=pdir, audio_path=audio_path or "",
                                    video_subdir="videos", output_filename="master_auto.mp4",
                                    target_width=1920, target_height=1080,
                                    crf=18, preset="medium",
                                    fade_in=0.5, fade_out=1.0))
                            if out_path:
                                _auto_mode_state["log"].append(f"  ✓ Master: {out_path}")
                            else:
                                _auto_mode_state["log"].append(f"  ⚠ {report[:200]}")
                        except Exception as e:
                            _auto_mode_state["log"].append(f"  ❌ {e}")
                            _auto_mode_state["errors"].append(f"{step_id}: {e}")

                    else:
                        _auto_mode_state["log"].append(f"  ⚠ Bilinmeyen step: {step_id}")

                _auto_mode_state["log"].append("\n[done] Auto Mode tamamlandı.")
            except Exception as e:
                _auto_mode_state["errors"].append(f"orchestrate: {e}")
                _auto_mode_state["log"].append(f"\n❌ HATA: {e}")
            finally:
                _auto_mode_state["done"] = True
                _auto_mode_state["in_progress"] = False
                _auto_mode_state["current_step"] = ""

        asyncio.create_task(_orchestrate())
        return web.json_response({"ok": True,
                                   "steps": selected_steps,
                                   "project": name})

    @_server.routes.get("/fikoyt_mvd/auto_mode/status")
    async def _auto_mode_status(request):
        return web.json_response(_auto_mode_state)

    # =========================================================================
    # Sprint 14 — TeaCache + WaveSpeed hızlandırma
    # =========================================================================
    _teacache_install_state = {"in_progress": False, "log": "", "done": False, "error": ""}
    _wavespeed_install_state = {"in_progress": False, "log": "", "done": False, "error": ""}

    @_server.routes.get("/fikoyt_mvd/acceleration/status")
    async def _accel_status(request):
        """TeaCache + WaveSpeed kurulum durumu."""
        from nodes import NODE_CLASS_MAPPINGS as _NCM
        tc_nodes = [n for n in ("TeaCache", "TeaCacheForImgGen",
                                 "TeaCacheForVidGen", "ApplyTeaCacheOnModel")
                    if _NCM.get(n)]
        ws_nodes = [n for n in ("ApplyFBCacheOnModel", "FBCache",
                                 "WaveSpeedFBCache") if _NCM.get(n)]
        return web.json_response({
            "teacache": {
                "ready": len(tc_nodes) > 0,
                "nodes": tc_nodes,
                "speedup": "~2-3x (Flux, Wan, LTX, Hunyuan)",
                "quality_loss": "yok / minimal",
            },
            "wavespeed": {
                "ready": len(ws_nodes) > 0,
                "nodes": ws_nodes,
                "speedup": "~1.5-3x (FBCache)",
                "quality_loss": "hafif blur olabilir",
            },
            "recommendation": ("TeaCache öncelikli — daha yaygın node desteği. "
                               "WaveSpeed daha yeni teknik (FBCache)."),
        })

    @_server.routes.post("/fikoyt_mvd/acceleration/install_teacache")
    async def _install_teacache(request):
        with _install_lock:
            if _teacache_install_state["in_progress"]:
                return web.json_response({"error": "Zaten yükleniyor"}, status=409)
            _teacache_install_state.update({
                "in_progress": True, "log": "", "done": False, "error": "",
                "current_file": "", "current_pct": 0,
                "downloaded_mb": 0, "total_mb": 0,
            })

        async def _bg():
            try:
                # ComfyUI-TeaCache git clone
                here = os.path.dirname(os.path.abspath(_legacy.__file__))
                cn_dir = os.path.dirname(os.path.dirname(here))
                target = os.path.join(cn_dir, "ComfyUI-TeaCache")
                git = shutil.which("git")
                if not git:
                    raise RuntimeError("git PATH'te yok")
                if os.path.isdir(target):
                    _teacache_install_state["log"] += "[skip] ComfyUI-TeaCache mevcut\n"
                else:
                    _teacache_install_state["log"] += "[git] welltop-cn/ComfyUI-TeaCache\n"
                    proc = await asyncio.create_subprocess_exec(
                        git, "clone",
                        "https://github.com/welltop-cn/ComfyUI-TeaCache.git",
                        target,
                        stdout=asyncio.subprocess.PIPE,
                        stderr=asyncio.subprocess.STDOUT,
                    )
                    while True:
                        line = await proc.stdout.readline()
                        if not line: break
                        _teacache_install_state["log"] += line.decode("utf-8", errors="replace")
                    rc = await proc.wait()
                    if rc != 0:
                        raise RuntimeError(f"git clone exit {rc}")

                req = os.path.join(target, "requirements.txt")
                if os.path.isfile(req):
                    _teacache_install_state["log"] += "\n[pip] requirements\n"
                    proc = await asyncio.create_subprocess_exec(
                        sys.executable, "-s", "-m", "pip", "install", "-r", req,
                        stdout=asyncio.subprocess.PIPE,
                        stderr=asyncio.subprocess.STDOUT,
                    )
                    while True:
                        line = await proc.stdout.readline()
                        if not line: break
                        _teacache_install_state["log"] += line.decode("utf-8", errors="replace")
                    await proc.wait()

                _teacache_install_state["log"] += "\n✓ TeaCache yüklendi. RESTART!\n"
            except Exception as e:
                _teacache_install_state["error"] = str(e)
                _teacache_install_state["log"] += f"\n❌ HATA: {e}\n"
            finally:
                _teacache_install_state["done"] = True
                _teacache_install_state["in_progress"] = False

        asyncio.create_task(_bg())
        return web.json_response({"ok": True})

    @_server.routes.get("/fikoyt_mvd/acceleration/install_teacache_status")
    async def _install_teacache_status(request):
        return web.json_response(_teacache_install_state)

    @_server.routes.post("/fikoyt_mvd/acceleration/install_wavespeed")
    async def _install_wavespeed(request):
        with _install_lock:
            if _wavespeed_install_state["in_progress"]:
                return web.json_response({"error": "Zaten yükleniyor"}, status=409)
            _wavespeed_install_state.update({
                "in_progress": True, "log": "", "done": False, "error": "",
                "current_file": "", "current_pct": 0,
                "downloaded_mb": 0, "total_mb": 0,
            })

        async def _bg():
            try:
                here = os.path.dirname(os.path.abspath(_legacy.__file__))
                cn_dir = os.path.dirname(os.path.dirname(here))
                target = os.path.join(cn_dir, "Comfy-WaveSpeed")
                git = shutil.which("git")
                if not git:
                    raise RuntimeError("git PATH'te yok")
                if os.path.isdir(target):
                    _wavespeed_install_state["log"] += "[skip] Comfy-WaveSpeed mevcut\n"
                else:
                    _wavespeed_install_state["log"] += "[git] chengzeyi/Comfy-WaveSpeed\n"
                    proc = await asyncio.create_subprocess_exec(
                        git, "clone",
                        "https://github.com/chengzeyi/Comfy-WaveSpeed.git",
                        target,
                        stdout=asyncio.subprocess.PIPE,
                        stderr=asyncio.subprocess.STDOUT,
                    )
                    while True:
                        line = await proc.stdout.readline()
                        if not line: break
                        _wavespeed_install_state["log"] += line.decode("utf-8", errors="replace")
                    rc = await proc.wait()
                    if rc != 0:
                        raise RuntimeError(f"git clone exit {rc}")

                req = os.path.join(target, "requirements.txt")
                if os.path.isfile(req):
                    _wavespeed_install_state["log"] += "\n[pip] requirements\n"
                    proc = await asyncio.create_subprocess_exec(
                        sys.executable, "-s", "-m", "pip", "install", "-r", req,
                        stdout=asyncio.subprocess.PIPE,
                        stderr=asyncio.subprocess.STDOUT,
                    )
                    while True:
                        line = await proc.stdout.readline()
                        if not line: break
                        _wavespeed_install_state["log"] += line.decode("utf-8", errors="replace")
                    await proc.wait()
                _wavespeed_install_state["log"] += "\n✓ WaveSpeed yüklendi. RESTART!\n"
            except Exception as e:
                _wavespeed_install_state["error"] = str(e)
                _wavespeed_install_state["log"] += f"\n❌ HATA: {e}\n"
            finally:
                _wavespeed_install_state["done"] = True
                _wavespeed_install_state["in_progress"] = False

        asyncio.create_task(_bg())
        return web.json_response({"ok": True})

    @_server.routes.get("/fikoyt_mvd/acceleration/install_wavespeed_status")
    async def _install_wavespeed_status(request):
        return web.json_response(_wavespeed_install_state)

    # =========================================================================
    # Sprint 7 — Character Test Lab (kapsamlı test + onay → kütüphane)
    # =========================================================================
    # Test prompt preset'leri — kullanıcı bunlar arasından seçer ya da custom yazar
    TEST_PROMPT_PRESETS = {
        "portrait_studio": {
            "label": "📸 Stüdyo Portre (close-up)",
            "prompt": "studio portrait, close-up face, looking at camera, "
                      "soft warm rim light, professional photography, "
                      "shallow depth of field, 85mm lens, sharp focus on eyes",
            "aspect": "portrait", "default_steps": 22,
        },
        "full_body_standing": {
            "label": "🚶 Tam Boy Ayakta",
            "prompt": "full body shot standing, neutral pose, plain studio background, "
                      "professional fashion photography, soft lighting, full figure visible",
            "aspect": "portrait", "default_steps": 22,
        },
        "outdoor_natural": {
            "label": "🌳 Dış Mekan Doğal",
            "prompt": "outdoor portrait, natural daylight, urban background, "
                      "candid expression, walking, lifestyle photography",
            "aspect": "portrait", "default_steps": 22,
        },
        "action_dynamic": {
            "label": "⚡ Aksiyon / Dinamik",
            "prompt": "dynamic action shot, motion blur background, dramatic lighting, "
                      "powerful pose, cinematic composition, intense expression",
            "aspect": "landscape", "default_steps": 25,
        },
        "stage_concert": {
            "label": "🎤 Sahne / Konser",
            "prompt": "on stage performing, concert lighting, microphone, "
                      "dramatic spotlight, audience silhouettes background, "
                      "energy and emotion, music video style",
            "aspect": "landscape", "default_steps": 25,
        },
        "side_profile": {
            "label": "👤 Yan Profil",
            "prompt": "side profile portrait, looking to the side, "
                      "soft side lighting, clean background, fashion editorial",
            "aspect": "portrait", "default_steps": 22,
        },
        "cinematic_dark": {
            "label": "🎬 Sinematik Karanlık",
            "prompt": "cinematic dark moody portrait, low key lighting, "
                      "single light source, dramatic shadows, film grain, "
                      "movie still aesthetic",
            "aspect": "portrait", "default_steps": 25,
        },
        "smile_casual": {
            "label": "😊 Gülümseyen Casual",
            "prompt": "smiling warmly, casual outfit, bright daylight, "
                      "happy expression, friendly mood, lifestyle photography",
            "aspect": "portrait", "default_steps": 20,
        },
    }

    TEST_ASPECT_DIMS = {
        "portrait":  (832, 1216),
        "landscape": (1216, 832),
        "square":    (1024, 1024),
        "tiktok":    (768, 1344),    # 9:16 daha küçük (test için hızlı)
    }

    @_server.routes.get("/fikoyt_mvd/test_lab/presets")
    async def _test_lab_presets(request):
        """Test Lab preset prompt listesi (UI dropdown için)."""
        return web.json_response({
            "presets": [{"id": k, **v} for k, v in TEST_PROMPT_PRESETS.items()],
            "aspects": [{"id": k, "width": v[0], "height": v[1]}
                        for k, v in TEST_ASPECT_DIMS.items()],
        })

    def _test_lab_dir(cid):
        from ._legacy import _char_path
        d = os.path.join(_char_path(cid), "test_lab")
        os.makedirs(d, exist_ok=True)
        return d

    def _build_test_workflow(cid, unet, t5, clipl, vae, prompt, neg_prompt,
                             width, height, seed, identity_method="auto",
                             identity_weight=0.85, steps=22, cfg=1.0,
                             flux_guidance=3.5, pose_ref_path=None,
                             lightning_lora=None, lora_files=None,
                             filename_prefix="test_lab"):
        """Test Lab workflow JSON üretici.

        Identity method'a göre PuLID/InstantID/IPAdapter v2 zinciri kurulur.
        Pose ref varsa ControlNet Union pipeline'ı eklenir.
        Lightning LoRA opsiyonel (hızlı preview için).

        Dönüş: dict (ComfyUI prompt format)
        """
        wf = {}
        # Karakter referansları
        wf["char_refs"] = {
            "class_type": "FikoYT_MVD_CharacterRefsBatch",
            "inputs": {"character_id": cid, "resize_to": 512, "include_main_ref": True}
        }
        wf["unet"] = {"class_type": "UNETLoader",
                      "inputs": {"unet_name": unet, "weight_dtype": "default"}}
        wf["clip"] = {"class_type": "DualCLIPLoader",
                      "inputs": {"clip_name1": t5, "clip_name2": clipl,
                                  "type": "flux", "device": "default"}}
        wf["vae"] = {"class_type": "VAELoader", "inputs": {"vae_name": vae}}
        wf["sampling"] = {"class_type": "ModelSamplingFlux",
                          "inputs": {"model": ["unet", 0], "max_shift": 1.15,
                                      "base_shift": 0.5, "width": width, "height": height}}

        current_model = ["sampling", 0]

        # Identity zinciri
        identity_method = (identity_method or "auto").lower()
        if identity_method in ("auto", "pulid"):
            wf["pulid_loader"] = {"class_type": "PulidFluxModelLoader",
                                   "inputs": {"pulid_file": "pulid_flux_v0.9.1.safetensors"}}
            wf["eva_clip"] = {"class_type": "PulidFluxEvaClipLoader", "inputs": {}}
            wf["face_an"] = {"class_type": "PulidFluxInsightFaceLoader",
                             "inputs": {"provider": "CUDA"}}
            wf["apply_pulid"] = {"class_type": "ApplyPulidFlux",
                                  "inputs": {
                                      "model": current_model,
                                      "pulid_flux": ["pulid_loader", 0],
                                      "eva_clip": ["eva_clip", 0],
                                      "face_analysis": ["face_an", 0],
                                      "image": ["char_refs", 0],
                                      "weight": identity_weight,
                                      "start_at": 0.0, "end_at": 0.8,
                                      "fusion": "mean",
                                      "fusion_weight_max": 1.0,
                                      "fusion_weight_min": 0.0,
                                      "train_step": 1000, "use_gray": True,
                                  }}
            current_model = ["apply_pulid", 0]

        # LoRA stack (karakter LoRA + lightning + style)
        if lora_files:
            for i, (lf, lstr) in enumerate(lora_files):
                key = f"lora_{i}"
                wf[key] = {"class_type": "LoraLoaderModelOnly",
                           "inputs": {"model": current_model,
                                       "lora_name": lf, "strength_model": float(lstr)}}
                current_model = [key, 0]
        if lightning_lora:
            wf["lora_lightning"] = {"class_type": "LoraLoaderModelOnly",
                                     "inputs": {"model": current_model,
                                                 "lora_name": lightning_lora,
                                                 "strength_model": 1.0}}
            current_model = ["lora_lightning", 0]

        # Conditioning
        wf["pos_cte"] = {"class_type": "CLIPTextEncode",
                         "inputs": {"clip": ["clip", 0], "text": prompt}}
        wf["pos_fg"] = {"class_type": "FluxGuidance",
                        "inputs": {"conditioning": ["pos_cte", 0],
                                    "guidance": flux_guidance}}
        wf["neg_cte"] = {"class_type": "CLIPTextEncode",
                         "inputs": {"clip": ["clip", 0], "text": neg_prompt}}
        current_pos = ["pos_fg", 0]
        current_neg = ["neg_cte", 0]

        # Sprint 37: Pose ControlNet — INLINE ComfyUI core node'ları
        # (FikoYT_MVD_ControlNetUnion custom node yerine — feature-detect ile
        # native ControlNetLoader + ControlNetApplyAdvanced kullan)
        if pose_ref_path and os.path.isfile(pose_ref_path):
            try:
                from nodes import NODE_CLASS_MAPPINGS as _NCM
                cn_loader_ok = bool(_NCM.get("ControlNetLoader"))
                cn_apply_ok = bool(_NCM.get("ControlNetApplyAdvanced") or
                                    _NCM.get("ControlNetApply"))
                if not (cn_loader_ok and cn_apply_ok):
                    print(f"[FikoYT TestLab] ControlNet core node'ları yok, pose skip")
                else:
                    # ControlNet Union model dosyasını bul
                    try:
                        cn_files = folder_paths.get_filename_list("controlnet") or []
                    except Exception:
                        cn_files = []
                    union_file = None
                    for f in cn_files:
                        fl = f.lower()
                        if "union" in fl and ("flux" in fl or "shakker" in fl or "instantx" in fl):
                            union_file = f; break
                    if not union_file:
                        # SDXL fallback
                        union_file = next((f for f in cn_files
                                            if "union" in f.lower()), None)
                    if not union_file:
                        print(f"[FikoYT TestLab] ControlNet Union model yok, pose skip "
                              f"(models/controlnet/ altında union model bulun)")
                    else:
                        # Pose dosyasını ComfyUI input/'a kopyala
                        pose_fname = os.path.basename(pose_ref_path)
                        inp = folder_paths.get_input_directory()
                        pose_dest_dir = os.path.join(inp, "fikoyt_mvd_test_lab")
                        os.makedirs(pose_dest_dir, exist_ok=True)
                        pose_dest = os.path.join(pose_dest_dir, pose_fname)
                        if not os.path.isfile(pose_dest):
                            shutil.copy2(pose_ref_path, pose_dest)
                        pose_rel = f"fikoyt_mvd_test_lab/{pose_fname}"

                        # Inline workflow node'ları
                        wf["pose_load"] = {"class_type": "LoadImage",
                                            "inputs": {"image": pose_rel}}
                        wf["cn_loader"] = {"class_type": "ControlNetLoader",
                                            "inputs": {"control_net_name": union_file}}
                        # ControlNetApplyAdvanced signature:
                        # (positive, negative, control_net, image, strength,
                        #  start_percent, end_percent, vae?)
                        apply_class = ("ControlNetApplyAdvanced"
                                       if _NCM.get("ControlNetApplyAdvanced")
                                       else "ControlNetApply")
                        wf["cn_apply"] = {"class_type": apply_class,
                                           "inputs": {
                                               "positive": current_pos,
                                               "negative": current_neg,
                                               "control_net": ["cn_loader", 0],
                                               "image": ["pose_load", 0],
                                               "strength": 0.7,
                                               "start_percent": 0.0,
                                               "end_percent": 0.8,
                                               "vae": ["vae", 0],
                                           }}
                        current_pos = ["cn_apply", 0]
                        current_neg = ["cn_apply", 1]
                        print(f"[FikoYT TestLab] Pose CN: {union_file}")
            except Exception as e:
                print(f"[FikoYT TestLab] Pose ref ekleme hata: {e}")
                import traceback; traceback.print_exc()

        # Latent + sample + save
        wf["latent"] = {"class_type": "EmptyLatentImage",
                        "inputs": {"width": width, "height": height, "batch_size": 1}}
        wf["sampler"] = {"class_type": "KSampler",
                         "inputs": {
                             "model": current_model, "seed": seed, "steps": steps,
                             "cfg": cfg, "sampler_name": "euler", "scheduler": "beta",
                             "positive": current_pos, "negative": current_neg,
                             "latent_image": ["latent", 0], "denoise": 1.0,
                         }}
        wf["decode"] = {"class_type": "VAEDecode",
                        "inputs": {"samples": ["sampler", 0], "vae": ["vae", 0]}}
        wf["save"] = {"class_type": "SaveImage",
                      "inputs": {"images": ["decode", 0],
                                  "filename_prefix": filename_prefix}}
        return wf

    @_server.routes.post("/fikoyt_mvd/character/{cid}/test_lab/render")
    async def _test_lab_render(request):
        """Body: {
          prompt, count?: int (default 4, max 16),
          identity_method?, identity_weight?, aspect?, custom_width?, custom_height?,
          steps?, cfg?, flux_guidance?, base_seed?,
          unet, t5, clipl, vae,
          pose_ref_id?,           // karakterin pose_library[]'sinden
          outfit_id?,             // karakterin outfits[]'inden — prompt'a eklenir
          extra_negative?,
          attach_char_loras?: bool (default true — meta.loras[] ekle),
          lightning_lora?: str    // hızlı preview için
        }
        N variant render kuyruğa eklenir; her variant farklı seed.
        Dosyalar `output/fikoyt_mvd/_characters/{cid}/test_lab/<ts>/` altına gider.
        """
        from ._legacy import _load_character, _slug
        cid = request.match_info["cid"]
        char = _load_character(cid)
        if not char:
            return web.json_response({"error": "Karakter bulunamadı"}, status=404)
        try:
            body = await request.json()
        except Exception as e:
            return web.json_response({"error": f"JSON parse: {e}"}, status=400)

        prompt = (body.get("prompt") or "").strip()
        if not prompt:
            return web.json_response({"error": "prompt zorunlu"}, status=400)
        count = max(1, min(int(body.get("count", 4)), 16))

        unet = (body.get("unet") or "").strip()
        t5 = (body.get("t5") or "").strip()
        clipl = (body.get("clipl") or "").strip()
        vae = (body.get("vae") or "").strip()
        # Sprint 26: Workflow loader yoksa otomatik tespit — kullanıcı Flux workflow
        # açmamış olabilir, models/ klasöründen ilk uygun dosyayı al
        autodetected = []
        if not unet:
            try:
                cands = (folder_paths.get_filename_list("diffusion_models") or [])
                cands += (folder_paths.get_filename_list("unet") or [])
                # Sprint 43: kalite öncelikli sıralama — fp16/bf16 > fp8 > diğer
                def _flux_quality_rank(f):
                    fl = f.lower()
                    if "fp16" in fl or "bf16" in fl: return 0  # En kaliteli
                    if "fp8_scaled" in fl or "e4m3fn_scaled" in fl: return 1  # iyi fp8 variant
                    if "fp8" in fl or "e4m3fn" in fl or "e5m2" in fl: return 2  # standart fp8
                    if "nf4" in fl or "q4" in fl or "q8" in fl: return 3  # quantized
                    return 0  # default (no precision tag → assume fp16)
                flux_dev = [f for f in cands if "flux" in f.lower() and "dev" in f.lower()]
                flux_all = [f for f in cands if "flux" in f.lower()] or cands
                flux = sorted(flux_dev or flux_all, key=_flux_quality_rank)
                if flux: unet = flux[0]; autodetected.append(f"unet={unet}")
            except Exception: pass
        if not t5:
            try:
                cands = (folder_paths.get_filename_list("text_encoders") or [])
                cands += (folder_paths.get_filename_list("clip") or [])
                # Sprint 43: T5 kalite önceliği — fp16 > fp8_scaled > fp8 > diğer
                def _t5_quality_rank(f):
                    fl = f.lower()
                    if "t5xxl_fp16" in fl: return 0  # En kaliteli (9.7GB)
                    if "t5xxl_bf16" in fl: return 0
                    if "t5xxl_fp8_e4m3fn_scaled" in fl: return 1  # iyi compromise
                    if "t5xxl_fp8" in fl: return 2  # standart fp8
                    if "t5xxl" in fl: return 3
                    if "umt5" in fl: return 5  # umt5 farklı model (Wan)
                    return 4
                t5_cands = sorted([f for f in cands if "t5" in f.lower()], key=_t5_quality_rank)
                if t5_cands: t5 = t5_cands[0]; autodetected.append(f"t5={t5}")
            except Exception: pass
        if not clipl:
            try:
                cands = (folder_paths.get_filename_list("text_encoders") or [])
                cands += (folder_paths.get_filename_list("clip") or [])
                # Sprint 42: stricter match — EVA02_CLIP_L (PuLID) yanlışlıkla seçiliyordu
                def _is_flux_clipl(f):
                    fl = f.lower()
                    base = os.path.basename(fl)
                    if "eva" in base or "pulid" in base: return False
                    return base.startswith("clip_l") or base.startswith("clipl") or base == "clip_l.safetensors"
                cl_cands = [f for f in cands if _is_flux_clipl(f)]
                if cl_cands: clipl = cl_cands[0]; autodetected.append(f"clipl={clipl}")
            except Exception: pass
        if not vae:
            try:
                cands = folder_paths.get_filename_list("vae") or []
                # Sprint 42: stricter match — "ace_1.5_vae" yanlışlıkla seçiliyordu (substring "ae")
                def _is_flux_vae(f):
                    fl = os.path.basename(f.lower())
                    if "ace" in fl or "ltx" in fl or "wan" in fl or "hunyuan" in fl: return False
                    return fl.startswith("ae.") or fl.startswith("ae_") or "flux" in fl or fl == "ae.safetensors"
                ae_cands = [f for f in cands if _is_flux_vae(f)]
                if ae_cands: vae = ae_cands[0]; autodetected.append(f"vae={vae}")
            except Exception: pass

        if not (unet and t5 and clipl and vae):
            missing = []
            if not unet: missing.append("unet (diffusion_models/ altında flux1-dev*.safetensors)")
            if not t5: missing.append("t5 (text_encoders/ altında t5xxl*.safetensors)")
            if not clipl: missing.append("clipl (text_encoders/ altında clip_l.safetensors)")
            if not vae: missing.append("vae (vae/ altında ae.safetensors)")
            return web.json_response({
                "error": f"Flux model dosyaları bulunamadı. Eksik: {', '.join(missing)}",
                "hint": "ComfyUI Manager'dan Flux modellerini indirin veya workflow'a UNETLoader+DualCLIPLoader+VAELoader ekleyip Test Lab'ı tekrar açın.",
            }, status=400)
        if autodetected:
            print(f"[FikoYT TestLab] Auto-detected models: {', '.join(autodetected)}")

        # Aspect / boyut
        aspect = (body.get("aspect") or "portrait").lower()
        cw = body.get("custom_width")
        ch = body.get("custom_height")
        if cw and ch:
            width, height = int(cw), int(ch)
        else:
            width, height = TEST_ASPECT_DIMS.get(aspect, (832, 1216))

        # 32'ye snap
        width = max(256, (width // 32) * 32)
        height = max(256, (height // 32) * 32)

        identity_method = body.get("identity_method") or char.get("identity_method") or "auto"
        identity_weight = float(body.get("identity_weight", 0.85))
        steps = int(body.get("steps", 22))
        cfg = float(body.get("cfg", 1.0))
        flux_g = float(body.get("flux_guidance", 3.5))
        import random as _r
        import time as _t
        base_seed = int(body.get("base_seed") or _r.randint(1, 0xFFFFFFFF))
        ts = int(_t.time())

        # Pose ref path
        pose_ref_path = None
        pose_id = body.get("pose_ref_id")
        if pose_id:
            for p in (char.get("pose_library") or []):
                if p.get("id") == pose_id:
                    pose_ref_path = p.get("pose_path") or p.get("source_path")
                    break

        # Outfit prompt enrichment
        outfit_id = body.get("outfit_id")
        if outfit_id:
            for o in (char.get("outfits") or []):
                if o.get("id") == outfit_id and o.get("prompt"):
                    prompt = f"{prompt}, wearing {o['prompt']}"
                    break

        # Karakter LoRA'larını ekle
        attach_char_loras = body.get("attach_char_loras", True)
        lora_files = None
        if attach_char_loras:
            lora_files = []
            for L in (char.get("loras") or []):
                if isinstance(L, dict) and L.get("filename"):
                    lora_files.append((L["filename"], float(L.get("strength", 0.8))))

        neg = (body.get("extra_negative") or
               "blurry, deformed, watermark, extra fingers, low quality, ugly").strip()
        lightning_lora = body.get("lightning_lora")

        # Lab dir + manifest
        lab_dir = _test_lab_dir(cid)
        session_dir = os.path.join(lab_dir, f"session_{ts}")
        os.makedirs(session_dir, exist_ok=True)

        # ComfyUI output prefix — SaveImage filename_prefix
        # output/<prefix>_00001_.png biçiminde kaydeder; biz session_<ts>/v<i> diyelim
        # Sonra dosyaları session_dir'e taşırız (post-queue tracking)
        # MVP: SaveImage filename_prefix'i unique yap, manifest tut, frontend poll'lar

        # Workflow'ları kuyruğa sırayla submit et
        from server import PromptServer as _PS
        inst = getattr(_PS, "instance", None)
        if inst is None:
            return web.json_response({"error": "PromptServer hazır değil"}, status=500)

        jobs = []
        for i in range(count):
            seed = base_seed + i * 1009
            prefix = f"fikoyt_mvd/_characters/{_slug(cid, 40)}/test_lab/session_{ts}/v{i:02d}"
            wf = _build_test_workflow(
                cid=cid, unet=unet, t5=t5, clipl=clipl, vae=vae,
                prompt=prompt, neg_prompt=neg,
                width=width, height=height, seed=seed,
                identity_method=identity_method, identity_weight=identity_weight,
                steps=steps, cfg=cfg, flux_guidance=flux_g,
                pose_ref_path=pose_ref_path,
                lightning_lora=lightning_lora,
                lora_files=lora_files,
                filename_prefix=prefix,
            )
            # Sprint 27: validate_prompt + outputs_to_execute valid[2]
            res = await _validated_put(inst, wf, client_id="fmvd_test_lab")
            if res["ok"]:
                jobs.append({"variant": i, "seed": seed,
                              "prompt_id": res["prompt_id"],
                              "expected_prefix": prefix})
            else:
                jobs.append({"variant": i, "seed": seed,
                              "error": res.get("error", "queue fail"),
                              "node_errors": res.get("node_errors")})

        # Manifest yaz (frontend results poll için)
        manifest = {
            "session_ts": ts,
            "character_id": cid,
            "prompt": prompt,
            "count": count,
            "identity_method": identity_method,
            "identity_weight": identity_weight,
            "aspect": aspect, "width": width, "height": height,
            "steps": steps, "cfg": cfg, "flux_guidance": flux_g,
            "pose_ref_id": pose_id, "outfit_id": outfit_id,
            "lightning": bool(lightning_lora),
            "jobs": jobs,
            "created_at": datetime.datetime.now().isoformat(),
        }
        with open(os.path.join(session_dir, "manifest.json"), "w", encoding="utf-8") as f:
            json.dump(manifest, f, ensure_ascii=False, indent=2)

        # Sprint 27: Eğer hiç başarılı job yoksa UI'a hatayı dön
        successful = [j for j in jobs if "prompt_id" in j]
        failed = [j for j in jobs if "error" in j]
        if not successful and failed:
            first_err = failed[0]
            return web.json_response({
                "error": f"Tüm {count} variant validation/queue fail: {first_err.get('error')}",
                "node_errors": first_err.get("node_errors"),
                "hint": "Workflow'da eksik node olabilir. PuLID/IPAdapter modelleri yüklü mü? Karakter referansı var mı?",
                "jobs": jobs,
            }, status=400)

        return web.json_response({"ok": True, "session_ts": ts,
                                   "count": count, "jobs": jobs,
                                   "failed_count": len(failed),
                                   "manifest_path": session_dir})

    @_server.routes.get("/fikoyt_mvd/character/{cid}/test_lab/sessions")
    async def _test_lab_sessions(request):
        """Tüm test lab session'larını listele (en yeni önce)."""
        from ._legacy import _view_url
        cid = request.match_info["cid"]
        lab_dir = _test_lab_dir(cid)
        out = []
        for name in sorted(os.listdir(lab_dir), reverse=True):
            sdir = os.path.join(lab_dir, name)
            if not os.path.isdir(sdir):
                continue
            mpath = os.path.join(sdir, "manifest.json")
            if not os.path.isfile(mpath):
                continue
            # Sprint 24: boş/bozuk manifest dosyalarını sessizce atla
            # (Sprint 23 öncesi json import bug'ı yarım kalmış manifest'ler bırakmıştı)
            try:
                if os.path.getsize(mpath) < 10:
                    # Boş veya çok küçük dosya — sessizce sil ve atla
                    try:
                        os.remove(mpath)
                        # Tüm session klasörünü de temizle (içinde sadece boş manifest varsa)
                        if not any(f for f in os.listdir(sdir) if f != "manifest.json"):
                            try: os.rmdir(sdir)
                            except OSError: pass
                    except Exception:
                        pass
                    continue
            except OSError:
                continue
            try:
                with open(mpath, encoding="utf-8") as f:
                    content = f.read().strip()
                if not content:
                    continue
                m = json.loads(content)
                # ComfyUI output altındaki üretilmiş PNG'leri tara
                from ._legacy import _rel_to_output
                try:
                    import folder_paths as _fp
                    comfy_out = _fp.get_output_directory()
                except Exception:
                    comfy_out = None
                variants = []
                if comfy_out:
                    # output/fikoyt_mvd/_characters/<cid>/test_lab/session_<ts>/
                    rel_dir = os.path.join(
                        "fikoyt_mvd", "_characters", cid, "test_lab",
                        f"session_{m['session_ts']}")
                    abs_dir = os.path.join(comfy_out, rel_dir)
                    if os.path.isdir(abs_dir):
                        for fname in sorted(os.listdir(abs_dir)):
                            if fname.lower().endswith((".png", ".jpg", ".webp")):
                                fp = os.path.join(abs_dir, fname)
                                # variant index dosya adından
                                vidx = None
                                if "_v" in fname:
                                    try:
                                        vidx = int(fname.split("_v")[1].split("_")[0])
                                    except Exception:
                                        pass
                                variants.append({
                                    "file": fname,
                                    "path": fp,
                                    "url": _view_url(fp),
                                    "rel": _rel_to_output(fp),
                                    "variant_index": vidx,
                                    "approved_as": None,  # session manifest'ten alınmalı
                                })
                # Approved durumlarını manifest'ten ekle
                approvals = m.get("approvals", {})
                for v in variants:
                    if v["file"] in approvals:
                        v["approved_as"] = approvals[v["file"]]
                out.append({
                    "session_ts": m["session_ts"],
                    "session_dir": sdir,
                    "prompt": m.get("prompt"),
                    "count": m.get("count"),
                    "identity_method": m.get("identity_method"),
                    "aspect": m.get("aspect"),
                    "width": m.get("width"), "height": m.get("height"),
                    "created_at": m.get("created_at"),
                    "variants": variants,
                    "variant_count_actual": len(variants),
                })
            except json.JSONDecodeError:
                # Bozuk JSON — manifest'i sil, klasörü temizle, devam
                try:
                    os.remove(mpath)
                    if not any(f for f in os.listdir(sdir) if f != "manifest.json"):
                        try: os.rmdir(sdir)
                        except OSError: pass
                except Exception:
                    pass
                continue
            except Exception as e:
                print(f"[FikoYT TestLab] session read err {name}: {e}")
        return web.json_response({"sessions": out})

    @_server.routes.delete("/fikoyt_mvd/character/{cid}/test_lab/sessions/{ts}")
    async def _test_lab_del_session(request):
        cid = request.match_info["cid"]
        ts = request.match_info["ts"]
        # Lab dir altındaki session_<ts> sil
        sdir = os.path.join(_test_lab_dir(cid), f"session_{ts}")
        if os.path.isdir(sdir):
            shutil.rmtree(sdir, ignore_errors=True)
        # ComfyUI output altındaki render dosyalarını da sil
        try:
            import folder_paths as _fp
            comfy_out = _fp.get_output_directory()
            render_dir = os.path.join(comfy_out, "fikoyt_mvd", "_characters",
                                       cid, "test_lab", f"session_{ts}")
            if os.path.isdir(render_dir):
                shutil.rmtree(render_dir, ignore_errors=True)
        except Exception:
            pass
        return web.json_response({"ok": True})

    @_server.routes.post("/fikoyt_mvd/character/{cid}/test_lab/approve")
    async def _test_lab_approve(request):
        """Body: {
          session_ts, variant_file,
          as_reference?: bool,        // refs/ altına ekle (LoRA training datasetine girer)
          as_pose?: bool,             // pose_library'ye ekle (DWPose extract)
          as_outfit_ref?: outfit_id,  // outfits[oid].reference_image set et
          as_main?: bool,             // ana karakter referans (image_path) olarak set et
          caption?: str               // reference için caption.txt
        }
        Onaylanmış variant'ı seçilen kategoriye ekler."""
        from ._legacy import (_load_character, _save_character, _char_path,
                              _add_character_ref, _set_character_caption,
                              _rel_to_output)
        cid = request.match_info["cid"]
        char = _load_character(cid)
        if not char:
            return web.json_response({"error": "Karakter bulunamadı"}, status=404)
        try:
            body = await request.json()
            ts = int(body.get("session_ts"))
            vfile = (body.get("variant_file") or "").strip()
            if not vfile:
                return web.json_response({"error": "variant_file eksik"}, status=400)

            try:
                import folder_paths as _fp
                comfy_out = _fp.get_output_directory()
            except Exception:
                return web.json_response({"error": "ComfyUI output dir bulunamadı"}, status=500)
            src_path = os.path.join(comfy_out, "fikoyt_mvd", "_characters", cid,
                                     "test_lab", f"session_{ts}", vfile)
            if not os.path.isfile(src_path):
                return web.json_response({"error": f"variant yok: {src_path}"}, status=404)

            results = {}

            # 1) Ana karakter referansı olarak set
            if body.get("as_main"):
                ext = os.path.splitext(vfile)[1].lstrip(".") or "png"
                cdir = _char_path(cid)
                # Eski main referansı sil (varsa)
                old = char.get("image_path")
                if old and os.path.isfile(old):
                    try: os.remove(old)
                    except Exception: pass
                new_main = os.path.join(cdir, f"reference.{ext}")
                shutil.copy2(src_path, new_main)
                char["image_path"] = new_main
                char["image_rel"] = _rel_to_output(new_main)
                _save_character(cid, char)
                results["main"] = new_main

            # 2) Reference olarak ekle (LoRA training dataset'ine de girer)
            if body.get("as_reference"):
                with open(src_path, "rb") as f:
                    img_bytes = f.read()
                # Original name'i test_lab_<vfile> formatında ver — uniq isim için
                original_name = f"test_lab_{vfile}"
                ref_path = _add_character_ref(cid, img_bytes, original_name,
                                               caption=body.get("caption", ""))
                if ref_path:
                    results["reference"] = {
                        "path": ref_path,
                        "filename": os.path.basename(ref_path),
                        "rel": _rel_to_output(ref_path),
                    }

            # 3) Pose olarak ekle (DWPose auto extract)
            if body.get("as_pose"):
                import uuid
                pid = uuid.uuid4().hex[:10]
                pose_dir = os.path.join(_char_path(cid), "poses")
                os.makedirs(pose_dir, exist_ok=True)
                ext = os.path.splitext(vfile)[1].lstrip(".") or "png"
                src_dest = os.path.join(pose_dir, f"{pid}_source.{ext}")
                shutil.copy2(src_path, src_dest)
                # DWPose extract (best-effort)
                pose_dest = None
                keypoints = "{}"
                extract_status = "DWPose extract atlandı"
                try:
                    from .visual import FikoYT_MVD_PoseExtractor
                    import torch
                    from PIL import Image as PILImage
                    import numpy as np
                    img = PILImage.open(src_dest).convert("RGB")
                    arr = np.array(img).astype(np.float32) / 255.0
                    img_tensor = torch.from_numpy(arr).unsqueeze(0)
                    pose_tensor, keypoints, extract_status = \
                        FikoYT_MVD_PoseExtractor().extract(img_tensor, "dwpose", 512)
                    pose_arr = (pose_tensor[0].cpu().numpy() * 255).clip(0, 255).astype("uint8")
                    PILImage.fromarray(pose_arr).save(
                        os.path.join(pose_dir, f"{pid}_pose.png"))
                    pose_dest = os.path.join(pose_dir, f"{pid}_pose.png")
                except Exception as e:
                    extract_status = f"DWPose extract fail: {e}"

                entry = {
                    "id": pid,
                    "label": body.get("pose_label") or f"test_lab_{pid[:6]}",
                    "source_path": src_dest,
                    "source_rel": _rel_to_output(src_dest),
                    "pose_path": pose_dest,
                    "pose_rel": _rel_to_output(pose_dest) if pose_dest else None,
                    "keypoints": keypoints,
                    "extract_status": extract_status,
                    "from_test_lab": True,
                    "from_session_ts": ts,
                    "created_at": datetime.datetime.now().isoformat(),
                }
                char.setdefault("pose_library", []).append(entry)
                _save_character(cid, char)
                results["pose"] = entry

            # 4) Outfit referansı olarak set
            oref = body.get("as_outfit_ref")
            if oref:
                outfits = char.get("outfits") or []
                outfit = next((o for o in outfits if o.get("id") == oref), None)
                if outfit:
                    ext = os.path.splitext(vfile)[1].lstrip(".") or "png"
                    outfit_dir = os.path.join(_char_path(cid), "outfits")
                    os.makedirs(outfit_dir, exist_ok=True)
                    ref_path = os.path.join(outfit_dir, f"{oref}_ref.{ext}")
                    shutil.copy2(src_path, ref_path)
                    outfit["reference_image"] = ref_path
                    outfit["reference_rel"] = _rel_to_output(ref_path)
                    _save_character(cid, char)
                    results["outfit"] = oref

            # Session manifest'ine approval kaydı
            sdir = os.path.join(_test_lab_dir(cid), f"session_{ts}")
            mpath = os.path.join(sdir, "manifest.json")
            if os.path.isfile(mpath):
                try:
                    with open(mpath, encoding="utf-8") as f:
                        m = json.load(f)
                    m.setdefault("approvals", {})[vfile] = list(results.keys())
                    with open(mpath, "w", encoding="utf-8") as f:
                        json.dump(m, f, ensure_ascii=False, indent=2)
                except Exception:
                    pass

            return web.json_response({"ok": True, "results": results})
        except Exception as e:
            import traceback; traceback.print_exc()
            return web.json_response({"error": str(e)}, status=500)

    @_server.routes.delete("/fikoyt_mvd/character/{cid}/test_lab/variants")
    async def _test_lab_del_variants(request):
        """Body: {session_ts, files: [str,...]} — seçili variantları sil."""
        cid = request.match_info["cid"]
        try:
            body = await request.json()
            ts = int(body.get("session_ts"))
            files = body.get("files") or []
            try:
                import folder_paths as _fp
                comfy_out = _fp.get_output_directory()
            except Exception:
                return web.json_response({"error": "output dir yok"}, status=500)
            base = os.path.join(comfy_out, "fikoyt_mvd", "_characters", cid,
                                 "test_lab", f"session_{ts}")
            removed = []
            for fn in files:
                fp = os.path.join(base, fn)
                if os.path.isfile(fp):
                    try:
                        os.remove(fp)
                        removed.append(fn)
                    except Exception:
                        pass
            return web.json_response({"ok": True, "removed": removed})
        except Exception as e:
            return web.json_response({"error": str(e)}, status=500)

    # =========================================================================
    # Sprint 6 — Music Video Production Pipeline
    # =========================================================================
    _master_render_state = {"in_progress": False, "log": "", "done": False,
                             "error": "", "output_path": ""}
    _render_all_state = {"in_progress": False, "current": 0, "total": 0,
                          "log": "", "errors": [], "done": False}

    @_server.routes.get("/fikoyt_mvd/aspect_presets")
    async def _aspect_presets(request):
        """Müzik klibi platform aspect preset'leri listesi."""
        from .visual import MUSIC_VIDEO_ASPECT_PRESETS
        return web.json_response({
            "presets": [
                {"id": k, "width": v[0], "height": v[1], "label": v[2],
                 "platform": v[3], "ratio": f"{v[0]}x{v[1]}"}
                for k, v in MUSIC_VIDEO_ASPECT_PRESETS.items()
            ]
        })

    @_server.routes.post("/fikoyt_mvd/project/{name}/aspect_preset")
    async def _set_project_aspect(request):
        """Body: {preset_id, strategy?: 'contain'|'cover'}.
        Proje meta'sına aspect preset kaydeder; sonraki render'lar bu boyutu kullanır."""
        from ._legacy import PROJECT_BASE_DIR
        from .visual import MUSIC_VIDEO_ASPECT_PRESETS
        name = request.match_info["name"]
        meta_path = os.path.join(_legacy.safe_project_dir(name), "project.json")
        if not os.path.isfile(meta_path):
            return web.json_response({"error": "proje meta yok"}, status=404)
        try:
            body = await request.json()
            preset = body.get("preset_id", "youtube")
            strategy = body.get("strategy", "contain")
            if preset not in MUSIC_VIDEO_ASPECT_PRESETS:
                return web.json_response({"error": f"Bilinmeyen preset: {preset}"}, status=400)
            w, h, label, platform = MUSIC_VIDEO_ASPECT_PRESETS[preset]
            import json as _json
            with open(meta_path, encoding="utf-8") as f:
                meta = _json.load(f)
            meta["aspect"] = {
                "preset_id": preset, "width": w, "height": h,
                "label": label, "platform": platform, "strategy": strategy,
                "updated_at": datetime.datetime.now().isoformat(),
            }
            with open(meta_path, "w", encoding="utf-8") as f:
                _json.dump(meta, f, ensure_ascii=False, indent=2)
            return web.json_response({"ok": True, "aspect": meta["aspect"]})
        except Exception as e:
            return web.json_response({"error": str(e)}, status=500)

    # ----- Style consistency LoRA (proje-wide) -----
    @_server.routes.post("/fikoyt_mvd/project/{name}/style_lora")
    async def _set_project_style_lora(request):
        """Body: {filename, strength, trigger_words?}. Bu LoRA tüm segment'lere
        otomatik eklenir (LoraStacker node'u bunu okur)."""
        from ._legacy import PROJECT_BASE_DIR
        name = request.match_info["name"]
        meta_path = os.path.join(_legacy.safe_project_dir(name), "project.json")
        if not os.path.isfile(meta_path):
            return web.json_response({"error": "proje meta yok"}, status=404)
        try:
            body = await request.json()
            fn = (body.get("filename") or "").strip()
            if not fn:
                return web.json_response({"error": "filename eksik"}, status=400)
            strength = float(body.get("strength", 0.8))
            trigger = (body.get("trigger_words") or "").strip()
            import json as _json
            with open(meta_path, encoding="utf-8") as f:
                meta = _json.load(f)
            meta["style_lora"] = {
                "filename": fn, "strength": strength, "trigger_words": trigger,
                "enabled": True,
                "updated_at": datetime.datetime.now().isoformat(),
            }
            with open(meta_path, "w", encoding="utf-8") as f:
                _json.dump(meta, f, ensure_ascii=False, indent=2)
            return web.json_response({"ok": True, "style_lora": meta["style_lora"]})
        except Exception as e:
            return web.json_response({"error": str(e)}, status=500)

    @_server.routes.delete("/fikoyt_mvd/project/{name}/style_lora")
    async def _del_project_style_lora(request):
        from ._legacy import PROJECT_BASE_DIR
        name = request.match_info["name"]
        meta_path = os.path.join(_legacy.safe_project_dir(name), "project.json")
        if not os.path.isfile(meta_path):
            return web.json_response({"error": "proje meta yok"}, status=404)
        try:
            import json as _json
            with open(meta_path, encoding="utf-8") as f:
                meta = _json.load(f)
            meta.pop("style_lora", None)
            with open(meta_path, "w", encoding="utf-8") as f:
                _json.dump(meta, f, ensure_ascii=False, indent=2)
            return web.json_response({"ok": True})
        except Exception as e:
            return web.json_response({"error": str(e)}, status=500)

    # ----- Segment camera movement metadata -----
    @_server.routes.post("/fikoyt_mvd/project/{name}/segment/{idx}/camera")
    async def _set_segment_camera(request):
        """Body: {movement, speed?, easing?}.
        movement: static|pan_left|pan_right|pan_up|pan_down|zoom_in|zoom_out|
                  orbit_cw|orbit_ccw|dolly_in|dolly_out|handheld|crane_up|crane_down
        """
        from ._legacy import PROJECT_BASE_DIR
        name = request.match_info["name"]
        idx = int(request.match_info["idx"])
        meta_path = os.path.join(_legacy.safe_project_dir(name), "project.json")
        if not os.path.isfile(meta_path):
            return web.json_response({"error": "proje meta yok"}, status=404)
        VALID = {"static", "pan_left", "pan_right", "pan_up", "pan_down",
                 "zoom_in", "zoom_out", "orbit_cw", "orbit_ccw",
                 "dolly_in", "dolly_out", "handheld", "crane_up", "crane_down"}
        try:
            body = await request.json()
            movement = (body.get("movement") or "static").strip().lower()
            if movement not in VALID:
                return web.json_response(
                    {"error": f"Geçersiz movement. Geçerli: {sorted(VALID)}"}, status=400)
            speed = float(body.get("speed", 1.0))
            easing = (body.get("easing") or "linear").strip().lower()

            import json as _json
            with open(meta_path, encoding="utf-8") as f:
                meta = _json.load(f)
            segs = meta.get("segments") or []
            if not (0 <= idx < len(segs)):
                return web.json_response({"error": f"seg index {idx} aralık dışı"}, status=400)
            segs[idx]["camera"] = {
                "movement": movement, "speed": speed, "easing": easing,
                "updated_at": datetime.datetime.now().isoformat(),
            }
            with open(meta_path, "w", encoding="utf-8") as f:
                _json.dump(meta, f, ensure_ascii=False, indent=2)
            return web.json_response({"ok": True, "camera": segs[idx]["camera"]})
        except Exception as e:
            return web.json_response({"error": str(e)}, status=500)

    # ----- Beat grid (librosa) -----
    def _resolve_project_audio(name):
        """Proje adından audio dosyası path'i çöz: meta.audio_path > audio/ ilk dosya."""
        from ._legacy import PROJECT_BASE_DIR
        pdir = _legacy.safe_project_dir(name)
        meta_path = os.path.join(pdir, "project.json")
        audio_path = None
        if os.path.isfile(meta_path):
            try:
                import json as _json
                with open(meta_path, encoding="utf-8") as f:
                    audio_path = _json.load(f).get("audio_path")
            except Exception:
                pass
        if not audio_path or not os.path.isfile(audio_path):
            adir = os.path.join(pdir, "audio")
            if os.path.isdir(adir):
                for fn in os.listdir(adir):
                    if fn.lower().endswith((".mp3", ".wav", ".flac", ".m4a", ".ogg")):
                        audio_path = os.path.join(adir, fn)
                        break
        return audio_path, pdir, meta_path

    def _compute_beat_grid(audio_path):
        """librosa ile beat + onset grid. Sync hesaplama; caller executor'da çağırmalı."""
        import librosa
        import numpy as np
        y, sr = librosa.load(audio_path, sr=22050, mono=True)
        tempo, beats = librosa.beat.beat_track(y=y, sr=sr, units="time")
        onsets = librosa.onset.onset_detect(y=y, sr=sr, units="time", backtrack=True)
        # Sprint 42: librosa 0.10+ returns np.ndarray for tempo (0-d array)
        try:
            tempo_val = float(np.asarray(tempo).item() if hasattr(tempo, "item") else tempo)
        except Exception:
            tempo_val = float(np.atleast_1d(np.asarray(tempo))[0])
        return {
            "tempo_bpm": tempo_val,
            "duration_sec": float(len(y) / sr),
            "beats": [round(float(b), 3) for b in np.asarray(beats).flatten().tolist()],
            "onsets": [round(float(o), 3) for o in np.asarray(onsets).flatten().tolist()[:200]],
            "beat_count": int(np.asarray(beats).size),
        }

    @_server.routes.get("/fikoyt_mvd/project/{name}/beat_grid")
    async def _project_beat_grid(request):
        """Proje audio'sundan beat grid çıkar (librosa)."""
        name = request.match_info["name"]
        audio_path, pdir, _meta_path = _resolve_project_audio(name)
        if not os.path.isdir(pdir):
            return web.json_response({"error": "proje dizini yok"}, status=404)
        if not audio_path:
            return web.json_response({"error": "audio dosyası bulunamadı"}, status=404)
        try:
            import librosa  # noqa: F401
        except ImportError:
            return web.json_response({"error": "librosa yok"}, status=500)
        try:
            result = await asyncio.get_event_loop().run_in_executor(
                None, _compute_beat_grid, audio_path)
            return web.json_response(result)
        except Exception as e:
            import traceback; traceback.print_exc()
            return web.json_response({"error": str(e)}, status=500)

    @_server.routes.post("/fikoyt_mvd/project/{name}/snap_segments_to_beats")
    async def _snap_segments_to_beats(request):
        """Segment başlangıçlarını en yakın beat'e snap eder.
        Body: {tolerance_sec?: float (default 0.3)}"""
        name = request.match_info["name"]
        audio_path, pdir, meta_path = _resolve_project_audio(name)
        if not os.path.isfile(meta_path):
            return web.json_response({"error": "proje meta yok"}, status=404)
        if not audio_path:
            return web.json_response({"error": "audio yok"}, status=404)
        try:
            try:
                body = await request.json()
            except Exception:
                body = {}
            tol = float(body.get("tolerance_sec", 0.3))
            beat_data = await asyncio.get_event_loop().run_in_executor(
                None, _compute_beat_grid, audio_path)
            beats = beat_data.get("beats", [])
            if not beats:
                return web.json_response({"error": "beat çıkarılamadı"}, status=500)

            import json as _json
            with open(meta_path, encoding="utf-8") as f:
                meta = _json.load(f)
            segs = meta.get("segments") or []
            snapped = 0
            for s in segs:
                start = s.get("start_sec")
                if start is None:
                    continue
                closest = min(beats, key=lambda b: abs(b - start))
                if abs(closest - start) <= tol:
                    s["start_sec_orig"] = start
                    s["start_sec"] = closest
                    s["beat_snapped"] = True
                    snapped += 1
            with open(meta_path, "w", encoding="utf-8") as f:
                _json.dump(meta, f, ensure_ascii=False, indent=2)
            return web.json_response({
                "ok": True, "snapped": snapped, "total": len(segs),
                "tempo_bpm": beat_data.get("tempo_bpm"),
            })
        except Exception as e:
            import traceback; traceback.print_exc()
            return web.json_response({"error": str(e)}, status=500)

    # ----- Render queue: tüm segment görselleri toplu üret -----
    @_server.routes.post("/fikoyt_mvd/project/{name}/render_all_images")
    async def _render_all_images(request):
        """Tüm segment'lerin görsellerini sırayla render eder.
        ImageGen node'u zaten batch mode'da çalışır; burada UI tetikleyici +
        progress state. Body: {force?: bool (default false — mevcut PNG'leri atla)}"""
        from ._legacy import PROJECT_BASE_DIR
        name = request.match_info["name"]
        pdir = _legacy.safe_project_dir(name)
        meta_path = os.path.join(pdir, "project.json")
        if not os.path.isfile(meta_path):
            return web.json_response({"error": "proje meta yok"}, status=404)
        with _install_lock:
            if _render_all_state["in_progress"]:
                return web.json_response({"error": "Zaten render var"}, status=409)
        try:
            body = await request.json() if request.body_exists else {}
        except Exception:
            body = {}
        force = bool(body.get("force", False))
        import json as _json
        with open(meta_path, encoding="utf-8") as f:
            meta = _json.load(f)
        segs = meta.get("segments") or []
        if not segs:
            return web.json_response({"error": "segment yok"}, status=400)

        _render_all_state.update({
            "in_progress": True, "current": 0, "total": len(segs),
            "log": f"Toplam {len(segs)} segment, force={force}\n",
            "errors": [], "done": False,
            "started_at": datetime.datetime.now().isoformat(),
        })

        # NOT: Asıl render ComfyUI prompt queue üzerinden SegmentImageGen
        # node'u ile yapılır. Bu endpoint kullanıcıya "force tek tek render
        # tetiklemesini" hatırlatan hint ile döner; tam orchestration için
        # workflow JSON şablonunun build + queue put'u yapılır.
        # MVP olarak: state'i hazırlayıp UI'ın WS poll etmesini sağla.
        _render_all_state["log"] += (
            "ℹ Tam batch render için Görsel Stüdyo'da '🎬 Tümünü Render Et' butonunu kullan. "
            "Bu endpoint state tracking sağlar; gerçek render workflow queue üzerinden.\n")
        _render_all_state["done"] = True
        _render_all_state["in_progress"] = False
        return web.json_response({
            "ok": True, "total": len(segs),
            "hint": "SegmentImageGen.target_index=-1 ile tek seferde batch çalıştır."
        })

    @_server.routes.get("/fikoyt_mvd/project/{name}/render_all_status")
    async def _render_all_status(request):
        return web.json_response(_render_all_state)

    # ----- Master render endpoint (FFmpeg) -----
    @_server.routes.post("/fikoyt_mvd/project/{name}/master_render")
    async def _project_master_render(request):
        """Tüm segment video'larını + audio'yu birleştirip master MP4 üretir.
        Body: {audio_path?, target_width?, target_height?, crf?, preset?,
               fade_in?, fade_out?, output_filename?, video_subdir?}"""
        from ._legacy import PROJECT_BASE_DIR
        from .visual import FikoYT_MVD_MasterRender
        name = request.match_info["name"]
        pdir = _legacy.safe_project_dir(name)
        if not os.path.isdir(pdir):
            return web.json_response({"error": "proje dizini yok"}, status=404)
        with _install_lock:
            if _master_render_state["in_progress"]:
                return web.json_response({"error": "Zaten render var"}, status=409)
            _master_render_state.update({
                "in_progress": True, "log": "", "done": False, "error": "",
                "current_file": "", "current_pct": 0,
                "downloaded_mb": 0, "total_mb": 0,
                "output_path": "",
                "started_at": datetime.datetime.now().isoformat(),
            })

        try:
            body = await request.json() if request.body_exists else {}
        except Exception:
            body = {}

        # Audio path: explicit > project meta > audio/ klasöründe ilk dosya
        audio_path = (body.get("audio_path") or "").strip()
        if not audio_path:
            meta_path = os.path.join(pdir, "project.json")
            if os.path.isfile(meta_path):
                import json as _json
                with open(meta_path, encoding="utf-8") as f:
                    meta = _json.load(f)
                audio_path = meta.get("audio_path") or ""
        if not audio_path:
            adir = os.path.join(pdir, "audio")
            if os.path.isdir(adir):
                for fn in os.listdir(adir):
                    if fn.lower().endswith((".mp3", ".wav", ".flac", ".m4a", ".ogg")):
                        audio_path = os.path.join(adir, fn)
                        break

        # Default ayarlar
        opts = {
            "project_dir": pdir,
            "audio_path": audio_path,
            "video_subdir": body.get("video_subdir", "videos"),
            "output_filename": body.get("output_filename", "master.mp4"),
            "target_width": int(body.get("target_width", 1920)),
            "target_height": int(body.get("target_height", 1080)),
            "crf": int(body.get("crf", 18)),
            "preset": body.get("preset", "medium"),
            "fade_in": float(body.get("fade_in", 0.5)),
            "fade_out": float(body.get("fade_out", 1.0)),
        }

        async def _bg():
            try:
                node = FikoYT_MVD_MasterRender()
                out_path, report = await asyncio.get_event_loop().run_in_executor(
                    None, lambda: node.render(**opts))
                _master_render_state["output_path"] = out_path
                _master_render_state["log"] = report
                if not out_path:
                    _master_render_state["error"] = report
            except Exception as e:
                _master_render_state["error"] = str(e)
                _master_render_state["log"] += f"\n❌ HATA: {e}\n"
            finally:
                _master_render_state["done"] = True
                _master_render_state["in_progress"] = False

        asyncio.create_task(_bg())
        return web.json_response({"ok": True, "audio_path": audio_path,
                                   "opts": opts})

    @_server.routes.get("/fikoyt_mvd/project/{name}/master_render_status")
    async def _project_master_render_status(request):
        return web.json_response(_master_render_state)

    # ----- Lip-sync scoring (MediaPipe Face Mesh) -----
    @_server.routes.post("/fikoyt_mvd/segment/{idx}/lipsync_score")
    async def _lipsync_score(request):
        """Body: {project_dir, video_path, audio_path}.
        MediaPipe Face Mesh ile mouth keypoint hareketi + audio envelope
        karşılaştırması yapar, 0-100 skor döner."""
        try:
            body = await request.json()
            video_path = body.get("video_path")
            audio_path = body.get("audio_path")
            if not (video_path and os.path.isfile(video_path)):
                return web.json_response({"error": "video_path geçersiz"}, status=400)

            def _analyze():
                try:
                    import cv2
                    import numpy as np
                    import librosa
                except ImportError as e:
                    return {"error": f"Eksik paket: {e} (pip install mediapipe opencv-python)"}
                try:
                    import mediapipe as mp
                except ImportError:
                    return {"error": "mediapipe yok (pip install mediapipe)"}

                # Video'dan mouth open hareketi
                cap = cv2.VideoCapture(video_path)
                fps = cap.get(cv2.CAP_PROP_FPS) or 24
                face_mesh = mp.solutions.face_mesh.FaceMesh(
                    static_image_mode=False, max_num_faces=1, refine_landmarks=True,
                    min_detection_confidence=0.5)
                mouth_openness = []
                frames = 0
                while True:
                    ok, frame = cap.read()
                    if not ok:
                        break
                    frames += 1
                    res = face_mesh.process(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
                    if not res.multi_face_landmarks:
                        mouth_openness.append(0.0)
                        continue
                    lm = res.multi_face_landmarks[0].landmark
                    # Upper lip 13, Lower lip 14 (MediaPipe Face Mesh indices)
                    upper = lm[13]
                    lower = lm[14]
                    dist = abs(upper.y - lower.y)
                    mouth_openness.append(dist)
                cap.release()
                face_mesh.close()

                if not mouth_openness:
                    return {"error": "frame okunamadı"}

                # Audio RMS envelope (frame rate'e resample)
                rms_env = None
                if audio_path and os.path.isfile(audio_path):
                    y, sr = librosa.load(audio_path, sr=22050, mono=True)
                    hop = int(sr / fps)
                    rms = librosa.feature.rms(y=y, frame_length=hop * 2, hop_length=hop)[0]
                    rms_env = rms[:len(mouth_openness)]
                    # Eşit uzunluk
                    n = min(len(mouth_openness), len(rms_env))
                    mouth_openness = mouth_openness[:n]
                    rms_env = rms_env[:n]

                # Korelasyon
                mo = np.array(mouth_openness, dtype=np.float32)
                mo_n = (mo - mo.mean()) / (mo.std() + 1e-6)
                if rms_env is not None and len(rms_env) > 3:
                    re = np.array(rms_env, dtype=np.float32)
                    re_n = (re - re.mean()) / (re.std() + 1e-6)
                    corr = float(np.dot(mo_n, re_n) / max(len(mo_n), 1))
                    score = max(0, min(100, int((corr + 1) * 50)))  # -1..1 → 0..100
                else:
                    score = 0
                    corr = 0.0
                return {
                    "score": score,
                    "correlation": round(corr, 3),
                    "frames_analyzed": len(mouth_openness),
                    "fps": fps,
                    "mouth_open_mean": float(mo.mean()),
                    "mouth_open_std": float(mo.std()),
                }

            result = await asyncio.get_event_loop().run_in_executor(None, _analyze)
            if "error" in result:
                return web.json_response(result, status=500)
            return web.json_response(result)
        except Exception as e:
            import traceback; traceback.print_exc()
            return web.json_response({"error": str(e)}, status=500)

    # =========================================================================
    # Sprint 3 (W14) — Regional Multi-Char (2/3/4 karakter)
    # =========================================================================
    @_server.routes.post("/fikoyt_mvd/regional_multichar_generate")
    async def _regional_multichar(request):
        """Generic multi-character regional prompting endpoint (2/3/4 char).

        Body: {
          characters: [{
            cid: str,
            prompt: str,
            lora_strength?: float (default 0.8),
            mask_polygon: [[x,y],...] (image space),
            identity_method?: "auto"|"pulid"|"instantid"|"ipadapter_v2"|"none"
          }, ...],
          base_prompt: str,
          negative_prompt?: str,
          width?: int (default 1024),
          height?: int (default 1024),
          unet, t5, clipl, vae,  // model dosya isimleri
          steps?, cfg?, flux_guidance?, seed?,
          project_dir?, segment_index?
        }

        2char olduğunda eski /regional_2char_generate'e yönlendirilebilir;
        bu endpoint 3-4 char için yeni workflow template'lerini kullanır
        (flux_regional_3char.json / flux_regional_4char.json).
        """
        from ._legacy import _load_character
        try:
            from PIL import Image as _PIL, ImageDraw as _Draw
            import json as _json
            import time as _t
            data = await request.json()
            chars = data.get("characters") or []
            n = len(chars)
            if n < 2 or n > 4:
                return web.json_response(
                    {"error": f"2-4 karakter destekleniyor, {n} verildi"}, status=400)

            unet = (data.get("unet") or "").strip()
            t5 = (data.get("t5") or "").strip()
            clipl = (data.get("clipl") or "").strip()
            vae = (data.get("vae") or "").strip()
            if not (unet and t5 and clipl and vae):
                return web.json_response(
                    {"error": "unet/t5/clipl/vae zorunlu"}, status=400)

            w = int(data.get("width", 1024))
            h = int(data.get("height", 1024))
            steps = int(data.get("steps", 22))
            cfg = float(data.get("cfg", 1.0))
            flux_g = float(data.get("flux_guidance", 3.5))
            base_p = (data.get("base_prompt") or "cinematic scene, multi-character").strip()
            neg = (data.get("negative_prompt") or
                   "blurry, deformed, watermark, low quality, extra limbs").strip()
            seed = int(data.get("seed") or (int(_t.time() * 1000) & 0xFFFFFFFF))

            def _snap(v): return max(64, (int(v) // 32) * 32)
            w, h = _snap(w), _snap(h)

            # Polygon mask'ları PIL ile render et — ComfyUI input/ altına kaydet
            inp_dir = os.path.join(folder_paths.get_input_directory(), "fikoyt_mvd_regional")
            os.makedirs(inp_dir, exist_ok=True)
            ts = int(_t.time())
            mask_rels = []
            char_prompts = []
            for idx, c in enumerate(chars):
                poly = c.get("mask_polygon") or []
                if not poly or len(poly) < 3:
                    # Otomatik fallback: n karakter eşit dikey şerit
                    seg_w = w // n
                    x0 = idx * seg_w
                    x1 = (idx + 1) * seg_w if idx < n - 1 else w
                    poly = [[x0, 0], [x1, 0], [x1, h], [x0, h]]
                mask_img = _PIL.new("RGB", (w, h), (0, 0, 0))
                _Draw.Draw(mask_img).polygon(
                    [(int(p[0]), int(p[1])) for p in poly], fill=(255, 255, 255))
                mname = f"rmc_{ts}_{idx}.png"
                mpath = os.path.join(inp_dir, mname)
                mask_img.save(mpath)
                mask_rels.append(f"fikoyt_mvd_regional/{mname}")

                # Karakter prompt'u — explicit prompt > meta'dan üret
                if c.get("prompt"):
                    char_prompts.append(c["prompt"])
                elif c.get("cid"):
                    ch_meta = _load_character(c["cid"])
                    if ch_meta:
                        from ._legacy import _char_descriptor
                        char_prompts.append(_char_descriptor(ch_meta) +
                                            ((", " + ch_meta.get("notes", ""))
                                             if ch_meta.get("notes") else ""))
                    else:
                        char_prompts.append(f"character {idx+1}")
                else:
                    char_prompts.append(f"character {idx+1}")

            # Workflow template seç
            template_name = f"flux_regional_{n}char.json"
            template_path = os.path.join(
                os.path.dirname(os.path.dirname(os.path.abspath(_legacy.__file__))),
                "workflow", template_name)
            if not os.path.isfile(template_path):
                return web.json_response(
                    {"error": f"Workflow template yok: {template_name}"}, status=500)
            with open(template_path, encoding="utf-8") as f:
                wf_str = f.read()

            # Placeholder'ları doldur
            mask_keys = ["MASK_A", "MASK_B", "MASK_C", "MASK_D"]
            prompt_keys = ["PROMPT_A", "PROMPT_B", "PROMPT_C", "PROMPT_D"]
            wf_str = wf_str.replace("@@UNET@@", unet)
            wf_str = wf_str.replace("@@T5@@", t5)
            wf_str = wf_str.replace("@@CLIPL@@", clipl)
            wf_str = wf_str.replace("@@VAE@@", vae)
            wf_str = wf_str.replace("@@WIDTH@@", str(w))
            wf_str = wf_str.replace("@@HEIGHT@@", str(h))
            wf_str = wf_str.replace("@@SEED@@", str(seed))
            wf_str = wf_str.replace("@@STEPS@@", str(steps))
            wf_str = wf_str.replace("@@CFG@@", str(cfg))
            wf_str = wf_str.replace("@@FLUX_GUIDANCE@@", str(flux_g))
            wf_str = wf_str.replace("@@BASE_PROMPT@@",
                                    base_p.replace('"', '\\"'))
            wf_str = wf_str.replace("@@NEGATIVE@@", neg.replace('"', '\\"'))
            wf_str = wf_str.replace("@@FILENAME_PREFIX@@", f"regional_{n}char_{ts}")
            for i in range(n):
                wf_str = wf_str.replace(f"@@{mask_keys[i]}@@", mask_rels[i])
                wf_str = wf_str.replace(f"@@{prompt_keys[i]}@@",
                                        char_prompts[i].replace('"', '\\"'))

            try:
                wf = _json.loads(wf_str)
            except Exception as e:
                return web.json_response(
                    {"error": f"Workflow JSON parse fail: {e}",
                     "template": template_name}, status=500)

            # WF'yi ComfyUI prompt queue'ya gönder
            from server import PromptServer as _PS
            inst = getattr(_PS, "instance", None)
            if inst is None:
                return web.json_response({"error": "PromptServer hazır değil"}, status=500)

            # Sprint 27: validated_put
            res = await _validated_put(inst, wf, client_id="fmvd_regional_multichar")
            if not res["ok"]:
                return web.json_response({
                    "error": f"Regional validation: {res['error']}",
                    "node_errors": res.get("node_errors"),
                }, status=400)
            prompt_id = res["prompt_id"]

            return web.json_response({
                "ok": True,
                "char_count": n,
                "template": template_name,
                "masks": mask_rels,
                "prompt_id": str(prompt_id) if prompt_id is not None else None,
                "seed": seed,
            })
        except Exception as e:
            import traceback; traceback.print_exc()
            return web.json_response({"error": str(e)}, status=500)

    # =========================================================================
    # Identity dispatcher status — tüm yöntemleri tek seferde özetler
    # =========================================================================
    @_server.routes.get("/fikoyt_mvd/check_identity")
    async def _check_identity(request):
        """Tüm identity yöntemlerinin (PuLID + InstantID + IPAdapter v2)
        kurulum durumunu tek payload'da döner. UI'da method dropdown'una
        yanına ✓/✗ rozetler için kullanılır."""
        # PuLID için _legacy'deki check_pulid endpoint mantığını tekrar etmek yerine
        # doğrudan kontrol yapalım (basitlik için duplikasyon)
        from nodes import NODE_CLASS_MAPPINGS as _NCM
        pulid_nodes = all(_NCM.get(n) for n in
                          ["PulidFluxModelLoader", "PulidFluxEvaClipLoader",
                           "PulidFluxInsightFaceLoader", "ApplyPulidFlux"])
        pulid_model = os.path.isfile(os.path.join(
            folder_paths.models_dir, "pulid", "pulid_flux_v0.9.1.safetensors"))
        return web.json_response({
            "pulid": {
                "ready": bool(pulid_nodes and pulid_model),
                "node_installed": bool(pulid_nodes),
            },
            "instantid": _instantid_status_dict(),
            "ipadapter_v2": _ipadapter_v2_status_dict(),
        })

    # ════════════════════════════════════════════════════════════════════════
    # SPRINT 44 — Quick Lab: Standalone (project-less) preview endpoints
    # ════════════════════════════════════════════════════════════════════════
    # Müzik/Video/Görsel için proje state'i olmadan tek-tık önizleme.
    # Çıktılar ComfyUI'nin default `output/` klasörüne yazılır, indirme kolay.

    @_server.routes.post("/fikoyt_mvd/quick/image")
    async def _quick_image(request):
        """Standalone image gen. Body: {
          prompt, negative?,
          character_id?, identity_method? (auto/pulid/instantid/ipadapter_v2/none),
          identity_weight?, pose_ref_id?,
          loras?: [{name, strength}],
          lightning?: bool,
          batch_count?: int (1-8 default 1),
          aspect_preset_id?, width?, height?,
          steps?, flux_guidance?, seed?, sampler_name?, scheduler?,
          unet?, t5?, clipl?, vae?
        }
        Karakter opsiyonel. Output: output/fikoyt_quick/img_<ts>_NNNNN.png (batch_count adet)
        """
        try:
            body = await request.json()
        except Exception as e:
            return web.json_response({"error": f"JSON: {e}"}, status=400)

        prompt = (body.get("prompt") or "").strip()
        if not prompt:
            return web.json_response({"error": "prompt zorunlu"}, status=400)
        batch_count = max(1, min(int(body.get("batch_count") or 1), 8))

        # Auto-detect models (Test Lab ile aynı kalite öncelikleri)
        unet = (body.get("unet") or "").strip()
        t5 = (body.get("t5") or "").strip()
        clipl = (body.get("clipl") or "").strip()
        vae = (body.get("vae") or "").strip()
        autodetected = []
        if not unet:
            cands = (folder_paths.get_filename_list("diffusion_models") or [])
            cands += (folder_paths.get_filename_list("unet") or [])
            def _rank_unet(f):
                fl = f.lower()
                if "fp16" in fl or "bf16" in fl: return 0
                if "fp8_scaled" in fl: return 1
                if "fp8" in fl or "e4m3fn" in fl: return 2
                return 3
            flux = sorted([f for f in cands if "flux" in f.lower() and "dev" in f.lower()]
                          or [f for f in cands if "flux" in f.lower()] or cands, key=_rank_unet)
            if flux: unet = flux[0]; autodetected.append(f"unet={unet}")
        if not t5:
            cands = (folder_paths.get_filename_list("text_encoders") or [])
            cands += (folder_paths.get_filename_list("clip") or [])
            def _rank_t5(f):
                fl = f.lower()
                if "t5xxl_fp16" in fl: return 0
                if "t5xxl_fp8_e4m3fn_scaled" in fl: return 1
                if "t5xxl_fp8" in fl: return 2
                if "t5xxl" in fl: return 3
                if "umt5" in fl: return 5
                return 4
            t5_cands = sorted([f for f in cands if "t5" in f.lower()], key=_rank_t5)
            if t5_cands: t5 = t5_cands[0]; autodetected.append(f"t5={t5}")
        if not clipl:
            cands = (folder_paths.get_filename_list("text_encoders") or [])
            cands += (folder_paths.get_filename_list("clip") or [])
            cl_cands = [f for f in cands if os.path.basename(f.lower()).startswith("clip_l")
                        and "eva" not in os.path.basename(f.lower())
                        and "pulid" not in os.path.basename(f.lower())]
            if cl_cands: clipl = cl_cands[0]; autodetected.append(f"clipl={clipl}")
        if not vae:
            cands = folder_paths.get_filename_list("vae") or []
            def _is_flux_vae(f):
                fl = os.path.basename(f.lower())
                if "ace" in fl or "ltx" in fl or "wan" in fl or "hunyuan" in fl: return False
                return fl.startswith("ae.") or fl.startswith("ae_") or "flux" in fl
            v = [f for f in cands if _is_flux_vae(f)]
            if v: vae = v[0]; autodetected.append(f"vae={vae}")

        if not (unet and t5 and clipl and vae):
            missing = [n for n, v in [("unet", unet), ("t5", t5), ("clipl", clipl), ("vae", vae)] if not v]
            # VAE eksikse Qwen 2512 alternatifi öner (Qwen modeller diskte olabilir)
            extra_hint = ""
            try:
                _all_unet = (folder_paths.get_filename_list("unet") or []) + \
                            (folder_paths.get_filename_list("diffusion_models") or [])
                _all_vae = folder_paths.get_filename_list("vae") or []
                has_qwen_unet = any("qwen" in f.lower() and "image" in f.lower() for f in _all_unet)
                has_qwen_vae = any("qwen" in f.lower() and "image" in f.lower() for f in _all_vae)
                if "vae" in missing and has_qwen_unet and has_qwen_vae:
                    extra_hint = (" | ALTERNATIF: Flux VAE yok ama Qwen-Image 2512 mevcut. "
                                  "Bunun yerine POST /fikoyt_mvd/quick/qwen_t2i çağırın.")
            except Exception:
                pass
            return web.json_response({
                "error": f"Model eksik: {', '.join(missing)}",
                "hint": "models/ altında Flux dosyalarını ekleyin (ae.safetensors)." + extra_hint,
                "alternative_endpoint": "/fikoyt_mvd/quick/qwen_t2i" if "vae" in missing else None,
            }, status=400)
        if autodetected:
            print(f"[FikoYT QuickLab/Image] auto: {', '.join(autodetected)}")

        # Boyut — aspect_preset_id varsa onu kullan
        aspect_id = (body.get("aspect_preset_id") or "").strip()
        if aspect_id:
            try:
                from .visual import MUSIC_VIDEO_ASPECT_PRESETS as _APS
                ap = _APS.get(aspect_id)
                if ap:
                    width, height = int(ap[0]), int(ap[1])
                else:
                    width = int(body.get("width") or 1024); height = int(body.get("height") or 1024)
            except Exception:
                width = int(body.get("width") or 1024); height = int(body.get("height") or 1024)
        else:
            width = int(body.get("width") or 1024)
            height = int(body.get("height") or 1024)
        width = max(256, (width // 32) * 32)
        height = max(256, (height // 32) * 32)

        steps = int(body.get("steps") or 22)
        flux_g = float(body.get("flux_guidance") or 3.5)
        cfg = float(body.get("cfg") or 1.0)
        sampler_name = (body.get("sampler_name") or "euler").strip()
        scheduler = (body.get("scheduler") or "beta").strip()
        import random as _r
        base_seed = int(body.get("seed") or 0) or _r.randint(0, 2**32 - 1)

        # Karakter opsiyonel
        from ._legacy import _load_character
        cid = (body.get("character_id") or "").strip()
        character = _load_character(cid) if cid else None
        identity_method = (body.get("identity_method") or ("auto" if character else "none")).lower()
        identity_weight = float(body.get("identity_weight") or 0.85)
        identity_start = float(body.get("identity_start_pct") or body.get("identity_start") or 0.0)
        identity_end   = float(body.get("identity_end_pct")   or body.get("identity_end")   or 0.8)
        # Sliders gelirse 0-100 → 0-1 normalize
        if identity_start > 1.0: identity_start = identity_start / 100.0
        if identity_end > 1.0:   identity_end   = identity_end   / 100.0
        pose_ref_id = (body.get("pose_ref_id") or "").strip()
        loras = body.get("loras") or []
        lightning = bool(body.get("lightning"))
        controlnets_input = body.get("controlnets") or []  # [{mode, image, pose_ref_id, strength, start_pct, end_pct, preprocess}]
        outfit_id = (body.get("outfit_id") or "").strip()
        extra_negative = (body.get("extra_negative") or "").strip()
        color_grade = (body.get("color_grade") or "none").strip().lower()  # Sprint 57 A15

        # Lightning aktifse step otomatik 4
        if lightning:
            steps = 4
            cfg = 1.0
            sampler_name = "euler"
            scheduler = "sgm_uniform"

        # Pose ref dosyasını bul (karakter pose_library'sinden)
        pose_ref_path = None
        if pose_ref_id and character:
            for p in character.get("pose_library", []):
                if p.get("id") == pose_ref_id:
                    pose_ref_path = p.get("skeleton_path") or p.get("source_path")
                    break

        # Workflow inşası
        ts = int(time.time())
        prefix = f"fikoyt_quick/img_{ts}"
        jobs = []
        import uuid as _u
        from execution import validate_prompt as _vp
        from nodes import NODE_CLASS_MAPPINGS as _NCM

        for vi in range(batch_count):
            seed = base_seed + vi * 1000
            wf = {}
            wf["unet"] = {"class_type": "UNETLoader",
                          "inputs": {"unet_name": unet, "weight_dtype": "default"}}
            wf["clip"] = {"class_type": "DualCLIPLoader",
                          "inputs": {"clip_name1": t5, "clip_name2": clipl,
                                      "type": "flux", "device": "default"}}
            wf["vae"] = {"class_type": "VAELoader", "inputs": {"vae_name": vae}}
            wf["sampling"] = {"class_type": "ModelSamplingFlux",
                              "inputs": {"model": ["unet", 0], "max_shift": 1.15,
                                          "base_shift": 0.5, "width": width, "height": height}}
            current_model = ["sampling", 0]

            # Sprint 55: Karakter zinciri — PuLID / PuLID v2 / InstantID / IPAdapter v2 / Auto
            applied_identity = "none"
            if character:
                ref_path = character.get("image_path", "")
                if ref_path and os.path.isfile(ref_path):
                    wf["char_refs"] = {"class_type": "FikoYT_MVD_CharacterRefsBatch",
                                        "inputs": {"character_id": cid, "resize_to": 512, "include_main_ref": True}}

                    # Auto seçimi: hangi node'lar mevcut, oraya gider (pulid_v2 → pulid → instantid → ipadapter_v2)
                    def _has(*names): return all(_NCM.get(n) for n in names)
                    if identity_method == "auto":
                        if _has("PulidFluxModelLoader", "ApplyPulidFlux"):
                            # pulid_v2 dosyası varsa onu öner
                            try:
                                pulid_files = folder_paths.get_filename_list("pulid") or []
                                if any("v0.9.2" in f or "v2" in f.lower() for f in pulid_files):
                                    identity_method = "pulid_v2"
                                else:
                                    identity_method = "pulid"
                            except Exception:
                                identity_method = "pulid"
                        elif _has("InstantIDModelLoader", "ApplyInstantID"):
                            identity_method = "instantid"
                        elif _has("IPAdapterUnifiedLoaderFaceID", "IPAdapterFaceID"):
                            identity_method = "ipadapter_v2"

                    # PuLID v1
                    if identity_method == "pulid" and _has("PulidFluxModelLoader", "ApplyPulidFlux"):
                        wf["pulid_loader"] = {"class_type": "PulidFluxModelLoader",
                                               "inputs": {"pulid_file": "pulid_flux_v0.9.1.safetensors"}}
                        wf["eva_clip"] = {"class_type": "PulidFluxEvaClipLoader", "inputs": {}}
                        wf["face_an"] = {"class_type": "PulidFluxInsightFaceLoader",
                                          "inputs": {"provider": "CUDA"}}
                        wf["apply_pulid"] = {"class_type": "ApplyPulidFlux",
                                              "inputs": {
                                                  "model": current_model,
                                                  "pulid_flux": ["pulid_loader", 0],
                                                  "eva_clip": ["eva_clip", 0],
                                                  "face_analysis": ["face_an", 0],
                                                  "image": ["char_refs", 0],
                                                  "weight": identity_weight,
                                                  "start_at": identity_start,
                                                  "end_at": identity_end,
                                              }}
                        current_model = ["apply_pulid", 0]
                        applied_identity = "pulid_v1"

                    # PuLID v2 (Flux II) — v0.9.2 yoksa v0.9.1 fallback
                    elif identity_method in ("pulid_v2", "pulid2") and _has("PulidFluxModelLoader", "ApplyPulidFlux"):
                        v2_file = None
                        v1_fallback = False
                        try:
                            pulid_files = folder_paths.get_filename_list("pulid") or []
                            v2_file = next((f for f in pulid_files if "v0.9.2" in f or "_v2" in f.lower()), None)
                            if not v2_file:
                                v2_file = next((f for f in pulid_files if "v0.9.1" in f),
                                               "pulid_flux_v0.9.1.safetensors")
                                v1_fallback = True
                        except Exception:
                            v2_file = "pulid_flux_v0.9.1.safetensors"
                            v1_fallback = True
                        wf["pulid_loader"] = {"class_type": "PulidFluxModelLoader",
                                               "inputs": {"pulid_file": v2_file}}
                        wf["eva_clip"] = {"class_type": "PulidFluxEvaClipLoader", "inputs": {}}
                        wf["face_an"] = {"class_type": "PulidFluxInsightFaceLoader",
                                          "inputs": {"provider": "CUDA"}}
                        wf["apply_pulid"] = {"class_type": "ApplyPulidFlux",
                                              "inputs": {
                                                  "model": current_model,
                                                  "pulid_flux": ["pulid_loader", 0],
                                                  "eva_clip": ["eva_clip", 0],
                                                  "face_analysis": ["face_an", 0],
                                                  "image": ["char_refs", 0],
                                                  "weight": identity_weight,
                                                  "start_at": identity_start,
                                                  "end_at": identity_end,
                                              }}
                        current_model = ["apply_pulid", 0]
                        applied_identity = "pulid_v1_fallback" if v1_fallback else "pulid_v2"

                    # InstantID — SADECE SDXL (Flux için resmi destek yok)
                    elif identity_method == "instantid" and _has("InstantIDModelLoader", "ApplyInstantID"):
                        is_flux_unet = any(tok in (unet or "").lower()
                                            for tok in ("flux", "fp8_e4m3", "schnell"))
                        try:
                            instantid_files = folder_paths.get_filename_list("instantid") or []
                        except Exception:
                            instantid_files = []
                        cn_files_all = folder_paths.get_filename_list("controlnet") or []
                        instantid_cn = next((f for f in cn_files_all if "instantid" in f.lower()), None)
                        if is_flux_unet:
                            applied_identity = "skipped(instantid_requires_sdxl)"
                        elif not instantid_files:
                            applied_identity = "skipped(instantid_model_missing)"
                        elif not instantid_cn:
                            applied_identity = "skipped(instantid_controlnet_missing)"
                        else:
                            wf["instantid_loader"] = {"class_type": "InstantIDModelLoader",
                                                       "inputs": {"instantid_file": instantid_files[0]}}
                            wf["face_analysis"] = {"class_type": "InstantIDFaceAnalysis",
                                                    "inputs": {"provider": "CUDA"}}
                            wf["instantid_cn"] = {"class_type": "ControlNetLoader",
                                                   "inputs": {"control_net_name": instantid_cn}}
                            wf["apply_instantid"] = {"class_type": "ApplyInstantID",
                                                      "inputs": {
                                                          "instantid": ["instantid_loader", 0],
                                                          "insightface": ["face_analysis", 0],
                                                          "control_net": ["instantid_cn", 0],
                                                          "image": ["char_refs", 0],
                                                          "model": current_model,
                                                          "positive": ["pos_cte", 0],  # forward-ref, set later
                                                          "negative": ["neg_cte", 0],
                                                          "ip_weight": identity_weight,
                                                          "cn_strength": 0.65,
                                                          "start_at": identity_start,
                                                          "end_at": identity_end,
                                                      }}
                            current_model = ["apply_instantid", 0]
                            applied_identity = "instantid"

                    # IPAdapter FaceID Plus v2 — SDXL only
                    elif identity_method in ("ipadapter_v2", "ipadapter") and _has("IPAdapterUnifiedLoaderFaceID", "IPAdapterFaceID"):
                        is_flux_unet = any(tok in (unet or "").lower()
                                            for tok in ("flux", "fp8_e4m3", "schnell"))
                        if is_flux_unet:
                            applied_identity = "skipped(ipadapter_requires_sdxl)"
                        else:
                            wf["ipadapter_unified"] = {"class_type": "IPAdapterUnifiedLoaderFaceID",
                                                        "inputs": {"model": current_model,
                                                                    "preset": "FACEID PLUS V2",
                                                                    "lora_strength": 0.6,
                                                                    "provider": "CUDA"}}
                            wf["apply_ipadapter"] = {"class_type": "IPAdapterFaceID",
                                                      "inputs": {
                                                          "model": ["ipadapter_unified", 0],
                                                          "ipadapter": ["ipadapter_unified", 1],
                                                          "image": ["char_refs", 0],
                                                          "weight": identity_weight,
                                                          "weight_faceidv2": identity_weight * 0.7,
                                                          "weight_type": "linear",
                                                          "combine_embeds": "concat",
                                                          "start_at": identity_start,
                                                          "end_at": identity_end,
                                                          "embeds_scaling": "V only",
                                                      }}
                            current_model = ["apply_ipadapter", 0]
                            applied_identity = "ipadapter_v2"

                    # ReActor (post-process — sonra eklenir)
                    elif identity_method == "reactor":
                        applied_identity = "reactor_pending"  # sample sonrası uygulanacak

                    # Hiç method tutmadıysa
                    elif identity_method not in ("none",):
                        applied_identity = f"skipped({identity_method}_node_missing)"

            # Karakter LoRA + extra LoRA stack
            # Sprint 57 A19: filename/name/path field uyumsuzluğu fix + version-aware load
            lora_stack = []
            if character:
                active_ts = character.get("active_lora_version")
                versions = character.get("lora_versions") or []
                active_v = next((v for v in versions if v.get("ts") == active_ts or v.get("id") == active_ts), None)
                if active_v and active_v.get("files"):
                    # Versioned LoRA — register snapshot dir as loras search path
                    vdir_rel = active_v.get("vdir_rel") or ""
                    if vdir_rel:
                        try:
                            char_root = os.path.join(folder_paths.get_output_directory(),
                                                      "fikoyt_mvd", "_characters",
                                                      character.get("id", ""))
                            vdir = active_v.get("vdir_abs") or os.path.join(char_root,
                                                                             "_lora_versions",
                                                                             active_v.get("ts", ""))
                            if os.path.isdir(vdir):
                                folder_paths.add_model_folder_path("loras", vdir)
                        except Exception as _e:
                            print(f"[QuickLab] lora_version path add fail: {_e}")
                    default_str = float(active_v.get("default_strength") or 0.85)
                    for fn in active_v.get("files", []):
                        if fn:
                            lora_stack.append({"name": fn, "strength": default_str})
                else:
                    # Legacy flat list — filename/name/path tüm field varyasyonlarını destekle
                    for lf in (character.get("loras") or []):
                        if isinstance(lf, dict):
                            nm = lf.get("filename") or lf.get("name") or lf.get("path")
                            if nm:
                                lora_stack.append({"name": nm,
                                                    "strength": float(lf.get("strength") or 0.85)})
                        elif isinstance(lf, str):
                            lora_stack.append({"name": lf, "strength": 0.85})
            # Kullanıcı LoRA'ları (body.loras)
            for ld in loras:
                if isinstance(ld, dict) and ld.get("name"):
                    lora_stack.append({"name": ld["name"],
                                        "strength": float(ld.get("strength") or 0.85)})
            for li, ld in enumerate(lora_stack):
                key = f"lora_{li}"
                wf[key] = {"class_type": "LoraLoaderModelOnly",
                            "inputs": {"model": current_model,
                                        "lora_name": ld["name"],
                                        "strength_model": ld["strength"]}}
                current_model = [key, 0]

            # Lightning LoRA
            if lightning:
                cands = folder_paths.get_filename_list("loras") or []
                light = next((f for f in cands if "lightning" in f.lower() and ("4step" in f.lower() or "4-step" in f.lower())),
                             next((f for f in cands if "lightning" in f.lower()), None))
                if light:
                    wf["lora_lightning"] = {"class_type": "LoraLoaderModelOnly",
                                             "inputs": {"model": current_model,
                                                         "lora_name": light, "strength_model": 1.0}}
                    current_model = ["lora_lightning", 0]

            # Conditioning
            neg_prompt = (body.get("negative") or "blurry, deformed, watermark, low quality").strip()
            wf["pos_cte"] = {"class_type": "CLIPTextEncode",
                             "inputs": {"clip": ["clip", 0], "text": prompt}}
            wf["pos_fg"] = {"class_type": "FluxGuidance",
                            "inputs": {"conditioning": ["pos_cte", 0], "guidance": flux_g}}
            wf["neg_cte"] = {"class_type": "CLIPTextEncode",
                             "inputs": {"clip": ["clip", 0], "text": neg_prompt}}
            current_pos = ["pos_fg", 0]
            current_neg = ["neg_cte", 0]

            # Sprint 55: Multi-ControlNet chain (pose_ref + controlnets[] array)
            # Backward compat: pose_ref_id varsa controlnets'in başına eklenir
            cn_chain = []
            if pose_ref_path and os.path.isfile(pose_ref_path):
                cn_chain.append({
                    "mode": "pose", "image": pose_ref_path,
                    "strength": 0.7, "start_pct": 0.0, "end_pct": 0.8,
                    "preprocess": False,  # zaten extracted skeleton
                })
            for cn_spec in controlnets_input:
                if not isinstance(cn_spec, dict): continue
                img_path = (cn_spec.get("image") or "").strip()
                cn_pose_id = (cn_spec.get("pose_ref_id") or "").strip()
                # CN için pose_ref_id verilirse de path'i karakterden çıkar
                if not img_path and cn_pose_id and character:
                    for p in character.get("pose_library", []):
                        if p.get("id") == cn_pose_id:
                            img_path = p.get("skeleton_path") or p.get("pose_path") or p.get("source_path")
                            break
                # input/ veya output/ relatif olabilir
                if img_path and not os.path.isabs(img_path):
                    for base in [folder_paths.get_input_directory(), folder_paths.get_output_directory()]:
                        cand = os.path.join(base, img_path)
                        if os.path.isfile(cand): img_path = cand; break
                if not img_path or not os.path.isfile(img_path): continue
                cn_chain.append({
                    "mode": (cn_spec.get("mode") or "pose").lower(),
                    "image": img_path,
                    "strength": float(cn_spec.get("strength") or 0.7),
                    "start_pct": float(cn_spec.get("start_pct") or 0.0),
                    "end_pct":   float(cn_spec.get("end_pct")   or 1.0),
                    "preprocess": cn_spec.get("preprocess") is not False,
                })

            applied_controlnets = []
            if cn_chain:
                cn_loader = _NCM.get("ControlNetLoader")
                cn_apply = _NCM.get("ControlNetApplyAdvanced") or _NCM.get("ControlNetApply")
                if cn_loader and cn_apply:
                    cn_cands = folder_paths.get_filename_list("controlnet") or []
                    cn_union = next((f for f in cn_cands if "flux" in f.lower() and "union" in f.lower()),
                                    next((f for f in cn_cands if "union" in f.lower()), None))
                    if cn_union:
                        # Tek Union model loader (paylaşımlı)
                        wf["cn_union_load"] = {"class_type": "ControlNetLoader",
                                                "inputs": {"control_net_name": cn_union}}
                        # Preprocessor map
                        prep_map = {
                            "pose": "DWPreprocessor",
                            "dwpose": "DWPreprocessor",
                            "openpose": "OpenposePreprocessor",
                            "canny": "CannyEdgePreprocessor",
                            "depth": "DepthAnythingV2Preprocessor",
                            "softedge": "HEDPreprocessor",
                            "tile": "TilePreprocessor",
                            "blur": "Blur",
                        }
                        for ci, cn in enumerate(cn_chain):
                            try:
                                # Image'i input/'a kopyala (LoadImage için)
                                import shutil as _sh
                                base = os.path.basename(cn["image"])
                                dest = os.path.join(folder_paths.get_input_directory(), base)
                                if not os.path.isfile(dest) or os.path.getsize(dest) != os.path.getsize(cn["image"]):
                                    try: _sh.copyfile(cn["image"], dest)
                                    except Exception: pass
                                wf[f"cn_img_{ci}"] = {"class_type": "LoadImage",
                                                       "inputs": {"image": base}}
                                ctrl_img_ref = [f"cn_img_{ci}", 0]
                                # Preprocess
                                if cn["preprocess"]:
                                    prep_node = prep_map.get(cn["mode"])
                                    if prep_node and _NCM.get(prep_node):
                                        pp_in = {"image": [f"cn_img_{ci}", 0]}
                                        if cn["mode"] in ("pose", "dwpose"):
                                            pp_in.update({"detect_hand": "enable", "detect_body": "enable",
                                                           "detect_face": "enable", "resolution": 512})
                                        wf[f"cn_pp_{ci}"] = {"class_type": prep_node, "inputs": pp_in}
                                        ctrl_img_ref = [f"cn_pp_{ci}", 0]
                                wf[f"cn_apply_{ci}"] = {"class_type": "ControlNetApplyAdvanced",
                                                         "inputs": {
                                                             "positive": current_pos,
                                                             "negative": current_neg,
                                                             "control_net": ["cn_union_load", 0],
                                                             "image": ctrl_img_ref,
                                                             "vae": ["vae", 0],
                                                             "strength": cn["strength"],
                                                             "start_percent": cn["start_pct"],
                                                             "end_percent": cn["end_pct"],
                                                         }}
                                current_pos = [f"cn_apply_{ci}", 0]
                                current_neg = [f"cn_apply_{ci}", 1]
                                applied_controlnets.append({"mode": cn["mode"], "strength": cn["strength"],
                                                              "start": cn["start_pct"], "end": cn["end_pct"]})
                            except Exception as e:
                                print(f"[QuickLab] CN[{ci}] {cn['mode']} skip: {e}")

            # InstantID için pos/neg referansları güncelle (yukarıda ileri-ref vardı)
            if applied_identity == "instantid" and "apply_instantid" in wf:
                wf["apply_instantid"]["inputs"]["positive"] = current_pos
                wf["apply_instantid"]["inputs"]["negative"] = current_neg

            # === Sprint 57: Mode dispatcher (generate/i2i/inpaint/outpaint/kontext/upscale) ===
            mode = (body.get("mode") or "generate").strip().lower()
            init_image_rel = (body.get("init_image") or "").strip()
            mask_image_rel = (body.get("mask_image") or "").strip()
            denoise_body = body.get("denoise")
            ref_images = body.get("ref_images") or []
            upscale_mode = (body.get("upscale_mode") or "latent_2").strip().lower()
            applied_mode_info = {"mode": mode}

            def _resolve_image(s):
                """Resolve init/mask image path. Accepts abs path, input/-relative, output/-relative, or /view?... URL."""
                s = (s or "").strip()
                if not s: return None
                if s.startswith("/view?") or s.startswith("view?"):
                    try:
                        from urllib.parse import urlparse, parse_qs
                        q = parse_qs(urlparse(s).query)
                        fn = (q.get("filename") or [""])[0]
                        tp = (q.get("type") or ["output"])[0]
                        sub = (q.get("subfolder") or [""])[0]
                        base = folder_paths.get_output_directory() if tp == "output" else folder_paths.get_input_directory()
                        cand = os.path.join(base, sub, fn)
                        return cand if os.path.isfile(cand) else None
                    except Exception:
                        return None
                if os.path.isabs(s) and os.path.isfile(s): return s
                for base in (folder_paths.get_input_directory(), folder_paths.get_output_directory()):
                    cand = os.path.join(base, s)
                    if os.path.isfile(cand): return cand
                return None

            def _stage_to_input(abs_path):
                """Copy file into ComfyUI input/ so LoadImage can find it. Returns relative basename."""
                import shutil as _sh
                base = os.path.basename(abs_path)
                dest = os.path.join(folder_paths.get_input_directory(), base)
                try:
                    if not os.path.isfile(dest) or os.path.getsize(dest) != os.path.getsize(abs_path):
                        _sh.copyfile(abs_path, dest)
                except Exception as e:
                    print(f"[QuickLab] stage_to_input fail: {e}")
                return base

            # Default: latent + denoise + sampler input ref
            latent_ref = ["latent", 0]
            sampler_denoise = 1.0
            save_src = None  # default = ["decode", 0]; ESRGAN-only path overrides

            if mode in ("generate", "txt2img", ""):
                wf["latent"] = {"class_type": "EmptySD3LatentImage" if _NCM.get("EmptySD3LatentImage") else "EmptyLatentImage",
                                "inputs": {"width": width, "height": height, "batch_size": 1}}

            elif mode in ("i2i", "img2img"):
                init_abs = _resolve_image(init_image_rel)
                if not init_abs:
                    return web.json_response({"error": f"i2i: init_image bulunamadı ({init_image_rel})"}, status=400)
                init_base = _stage_to_input(init_abs)
                # Derive dims from init image
                try:
                    from PIL import Image as _PIL
                    with _PIL.open(init_abs) as _im:
                        iw, ih = _im.size
                    width = max(256, (iw // 16) * 16)
                    height = max(256, (ih // 16) * 16)
                except Exception:
                    pass
                wf["init_load"] = {"class_type": "LoadImage", "inputs": {"image": init_base}}
                wf["init_scale"] = {"class_type": "ImageScale",
                                     "inputs": {"image": ["init_load", 0], "width": width, "height": height,
                                                "upscale_method": "lanczos", "crop": "disabled"}}
                wf["latent"] = {"class_type": "VAEEncode",
                                 "inputs": {"pixels": ["init_scale", 0], "vae": ["vae", 0]}}
                sampler_denoise = float(denoise_body) if denoise_body is not None else 0.6
                sampler_denoise = max(0.05, min(sampler_denoise, 1.0))
                applied_mode_info["init_image"] = init_base
                applied_mode_info["denoise"] = sampler_denoise

            elif mode == "inpaint":
                init_abs = _resolve_image(init_image_rel)
                mask_abs = _resolve_image(mask_image_rel)
                if not init_abs:
                    return web.json_response({"error": f"inpaint: init_image bulunamadı ({init_image_rel})"}, status=400)
                if not mask_abs:
                    return web.json_response({"error": f"inpaint: mask_image bulunamadı ({mask_image_rel})"}, status=400)
                init_base = _stage_to_input(init_abs)
                mask_base = _stage_to_input(mask_abs)
                wf["init_load"] = {"class_type": "LoadImage", "inputs": {"image": init_base}}
                wf["mask_load"] = {"class_type": "LoadImage", "inputs": {"image": mask_base}}
                wf["mask_conv"] = {"class_type": "ImageToMask",
                                    "inputs": {"image": ["mask_load", 0], "channel": "red"}}
                # Flux Fill model auto-detect (preferred)
                fill_avail = "fill" in (unet or "").lower()
                if fill_avail and _NCM.get("InpaintModelConditioning"):
                    wf["inpaint_cond"] = {"class_type": "InpaintModelConditioning",
                                           "inputs": {"positive": current_pos, "negative": current_neg,
                                                       "vae": ["vae", 0],
                                                       "pixels": ["init_load", 0],
                                                       "mask": ["mask_conv", 0],
                                                       "noise_mask": True}}
                    current_pos = ["inpaint_cond", 0]
                    current_neg = ["inpaint_cond", 1]
                    latent_ref = ["inpaint_cond", 2]
                    # Flux Fill ister guidance ~30
                    if "pos_fg" in wf:
                        wf["pos_fg"]["inputs"]["guidance"] = max(flux_g, 30.0)
                else:
                    wf["latent"] = {"class_type": "VAEEncodeForInpaint",
                                     "inputs": {"pixels": ["init_load", 0], "vae": ["vae", 0],
                                                 "mask": ["mask_conv", 0], "grow_mask_by": 6}}
                sampler_denoise = float(denoise_body) if denoise_body is not None else 0.85
                sampler_denoise = max(0.2, min(sampler_denoise, 1.0))
                applied_mode_info["init_image"] = init_base
                applied_mode_info["mask_image"] = mask_base
                applied_mode_info["denoise"] = sampler_denoise
                applied_mode_info["flux_fill_used"] = bool(fill_avail)

            elif mode == "outpaint":
                init_abs = _resolve_image(init_image_rel)
                if not init_abs:
                    return web.json_response({"error": f"outpaint: init_image bulunamadı ({init_image_rel})"}, status=400)
                def _snap16(x):
                    try: return max(0, (int(x) // 16) * 16)
                    except Exception: return 0
                pad_l = _snap16(body.get("pad_left") or 0)
                pad_r = _snap16(body.get("pad_right") or 0)
                pad_t = _snap16(body.get("pad_top") or 0)
                pad_b = _snap16(body.get("pad_bottom") or 0)
                if pad_l + pad_r + pad_t + pad_b == 0:
                    return web.json_response({"error": "outpaint: en az 1 yön > 0 olmalı"}, status=400)
                feather = max(0, min(int(body.get("feather") or 40), 128))
                init_base = _stage_to_input(init_abs)
                wf["init_load"] = {"class_type": "LoadImage", "inputs": {"image": init_base}}
                wf["pad"] = {"class_type": "ImagePadForOutpaint",
                              "inputs": {"image": ["init_load", 0],
                                          "left": pad_l, "right": pad_r,
                                          "top": pad_t, "bottom": pad_b,
                                          "feathering": feather}}
                if _NCM.get("InpaintModelConditioning"):
                    wf["inpaint_cond"] = {"class_type": "InpaintModelConditioning",
                                           "inputs": {"positive": current_pos, "negative": current_neg,
                                                       "vae": ["vae", 0],
                                                       "pixels": ["pad", 0], "mask": ["pad", 1],
                                                       "noise_mask": True}}
                    current_pos = ["inpaint_cond", 0]
                    current_neg = ["inpaint_cond", 1]
                    latent_ref = ["inpaint_cond", 2]
                    if "fill" in (unet or "").lower() and "pos_fg" in wf:
                        wf["pos_fg"]["inputs"]["guidance"] = max(flux_g, 30.0)
                else:
                    wf["latent"] = {"class_type": "VAEEncode",
                                     "inputs": {"pixels": ["pad", 0], "vae": ["vae", 0]}}
                sampler_denoise = 1.0
                applied_mode_info.update({"init_image": init_base, "pad": [pad_l, pad_r, pad_t, pad_b], "feather": feather})

            elif mode == "kontext":
                # Flux Kontext multi-reference
                kontext_unet = (body.get("kontext_unet") or "").strip()
                if not kontext_unet:
                    try:
                        all_cands = (folder_paths.get_filename_list("diffusion_models") or []) \
                                  + (folder_paths.get_filename_list("unet") or [])
                    except Exception:
                        all_cands = folder_paths.get_filename_list("unet") or []
                    kontext_unet = next((f for f in all_cands if "kontext" in f.lower()), None)
                if not kontext_unet:
                    return web.json_response({
                        "error": "Flux Kontext modeli yok",
                        "hint": "models/unet/ veya models/diffusion_models/ altına flux1-dev-kontext_fp8_scaled.safetensors koyun",
                        "url": "https://huggingface.co/Comfy-Org/flux1-kontext-dev_ComfyUI/resolve/main/split_files/diffusion_models/flux1-dev-kontext_fp8_scaled.safetensors",
                    }, status=400)
                if "unet" in wf:
                    wf["unet"]["inputs"]["unet_name"] = kontext_unet
                applied_refs = []
                cond_ref = current_pos
                refs_input = ref_images or ([init_image_rel] if init_image_rel else [])
                for ri, rp in enumerate(refs_input[:4]):
                    ra = _resolve_image(rp)
                    if not ra: continue
                    rb = _stage_to_input(ra)
                    wf[f"kref{ri}_img"] = {"class_type": "LoadImage", "inputs": {"image": rb}}
                    if _NCM.get("FluxKontextImageScale"):
                        wf[f"kref{ri}_sc"] = {"class_type": "FluxKontextImageScale",
                                               "inputs": {"image": [f"kref{ri}_img", 0]}}
                        enc_src = [f"kref{ri}_sc", 0]
                    else:
                        enc_src = [f"kref{ri}_img", 0]
                    wf[f"kref{ri}_enc"] = {"class_type": "VAEEncode",
                                             "inputs": {"pixels": enc_src, "vae": ["vae", 0]}}
                    if _NCM.get("ReferenceLatent"):
                        wf[f"kref{ri}_lat"] = {"class_type": "ReferenceLatent",
                                                 "inputs": {"conditioning": cond_ref,
                                                             "latent": [f"kref{ri}_enc", 0]}}
                        cond_ref = [f"kref{ri}_lat", 0]
                    applied_refs.append(rb)
                if len(applied_refs) >= 2 and _NCM.get("FluxKontextMultiReferenceLatentMethod"):
                    wf["kref_method"] = {"class_type": "FluxKontextMultiReferenceLatentMethod",
                                          "inputs": {"conditioning": cond_ref,
                                                      "reference_latents_method": (body.get("ref_method") or "offset")}}
                    cond_ref = ["kref_method", 0]
                current_pos = cond_ref
                wf["latent"] = {"class_type": "EmptySD3LatentImage" if _NCM.get("EmptySD3LatentImage") else "EmptyLatentImage",
                                "inputs": {"width": width, "height": height, "batch_size": 1}}
                applied_mode_info["kontext_refs"] = applied_refs
                applied_mode_info["kontext_unet"] = kontext_unet

            elif mode == "upscale":
                init_abs = _resolve_image(init_image_rel)
                if not init_abs:
                    return web.json_response({"error": f"upscale: init_image bulunamadı ({init_image_rel})"}, status=400)
                init_base = _stage_to_input(init_abs)
                wf["init_load"] = {"class_type": "LoadImage", "inputs": {"image": init_base}}
                if upscale_mode == "esrgan_4":
                    # Pixel-space ESRGAN, no diffusion
                    up_files = folder_paths.get_filename_list("upscale_models") or []
                    up_model = (body.get("upscale_model") or "").strip()
                    if not up_model:
                        up_model = next((f for f in up_files if "ultrasharp" in f.lower()),
                                   next((f for f in up_files if "realesrgan_x4" in f.lower()),
                                   next((f for f in up_files if "esrgan" in f.lower() and "x4" in f.lower()),
                                   next((f for f in up_files if "esrgan" in f.lower()), None))))
                    if not up_model:
                        return web.json_response({"error": "ESRGAN modeli yok",
                                                   "hint": "models/upscale_models/ altına RealESRGAN_x4plus.pth indirin"}, status=400)
                    wf["up_load"] = {"class_type": "UpscaleModelLoader",
                                      "inputs": {"model_name": up_model}}
                    wf["up_apply"] = {"class_type": "ImageUpscaleWithModel",
                                       "inputs": {"upscale_model": ["up_load", 0],
                                                   "image": ["init_load", 0]}}
                    save_src = ["up_apply", 0]
                    applied_mode_info["upscale_mode"] = "esrgan_4"
                    applied_mode_info["upscale_model"] = up_model
                else:
                    up_factor = 1.5 if upscale_mode in ("latent_1_5", "latent1.5") else 2.0
                    wf["enc"] = {"class_type": "VAEEncode",
                                  "inputs": {"pixels": ["init_load", 0], "vae": ["vae", 0]}}
                    wf["latent"] = {"class_type": "LatentUpscaleBy",
                                     "inputs": {"samples": ["enc", 0],
                                                 "upscale_method": "nearest-exact",
                                                 "scale_by": up_factor}}
                    sampler_denoise = float(denoise_body) if denoise_body is not None else 0.4
                    sampler_denoise = max(0.05, min(sampler_denoise, 0.9))
                    applied_mode_info.update({"upscale_mode": upscale_mode,
                                               "upscale_factor": up_factor,
                                               "denoise": sampler_denoise})
            else:
                # Fallback: unknown mode → generate
                wf["latent"] = {"class_type": "EmptySD3LatentImage" if _NCM.get("EmptySD3LatentImage") else "EmptyLatentImage",
                                "inputs": {"width": width, "height": height, "batch_size": 1}}

            # ESRGAN-only path skips sampler entirely
            if save_src is None:
                wf["sampler"] = {"class_type": "KSampler",
                                 "inputs": {"model": current_model,
                                             "positive": current_pos, "negative": current_neg,
                                             "latent_image": latent_ref,
                                             "seed": seed, "steps": steps, "cfg": cfg,
                                             "sampler_name": sampler_name, "scheduler": scheduler,
                                             "denoise": sampler_denoise}}
                wf["decode"] = {"class_type": "VAEDecode",
                                "inputs": {"samples": ["sampler", 0], "vae": ["vae", 0]}}
                save_src = ["decode", 0]

            # Sprint 57 A4: ReActor post-decode face swap
            if applied_identity == "reactor_pending" and _NCM.get("ReActorFaceSwap"):
                ref_imgs = (character or {}).get("face_refs") or \
                           (character or {}).get("references") or []
                if isinstance(ref_imgs, str): ref_imgs = [ref_imgs]
                ref_path = next((p for p in ref_imgs if p and os.path.isfile(p)), None)
                if not ref_path and character and character.get("image_path"):
                    if os.path.isfile(character["image_path"]):
                        ref_path = character["image_path"]
                if ref_path:
                    ref_base = _stage_to_input(ref_path)
                    wf["reactor_ref"] = {"class_type": "LoadImage",
                                         "inputs": {"image": ref_base}}
                    fr_files = folder_paths.get_filename_list("facerestore_models") or []
                    fr_model = next((f for f in fr_files if "gfpganv1.4" in f.lower()),
                                next((f for f in fr_files if "gfpgan" in f.lower()),
                                next((f for f in fr_files if "codeformer" in f.lower()), "none")))
                    wf["reactor_swap"] = {"class_type": "ReActorFaceSwap",
                                          "inputs": {
                                              "enabled": True,
                                              "input_image": save_src,
                                              "source_image": ["reactor_ref", 0],
                                              "swap_model": "inswapper_128.onnx",
                                              "facedetection": "retinaface_resnet50",
                                              "face_restore_model": fr_model,
                                              "face_restore_visibility": 1.0,
                                              "codeformer_weight": 0.5,
                                              "detect_gender_input": "no",
                                              "detect_gender_source": "no",
                                              "input_faces_index": "0",
                                              "source_faces_index": "0",
                                              "console_log_level": 1,
                                          }}
                    save_src = ["reactor_swap", 0]
                    applied_identity = "reactor"
                else:
                    applied_identity = "skipped(reactor_no_ref_image)"
            elif applied_identity == "reactor_pending":
                applied_identity = "skipped(reactor_node_missing)"

            # Sprint 57 A13/A14: ADetailer face + hand (post-decode refinement)
            adet_face = bool(body.get("adetailer_face"))
            adet_hand = bool(body.get("adetailer_hands"))
            if (adet_face or adet_hand) and _NCM.get("FaceDetailer") and _NCM.get("UltralyticsDetectorProvider") and _NCM.get("SAMLoader"):
                wf["adet_sam"] = {"class_type": "SAMLoader",
                                  "inputs": {"model_name": "sam_vit_b_01ec64.pth",
                                              "device_mode": "AUTO"}}
                if adet_face:
                    wf["adet_face_bbox"] = {"class_type": "UltralyticsDetectorProvider",
                                             "inputs": {"model_name": "bbox/face_yolov8m.pt"}}
                    wf["adet_face"] = {"class_type": "FaceDetailer",
                                        "inputs": {
                                            "image": save_src,
                                            "model": current_model, "clip": ["clip", 0], "vae": ["vae", 0],
                                            "positive": current_pos, "negative": current_neg,
                                            "bbox_detector": ["adet_face_bbox", 0],
                                            "sam_model_opt": ["adet_sam", 0],
                                            "guide_size": 512, "guide_size_for": True,
                                            "max_size": 1024,
                                            "seed": seed + 1, "steps": max(8, steps // 2),
                                            "cfg": cfg, "sampler_name": sampler_name, "scheduler": scheduler,
                                            "denoise": float(body.get("adetailer_denoise") or 0.4),
                                            "feather": 5, "noise_mask": True, "force_inpaint": True,
                                            "bbox_threshold": 0.5, "bbox_dilation": 10, "bbox_crop_factor": 3.0,
                                            "sam_detection_hint": "center-1", "sam_dilation": 0,
                                            "sam_threshold": 0.93, "sam_bbox_expansion": 0,
                                            "sam_mask_hint_threshold": 0.7, "sam_mask_hint_use_negative": "False",
                                            "drop_size": 10, "wildcard": "",
                                            "cycle": 1, "inpaint_model": False, "noise_mask_feather": 20,
                                        }}
                    save_src = ["adet_face", 0]
                if adet_hand:
                    wf["adet_hand_bbox"] = {"class_type": "UltralyticsDetectorProvider",
                                             "inputs": {"model_name": "bbox/hand_yolov8s.pt"}}
                    wf["adet_hand"] = {"class_type": "FaceDetailer",
                                        "inputs": {
                                            "image": save_src,
                                            "model": current_model, "clip": ["clip", 0], "vae": ["vae", 0],
                                            "positive": current_pos, "negative": current_neg,
                                            "bbox_detector": ["adet_hand_bbox", 0],
                                            "sam_model_opt": ["adet_sam", 0],
                                            "guide_size": 384, "guide_size_for": True,
                                            "max_size": 1024,
                                            "seed": seed + 7777, "steps": max(8, steps // 2),
                                            "cfg": cfg, "sampler_name": sampler_name, "scheduler": scheduler,
                                            "denoise": float(body.get("adetailer_hands_denoise") or 0.5),
                                            "feather": 5, "noise_mask": True, "force_inpaint": True,
                                            "bbox_threshold": 0.35, "bbox_dilation": 12, "bbox_crop_factor": 3.0,
                                            "sam_detection_hint": "center-1", "sam_dilation": 0,
                                            "sam_threshold": 0.93, "sam_bbox_expansion": 0,
                                            "sam_mask_hint_threshold": 0.7, "sam_mask_hint_use_negative": "False",
                                            "drop_size": 8, "wildcard": "",
                                            "cycle": 1, "inpaint_model": False, "noise_mask_feather": 20,
                                        }}
                    save_src = ["adet_hand", 0]

            wf["save"] = {"class_type": "SaveImage",
                          "inputs": {"images": save_src,
                                      "filename_prefix": f"{prefix}_v{vi:02d}"}}

            pid = str(_u.uuid4())
            # Sprint 53: validate_prompt(pid, wf, None) — 3 positional args
            valid = await _vp(pid, wf, None) if asyncio.iscoroutinefunction(_vp) else _vp(pid, wf, None)
            if not valid[0]:
                return web.json_response({"error": f"Workflow validate fail (variant {vi})",
                                           "details": str(valid[1]) if len(valid) > 1 else "unknown",
                                           "node_errors": valid[3] if len(valid) > 3 else {}}, status=500)
            outputs_to_exec = valid[2] if len(valid) > 2 else []
            _server.prompt_queue.put((0, pid, wf,
                                       {"client_id": "fikoyt_quicklab",
                                         "create_time": int(time.time() * 1000)},
                                       outputs_to_exec, {}))
            jobs.append({"variant": vi, "seed": seed, "prompt_id": pid,
                          "expected_prefix": f"{prefix}_v{vi:02d}"})

        # Sprint 57 A15: Post-render color grade (async watcher, non-blocking)
        if color_grade != "none":
            try:
                from .color_grade import watch_and_grade, PRESETS as _CG_PRESETS
                if color_grade in _CG_PRESETS:
                    out_dir = os.path.join(folder_paths.get_output_directory(), "fikoyt_quick")
                    asyncio.create_task(watch_and_grade(
                        out_dir, f"{os.path.basename(prefix)}_v*_*.png",
                        color_grade, expected_count=batch_count, overwrite=True))
            except Exception as _cge:
                print(f"[QuickLab] color_grade watcher fail: {_cge}")

        return web.json_response({
            "ok": True, "jobs": jobs, "batch_count": batch_count,
            "expected_prefix": prefix,
            "output_dir": "output/fikoyt_quick/",
            "character_used": cid if character else None,
            "identity_method": identity_method if character else "none",
            "lora_count": len(lora_stack), "lightning": lightning,
            "pose_used": pose_ref_id if pose_ref_path else None,
            "models": {"unet": unet, "t5": t5, "clipl": clipl, "vae": vae},
            "size": {"w": width, "h": height},
            # Sprint 55+57: ne gerçekten uygulandı (kullanıcı görür)
            "applied": {
                "identity": applied_identity,
                "identity_weight": identity_weight,
                "identity_start": identity_start,
                "identity_end": identity_end,
                "controlnets": applied_controlnets,
                "loras": [{"name": l["name"], "strength": l["strength"]} for l in lora_stack],
                "lightning": lightning,
                "character_id": cid if character else None,
                "outfit": outfit_id or None,
                "mode_info": applied_mode_info,
                "color_grade": color_grade if color_grade != "none" else None,
            },
        })

    @_server.routes.post("/fikoyt_mvd/quick/music")
    async def _quick_music(request):
        """Standalone music gen via ACE-Step. Body: {
          prompt (style/lyrics), duration_sec?, seed?
        }
        Output: output/fikoyt_quick/music_<ts>.flac
        """
        try:
            body = await request.json()
        except Exception as e:
            return web.json_response({"error": f"JSON: {e}"}, status=400)
        prompt = (body.get("prompt") or "").strip()
        if not prompt:
            return web.json_response({"error": "prompt zorunlu (stil + lyrics)"}, status=400)
        duration = max(5, min(int(body.get("duration_sec") or 30), 240))
        import random as _r
        seed = int(body.get("seed") or _r.randint(0, 2**32 - 1))

        # ACE-Step modelleri otomatik tespit
        unet_cands = (folder_paths.get_filename_list("diffusion_models") or [])
        unet_cands += (folder_paths.get_filename_list("unet") or [])
        ace_unets = [f for f in unet_cands if "ace" in f.lower() and "step" in f.lower()]
        if not ace_unets:
            return web.json_response({"error": "ACE-Step model bulunamadı",
                                       "hint": "models/diffusion_models/ altına acestep_v1.5_turbo.safetensors koyun"}, status=400)
        ace_unet = sorted(ace_unets, key=lambda f: 0 if "turbo" in f.lower() else 1)[0]

        vae_cands = folder_paths.get_filename_list("vae") or []
        ace_vaes = [f for f in vae_cands if "ace" in f.lower() and "vae" in f.lower()]
        if not ace_vaes:
            return web.json_response({"error": "ACE VAE bulunamadı (ace_1.5_vae.safetensors)"}, status=400)
        ace_vae = ace_vaes[0]

        clip_cands = (folder_paths.get_filename_list("text_encoders") or [])
        clip_cands += (folder_paths.get_filename_list("clip") or [])
        ace_clips = [f for f in clip_cands if "qwen" in f.lower() and "ace" in f.lower()]
        if not ace_clips:
            return web.json_response({"error": "ACE CLIP bulunamadı (qwen_*_ace15.safetensors)"}, status=400)
        ace_clip = ace_clips[0]

        ts = int(time.time())
        prefix = f"fikoyt_quick/music_{ts}"
        # ACE-Step minimum workflow
        wf = {
            "unet": {"class_type": "UNETLoader", "inputs": {"unet_name": ace_unet, "weight_dtype": "default"}},
            "vae": {"class_type": "VAELoader", "inputs": {"vae_name": ace_vae}},
            "clip": {"class_type": "CLIPLoader", "inputs": {"clip_name": ace_clip, "type": "ace"}},
            "pos": {"class_type": "TextEncodeAceStepAudio",
                     "inputs": {"clip": ["clip", 0], "tags": prompt, "lyrics": prompt,
                                 "lyrics_strength": 0.99}},
            "neg": {"class_type": "TextEncodeAceStepAudio",
                     "inputs": {"clip": ["clip", 0], "tags": "low quality, noise", "lyrics": "",
                                 "lyrics_strength": 0.0}},
            "latent": {"class_type": "EmptyAceStepLatentAudio",
                        "inputs": {"seconds": float(duration), "batch_size": 1}},
            "sampler": {"class_type": "KSampler",
                         "inputs": {"model": ["unet", 0], "positive": ["pos", 0],
                                     "negative": ["neg", 0], "latent_image": ["latent", 0],
                                     "seed": seed, "steps": int(body.get("steps") or 50),
                                     "cfg": float(body.get("cfg") or 5.0),
                                     "sampler_name": body.get("sampler_name") or "euler",
                                     "scheduler": body.get("scheduler") or "simple",
                                     "denoise": 1.0}},
            "decode": {"class_type": "VAEDecodeAudio",
                        "inputs": {"samples": ["sampler", 0], "vae": ["vae", 0]}},
            "save": {"class_type": "SaveAudio",
                      "inputs": {"audio": ["decode", 0], "filename_prefix": prefix}},
        }
        import uuid as _u
        from execution import validate_prompt as _vp
        pid = str(_u.uuid4())
        # Sprint 53: validate_prompt(pid, wf, None)
        valid = await _vp(pid, wf, None) if asyncio.iscoroutinefunction(_vp) else _vp(pid, wf, None)
        if not valid[0]:
            return web.json_response({"error": "Workflow validate fail (SaveAudio/TextEncodeAceStepAudio node yok olabilir)",
                                       "details": str(valid[1]) if len(valid) > 1 else "unknown",
                                       "node_errors": valid[3] if len(valid) > 3 else {}}, status=500)
        outputs_to_exec = valid[2] if len(valid) > 2 else []
        _server.prompt_queue.put((0, pid, wf,
                                   {"client_id": "fikoyt_quicklab",
                                     "create_time": int(time.time() * 1000)},
                                   outputs_to_exec, {}))
        return web.json_response({
            "ok": True, "prompt_id": pid, "seed": seed, "duration_sec": duration,
            "expected_prefix": prefix, "output_dir": "output/fikoyt_quick/",
            "models": {"unet": ace_unet, "clip": ace_clip, "vae": ace_vae},
        })

    @_server.routes.post("/fikoyt_mvd/quick/qwen_t2i")
    async def _quick_qwen_t2i(request):
        """Qwen-Image 2512 base T2I — sıfırdan görsel üretim (image input yok).

        Body: {
          prompt: str (zorunlu),
          negative?: str,
          width?: int (default 1328), height?: int (default 1328),
          seed?: int,
          lightning?: bool (default True — 4-step Lightning LoRA),
          steps?: int (lightning false ise),
          cfg?: float (lightning false ise default 4.0)
        }
        Aspect preset'leri (1:1=1328x1328, 16:9=1664x928, 9:16=928x1664, 4:3=1472x1104,
        3:2=1584x1056) frontend tarafından width/height olarak gönderilir.
        """
        try:
            body = await request.json()
        except Exception as e:
            return web.json_response({"error": f"JSON: {e}"}, status=400)

        prompt = (body.get("prompt") or "").strip()
        if not prompt:
            return web.json_response({"error": "prompt zorunlu"}, status=400)
        neg_prompt = (body.get("negative") or "").strip()

        # Boyut — 32 multiple snap
        width = int(body.get("width") or 1328)
        height = int(body.get("height") or 1328)
        width = max(256, (width // 32) * 32)
        height = max(256, (height // 32) * 32)

        # Model disk taraması
        try:
            unet_cands = (folder_paths.get_filename_list("diffusion_models") or [])
            unet_cands += (folder_paths.get_filename_list("unet") or [])
        except Exception:
            unet_cands = []
        # Qwen 2512 öncelikli (yeni), edit değil (T2I için 2512 base lazım)
        qwen_unet = next((f for f in unet_cands
                          if "qwen" in f.lower() and "image" in f.lower()
                          and "2512" in f.lower() and "edit" not in f.lower()), None) \
                    or next((f for f in unet_cands
                             if "qwen" in f.lower() and "image" in f.lower()
                             and "edit" not in f.lower()), None)
        if not qwen_unet:
            return web.json_response({
                "error": "Qwen-Image 2512 base modeli bulunamadı (qwen_image_2512*.safetensors)",
                "hint": "models/unet/ altına indirin",
                "url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_2512_fp8_e4m3fn.safetensors",
                "install_id": "qwen_image_2512_base",
                "install_endpoint": "/fikoyt_mvd/models/install",
                "install_hint_tr": "Otomatik indirme için: POST /fikoyt_mvd/models/install body { id: 'qwen_image_2512_base' } — ~25GB",
            }, status=424)

        try:
            clip_cands = (folder_paths.get_filename_list("text_encoders") or [])
            clip_cands += (folder_paths.get_filename_list("clip") or [])
        except Exception:
            clip_cands = []
        qwen_clip = next((f for f in clip_cands
                          if "qwen" in f.lower() and "2.5" in f.lower() and "vl" in f.lower()),
                         next((f for f in clip_cands if "qwen" in f.lower() and "vl" in f.lower()), None))
        if not qwen_clip:
            return web.json_response({
                "error": "Qwen 2.5-VL text encoder yok (qwen_2.5_vl_7b*.safetensors)",
                "url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors",
                "install_id": "qwen_image_2512_base",
                "install_endpoint": "/fikoyt_mvd/models/install",
            }, status=424)

        try:
            vae_cands = folder_paths.get_filename_list("vae") or []
        except Exception:
            vae_cands = []
        qwen_vae = next((f for f in vae_cands if "qwen" in f.lower() and "image" in f.lower()),
                       next((f for f in vae_cands if "qwen" in f.lower()), None))
        if not qwen_vae:
            return web.json_response({
                "error": "Qwen Image VAE yok (qwen_image_vae.safetensors)",
                "url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors",
                "install_id": "qwen_image_2512_base",
                "install_endpoint": "/fikoyt_mvd/models/install",
            }, status=424)

        # Lightning LoRA (4 step) — opsiyonel
        try:
            lora_cands = folder_paths.get_filename_list("loras") or []
        except Exception:
            lora_cands = []
        use_lightning = body.get("lightning", True)
        light_lora = None
        if use_lightning:
            # Qwen-Image-2512-Lightning-4steps öncelikli
            light_lora = next((f for f in lora_cands
                               if "qwen" in f.lower() and "image" in f.lower()
                               and "2512" in f.lower() and "lightning" in f.lower()), None) \
                        or next((f for f in lora_cands
                                 if "qwen" in f.lower() and "image" in f.lower()
                                 and "lightning" in f.lower() and "edit" not in f.lower()), None)

        # Parametreler
        import random as _r
        seed = int(body.get("seed") or 0) or _r.randint(0, 2**32 - 1)
        if light_lora:
            steps, cfg, shift = 4, 1.0, 3.1
        else:
            # Araştırma-optimal: Qwen-Image full ~20 step yeterli (50 gereksiz yavaş), cfg 4.0
            steps = int(body.get("steps") or 20)
            cfg = float(body.get("cfg") or 4.0)
            shift = 3.1
        ts = int(time.time())
        prefix = f"fikoyt_quick/qwen_t2i_{ts}"

        # Workflow JSON
        wf = {
            "unet": {"class_type": "UNETLoader",
                     "inputs": {"unet_name": qwen_unet, "weight_dtype": "default"}},
            "vae": {"class_type": "VAELoader",
                    "inputs": {"vae_name": qwen_vae}},
            "clip": {"class_type": "CLIPLoader",
                     "inputs": {"clip_name": qwen_clip, "type": "qwen_image", "device": "default"}},
            "pos_enc": {"class_type": "CLIPTextEncode",
                        "inputs": {"clip": ["clip", 0], "text": prompt}},
            "neg_enc": {"class_type": "CLIPTextEncode",
                        "inputs": {"clip": ["clip", 0], "text": neg_prompt}},
            "latent": {"class_type": "EmptySD3LatentImage",
                       "inputs": {"width": width, "height": height, "batch_size": 1}},
        }
        # LoRA chain (Lightning varsa)
        model_ref = ["unet", 0]
        if light_lora:
            wf["lora_light"] = {"class_type": "LoraLoaderModelOnly",
                                "inputs": {"model": model_ref,
                                           "lora_name": light_lora, "strength_model": 1.0}}
            model_ref = ["lora_light", 0]
        wf["model_sample"] = {"class_type": "ModelSamplingAuraFlow",
                              "inputs": {"model": model_ref, "shift": shift}}
        sampler_name = body.get("sampler_name") or "euler"
        scheduler_val = body.get("scheduler") or "simple"
        wf["sampler"] = {"class_type": "KSampler",
                         "inputs": {"model": ["model_sample", 0],
                                    "positive": ["pos_enc", 0], "negative": ["neg_enc", 0],
                                    "latent_image": ["latent", 0],
                                    "seed": seed, "steps": steps, "cfg": cfg,
                                    "sampler_name": sampler_name, "scheduler": scheduler_val,
                                    "denoise": 1.0}}
        wf["decode"] = {"class_type": "VAEDecode",
                        "inputs": {"samples": ["sampler", 0], "vae": ["vae", 0]}}
        wf["save"] = {"class_type": "SaveImage",
                      "inputs": {"images": ["decode", 0], "filename_prefix": prefix}}

        # Submit
        import uuid as _u
        from execution import validate_prompt as _vp
        pid = str(_u.uuid4())
        valid = await _vp(pid, wf, None) if asyncio.iscoroutinefunction(_vp) else _vp(pid, wf, None)
        if not valid[0]:
            return web.json_response({
                "error": "Workflow validate fail",
                "details": str(valid[1]) if len(valid) > 1 else "unknown",
                "node_errors": valid[3] if len(valid) > 3 else {},
            }, status=500)
        outputs_to_exec = valid[2] if len(valid) > 2 else []
        _server.prompt_queue.put((0, pid, wf,
                                  {"client_id": "fikoyt_quicklab",
                                   "create_time": int(time.time() * 1000)},
                                  outputs_to_exec, {}))
        return web.json_response({
            "ok": True, "prompt_id": pid, "seed": seed,
            "steps": steps, "cfg": cfg, "size": {"w": width, "h": height},
            "sampler_name": sampler_name, "scheduler": scheduler_val,
            "expected_prefix": prefix, "output_dir": "output/fikoyt_quick/",
            "models": {
                "unet": qwen_unet, "clip": qwen_clip, "vae": qwen_vae,
                "lightning_lora": light_lora or "(yok — full step)",
            },
        })

    @_server.routes.post("/fikoyt_mvd/quick/qwen_multi_angle")
    async def _quick_qwen_multi_angle(request):
        """Qwen-Image-Edit 2509 + Multiple-Angles LoRA ile tek input → kamera açısı.

        Body: {
          source_image_path: str (zorunlu — input/ veya output/ rel path),
          angle: "close_up"|"wide"|"45_right"|"90_right"|"45_left"|"90_left"|"aerial"|"low"
                 |"custom_prompt",
          custom_prompt?: str (angle=custom_prompt ise),
          seed?: int,
          lightning?: bool (default True — 4-step Lightning LoRA)
        }
        Returns: { prompt_id, angle, prompt, seed, expected_prefix }
        """
        try:
            body = await request.json()
        except Exception as e:
            return web.json_response({"error": f"JSON: {e}"}, status=400)

        src_path = (body.get("source_image_path") or "").strip()
        if not src_path:
            return web.json_response({"error": "source_image_path zorunlu"}, status=400)
        # Path normalize
        if not os.path.isabs(src_path):
            for base in (folder_paths.get_input_directory(),
                         folder_paths.get_output_directory()):
                cand = os.path.join(base, src_path)
                if os.path.isfile(cand):
                    src_path = cand
                    break
        if not os.path.isfile(src_path):
            return web.json_response({
                "error": f"Görsel bulunamadı: {src_path}",
                "hint": "input/ veya output/ klasöründe olmalı",
            }, status=404)
        # LoadImage için input/ altında dosya adı gerekli — kopyala/sym-link
        input_dir = folder_paths.get_input_directory()
        src_basename = os.path.basename(src_path)
        in_path = os.path.join(input_dir, src_basename)
        if not os.path.isfile(in_path):
            try:
                import shutil as _sh
                _sh.copy2(src_path, in_path)
            except Exception as e:
                return web.json_response({"error": f"input/ kopyalama hata: {e}"}, status=500)

        angle = (body.get("angle") or "close_up").lower().strip()
        # Sprint W22++ — 16 açı (8 mevcut + 8 yeni: pan/tilt/zoom/rotate)
        # dx8152 Multi-Angle LoRA tüm bunları destekliyor (2026 web research).
        ANGLE_PROMPTS = {
            # Lens / zoom
            "close_up":   "Turn the camera to a close-up.",
            "wide":       "Turn the camera to a wide-angle lens.",
            "forward":    "Move the camera forward (zoom in / dolly in).",
            "backward":   "Move the camera backward (zoom out / dolly out).",
            # Yatay rotasyon
            "45_right":   "Rotate the camera 45 degrees to the right.",
            "90_right":   "Rotate the camera 90 degrees to the right.",
            "45_left":    "Rotate the camera 45 degrees to the left.",
            "90_left":    "Rotate the camera 90 degrees to the left.",
            # Dikey eksen
            "aerial":     "Turn the camera to an aerial view (top-down).",
            "low":        "Turn the camera to a low-angle view (hero shot).",
            "tilt_up":    "Tilt the camera up.",
            "tilt_down":  "Tilt the camera down.",
            # Pan (statik pozisyon, kamera ekseninde dönüş)
            "pan_left":   "Pan the camera to the left.",
            "pan_right":  "Pan the camera to the right.",
            # Roll (kamera kendi etrafında dönüşü)
            "rotate_cw":  "Rotate the camera clockwise (roll right).",
            "rotate_ccw": "Rotate the camera counterclockwise (roll left).",
        }
        if angle == "custom_prompt":
            prompt = (body.get("custom_prompt") or "").strip()
            if not prompt:
                return web.json_response({"error": "angle=custom_prompt ise custom_prompt zorunlu"}, status=400)
        else:
            prompt = ANGLE_PROMPTS.get(angle)
            if not prompt:
                return web.json_response({
                    "error": f"Bilinmeyen açı: {angle}",
                    "valid_angles": list(ANGLE_PROMPTS.keys()) + ["custom_prompt"],
                }, status=400)

        # Model disk taraması — Qwen Edit zorunlu, Multi-Angle LoRA olmadan da çalışır
        try:
            unet_cands = (folder_paths.get_filename_list("diffusion_models") or [])
            unet_cands += (folder_paths.get_filename_list("unet") or [])
        except Exception:
            unet_cands = []
        qwen_edit = next((f for f in unet_cands
                          if "qwen" in f.lower() and "edit" in f.lower()), None)
        if not qwen_edit:
            return web.json_response({
                "error": "Qwen-Image-Edit modeli bulunamadı (qwen_image_edit_2509*.safetensors)",
                "hint": "models/unet/ veya models/diffusion_models/ altına indirin",
                "url": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_2509_fp8_e4m3fn.safetensors",
                "install_id": "qwen_edit_multi_angle",
                "install_endpoint": "/fikoyt_mvd/models/install",
                "install_hint_tr": "Otomatik indirme için: POST /fikoyt_mvd/models/install body { id: 'qwen_edit_multi_angle' } — ~25GB",
            }, status=424)

        try:
            clip_cands = (folder_paths.get_filename_list("text_encoders") or [])
            clip_cands += (folder_paths.get_filename_list("clip") or [])
        except Exception:
            clip_cands = []
        qwen_clip = next((f for f in clip_cands
                          if "qwen" in f.lower() and "2.5" in f.lower() and "vl" in f.lower()),
                         next((f for f in clip_cands if "qwen" in f.lower() and "vl" in f.lower()), None))
        if not qwen_clip:
            return web.json_response({
                "error": "Qwen 2.5-VL text encoder bulunamadı (qwen_2.5_vl_7b*.safetensors)",
                "hint": "models/text_encoders/ altına indirin",
                "url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors",
                "install_id": "qwen_edit_multi_angle",
                "install_endpoint": "/fikoyt_mvd/models/install",
            }, status=424)

        try:
            vae_cands = folder_paths.get_filename_list("vae") or []
        except Exception:
            vae_cands = []
        qwen_vae = next((f for f in vae_cands if "qwen" in f.lower() and "image" in f.lower()),
                       next((f for f in vae_cands if "qwen" in f.lower()), None))
        if not qwen_vae:
            return web.json_response({
                "error": "Qwen Image VAE bulunamadı (qwen_image_vae.safetensors)",
                "hint": "models/vae/ altına indirin",
                "url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors",
                "install_id": "qwen_edit_multi_angle",
                "install_endpoint": "/fikoyt_mvd/models/install",
            }, status=424)

        # LoRA'lar opsiyonel — yoksa graceful (steps artar)
        try:
            lora_cands = folder_paths.get_filename_list("loras") or []
        except Exception:
            lora_cands = []
        multi_angle_lora = next((f for f in lora_cands
                                 if "multiple-angles" in f.lower() or "multi-angle" in f.lower()), None)
        use_lightning = body.get("lightning", True)
        light_lora = None
        if use_lightning:
            light_lora = next((f for f in lora_cands
                               if "qwen" in f.lower() and "edit" in f.lower() and "lightning" in f.lower()),
                              next((f for f in lora_cands
                                    if "qwen" in f.lower() and "lightning" in f.lower()), None))

        # Parametreler
        import random as _r
        seed = int(body.get("seed") or 0) or _r.randint(0, 2**32 - 1)
        steps = 4 if light_lora else int(body.get("steps") or 20)
        cfg = 1.0 if light_lora else float(body.get("cfg") or 2.5)
        ts = int(time.time())
        prefix = f"fikoyt_quick/qwen_angle_{angle}_{ts}"

        # ─── Workflow JSON ───
        wf = {
            "unet": {"class_type": "UNETLoader",
                     "inputs": {"unet_name": qwen_edit, "weight_dtype": "default"}},
            "vae": {"class_type": "VAELoader",
                    "inputs": {"vae_name": qwen_vae}},
            "clip": {"class_type": "CLIPLoader",
                     "inputs": {"clip_name": qwen_clip, "type": "qwen_image", "device": "default"}},
            "load_img": {"class_type": "LoadImage",
                         "inputs": {"image": src_basename}},
            "img_scale": {"class_type": "ImageScaleToTotalPixels",
                          "inputs": {"image": ["load_img", 0],
                                     "upscale_method": "lanczos", "megapixels": 1.0,
                                     # ComfyUI 0.22 yeni required field — workflow validate fail önlemek için
                                     "resolution_steps": 1}},
            "vae_enc": {"class_type": "VAEEncode",
                        "inputs": {"pixels": ["img_scale", 0], "vae": ["vae", 0]}},
            "pos_enc": {"class_type": "TextEncodeQwenImageEditPlus",
                        "inputs": {"clip": ["clip", 0], "vae": ["vae", 0],
                                   "image1": ["img_scale", 0], "prompt": prompt}},
            "neg_enc": {"class_type": "TextEncodeQwenImageEditPlus",
                        "inputs": {"clip": ["clip", 0], "vae": ["vae", 0],
                                   "image1": ["img_scale", 0], "prompt": ""}},
        }

        # LoRA chain: Multi-Angle (varsa) → Lightning (varsa) → ModelSampling → CFGNorm
        model_ref = ["unet", 0]
        if multi_angle_lora:
            wf["lora_ma"] = {"class_type": "LoraLoaderModelOnly",
                             "inputs": {"model": model_ref,
                                        "lora_name": multi_angle_lora, "strength_model": 1.0}}
            model_ref = ["lora_ma", 0]
        if light_lora:
            wf["lora_light"] = {"class_type": "LoraLoaderModelOnly",
                                "inputs": {"model": model_ref,
                                           "lora_name": light_lora, "strength_model": 1.0}}
            model_ref = ["lora_light", 0]

        wf["model_sample"] = {"class_type": "ModelSamplingAuraFlow",
                              "inputs": {"model": model_ref, "shift": 3.0}}
        wf["cfg_norm"] = {"class_type": "CFGNorm",
                          "inputs": {"model": ["model_sample", 0], "strength": 1.0}}

        sampler_name = body.get("sampler_name") or "euler"
        scheduler_val = body.get("scheduler") or "simple"
        wf["sampler"] = {"class_type": "KSampler",
                         "inputs": {"model": ["cfg_norm", 0],
                                    "positive": ["pos_enc", 0], "negative": ["neg_enc", 0],
                                    "latent_image": ["vae_enc", 0],
                                    "seed": seed, "steps": steps, "cfg": cfg,
                                    "sampler_name": sampler_name, "scheduler": scheduler_val,
                                    "denoise": 1.0}}
        wf["decode"] = {"class_type": "VAEDecode",
                        "inputs": {"samples": ["sampler", 0], "vae": ["vae", 0]}}
        wf["save"] = {"class_type": "SaveImage",
                      "inputs": {"images": ["decode", 0], "filename_prefix": prefix}}

        # Submit
        import uuid as _u
        from execution import validate_prompt as _vp
        pid = str(_u.uuid4())
        valid = await _vp(pid, wf, None) if asyncio.iscoroutinefunction(_vp) else _vp(pid, wf, None)
        if not valid[0]:
            return web.json_response({
                "error": "Workflow validate fail",
                "details": str(valid[1]) if len(valid) > 1 else "unknown",
                "node_errors": valid[3] if len(valid) > 3 else {},
            }, status=500)
        outputs_to_exec = valid[2] if len(valid) > 2 else []
        _server.prompt_queue.put((0, pid, wf,
                                  {"client_id": "fikoyt_quicklab",
                                   "create_time": int(time.time() * 1000)},
                                  outputs_to_exec, {}))
        return web.json_response({
            "ok": True, "prompt_id": pid, "angle": angle, "prompt": prompt,
            "seed": seed, "steps": steps, "cfg": cfg,
            "expected_prefix": prefix, "output_dir": "output/fikoyt_quick/",
            "models": {
                "unet": qwen_edit, "clip": qwen_clip, "vae": qwen_vae,
                "multi_angle_lora": multi_angle_lora or "(yok — generic)",
                "lightning_lora": light_lora or "(yok — full step)",
            },
        })

    @_server.routes.post("/fikoyt_mvd/quick/video")
    async def _quick_video(request):
        """Standalone image→video. Body: {
          engine? (ltx/wan_s2v/infinitetalk default=ltx),
          init_image_path (zorunlu — ComfyUI input/ veya output/ rel path),
          audio_path? (wan_s2v/infinitetalk için zorunlu),
          prompt, negative?, duration_sec?, fps?,
          width?, height?, resolution_preset? (480p/720p/1080p),
          steps?, cfg?, seed?,
          sampler_name?, scheduler?,
          teacache?, wavespeed? (bool)
        }
        Output: output/fikoyt_quick/video_<ts>.webp (animated)
        """
        try:
            body = await request.json()
        except Exception as e:
            return web.json_response({"error": f"JSON: {e}"}, status=400)
        prompt = (body.get("prompt") or "").strip()
        if not prompt:
            return web.json_response({"error": "prompt zorunlu"}, status=400)
        # Engine alias normalize — frontend ENGINES dizininden gelen tüm varyantlar
        _eng_raw = (body.get("engine") or "ltx").lower().replace("-", "_").replace(".", "_")
        _engine_aliases = {
            # LTX ailesi → "ltx" branch
            "ltx": "ltx", "ltxv": "ltx", "ltx23": "ltx", "ltx2_3": "ltx",
            "ltx_2_3": "ltx", "ltx2": "ltx", "ltx2_19b": "ltx",
            # Wan-S2V (audio-driven) → "wan_s2v" branch
            "wan_s2v": "wan_s2v", "wan22_s2v": "wan_s2v", "wan2_2_s2v": "wan_s2v",
            # Wan 2.2 plain i2v (audio yok) — şimdilik LTX'e fallback (mevcut diskte LTX var, Wan2.2 büyük)
            "wan22": "ltx", "wan2_2": "ltx",
            # InfiniteTalk/HuMo lipsync → infinitetalk
            "infinitetalk": "infinitetalk", "infinite_talk": "infinitetalk",
            "humo": "infinitetalk",
            # Henüz hazır olmayanlar — anlamlı mesaj döner
            "hunyuan15": "_unsupported", "hunyuanvideo": "_unsupported",
            "cogvideox": "_unsupported", "cogvideox5b": "_unsupported",
        }
        engine = _engine_aliases.get(_eng_raw, _eng_raw)
        if engine == "_unsupported":
            return web.json_response({
                "error": f"'{_eng_raw}' engine Quick Lab'da henüz hazır değil. "
                         "Şimdilik şunlar destekleniyor: LTX 2.3 (ltx23), "
                         "Wan 2.2-S2V (wan22_s2v), InfiniteTalk/HuMo (infinitetalk).",
                "supported_engines": ["ltx23", "wan22_s2v", "infinitetalk"],
            }, status=501)
        init_path = (body.get("init_image_path") or "").strip()
        if not init_path:
            return web.json_response({"error": "init_image_path zorunlu — Görsel sekmesinden üret veya output yolu ver"}, status=400)
        # Path normalize: relatif veya absolute olabilir
        if not os.path.isabs(init_path):
            # ComfyUI output/ altında ara
            for base in [folder_paths.get_output_directory(), folder_paths.get_input_directory()]:
                cand = os.path.join(base, init_path)
                if os.path.isfile(cand):
                    init_path = cand; break
        if not os.path.isfile(init_path):
            return web.json_response({"error": f"init_image yok: {init_path}"}, status=400)
        audio_path = (body.get("audio_path") or "").strip()
        if engine in ("wan_s2v", "infinitetalk") and not audio_path:
            return web.json_response({"error": f"{engine} için audio_path zorunlu"}, status=400)
        if audio_path and not os.path.isabs(audio_path):
            for base in [folder_paths.get_output_directory(), folder_paths.get_input_directory()]:
                cand = os.path.join(base, audio_path)
                if os.path.isfile(cand):
                    audio_path = cand; break

        # Sprint W30: audio_start_sec / audio_end_sec → ffmpeg slice → tmp dosya
        _audio_start_sec = float(body.get("audio_start_sec") or 0)
        _audio_end_sec_raw = body.get("audio_end_sec")
        _audio_end_sec = float(_audio_end_sec_raw) if _audio_end_sec_raw is not None else None
        if audio_path and (_audio_start_sec > 0 or _audio_end_sec is not None):
            try:
                import subprocess as _sp
                ff = shutil.which("ffmpeg")
                if not ff:
                    try:
                        import imageio_ffmpeg as _iff
                        ff = _iff.get_ffmpeg_exe()
                    except Exception:
                        ff = None
                if ff:
                    _ts = int(time.time())
                    _tmp_dir = os.path.join(folder_paths.get_input_directory(), "fikoyt_quick_audio")
                    os.makedirs(_tmp_dir, exist_ok=True)
                    _tmp_out = os.path.join(_tmp_dir,
                        f"slice_{_ts}{os.path.splitext(audio_path)[1] or '.wav'}")
                    _args = [ff, "-y", "-i", audio_path, "-ss", str(_audio_start_sec)]
                    if _audio_end_sec is not None and _audio_end_sec > _audio_start_sec:
                        _args += ["-t", str(_audio_end_sec - _audio_start_sec)]
                    _args += ["-acodec", "copy", _tmp_out]
                    _r = _sp.run(_args, capture_output=True, text=True, timeout=120)
                    if _r.returncode != 0:
                        # codec copy fail → re-encode
                        _args = [ff, "-y", "-i", audio_path, "-ss", str(_audio_start_sec)]
                        if _audio_end_sec is not None and _audio_end_sec > _audio_start_sec:
                            _args += ["-t", str(_audio_end_sec - _audio_start_sec)]
                        _args += ["-acodec", "libmp3lame", "-b:a", "192k", _tmp_out]
                        _sp.run(_args, capture_output=True, text=True, timeout=120)
                    if os.path.isfile(_tmp_out) and os.path.getsize(_tmp_out) > 100:
                        audio_path = _tmp_out
            except Exception as _e:
                print(f"[FikoYT QuickLab/Video] audio slice skip: {_e}")

        duration = max(2, min(int(body.get("duration_sec") or 5), 60))
        fps = int(body.get("fps") or 24)
        # Resolution preset
        res_pre = (body.get("resolution_preset") or "").lower()
        if res_pre == "480p": width, height = 854, 480
        elif res_pre == "720p": width, height = 1280, 720
        elif res_pre == "1080p": width, height = 1920, 1080
        else:
            width = int(body.get("width") or 768)
            height = int(body.get("height") or 512)
        width = max(256, (width // 16) * 16)
        height = max(256, (height // 16) * 16)

        steps = int(body.get("steps") or 30)
        cfg = float(body.get("cfg") or 3.0)
        sampler_name = (body.get("sampler_name") or "euler").strip()
        scheduler = (body.get("scheduler") or "simple").strip()
        denoise = float(body.get("denoise") or 1.0)
        denoise = max(0.05, min(1.0, denoise))
        import random as _r
        seed = int(body.get("seed") or 0) or _r.randint(0, 2**32 - 1)

        ts = int(time.time())
        prefix = f"fikoyt_quick/video_{ts}_{engine}"
        wf = {}
        models = {}

        try:
            from nodes import NODE_CLASS_MAPPINGS as _NCM
        except Exception:
            _NCM = {}

        if engine == "ltx":
            # Model arama: checkpoints + diffusion_models + unet
            _ckpt_cands = folder_paths.get_filename_list("checkpoints") or []
            _unet_cands = (folder_paths.get_filename_list("diffusion_models") or []) + \
                          (folder_paths.get_filename_list("unet") or [])
            _ltx_ckpts = [f for f in _ckpt_cands if "ltx" in f.lower() and f.lower().endswith(".safetensors")]
            _ltx_unets = [f for f in _unet_cands if "ltx" in f.lower()]
            if not _ltx_ckpts and not _ltx_unets:
                return web.json_response({
                    "error": "LTX modeli eksik — otomatik indiriliyor",
                    "install_id": "ltx_video_2_3_stack",
                    "install_hint_tr": "LTX-Video 2.3 (~30GB checkpoint + VAE + Gemma)",
                }, status=424)
            _ltx_ckpt = sorted(_ltx_ckpts, key=lambda f: 0 if "dev" in f.lower() else 1)[0] if _ltx_ckpts else \
                        sorted(_ltx_unets, key=lambda f: 0 if "dev" in f.lower() else 1)[0]
            _ckpt_override = (body.get("checkpoint") or "").strip()
            if _ckpt_override:
                _all_ckpts = _ltx_ckpts + _ltx_unets
                _match = next((f for f in _all_ckpts if f == _ckpt_override), None)
                if _match:
                    _ltx_ckpt = _match
            # VAE
            _vae_cands = folder_paths.get_filename_list("vae") or []
            _ltx_vae = next((f for f in _vae_cands if "ltx" in f.lower() and "video" in f.lower()),
                            next((f for f in _vae_cands if "ltx" in f.lower()), None))
            if not _ltx_vae:
                return web.json_response({
                    "error": "LTX Video VAE eksik — otomatik indiriliyor",
                    "install_id": "ltx_video_2_3_stack",
                    "install_hint_tr": "LTX Video VAE (~250MB)",
                }, status=424)
            # Text encoder (Gemma 3 preferred for LTX 2.3)
            _te_cands = (folder_paths.get_filename_list("text_encoders") or []) + \
                        (folder_paths.get_filename_list("clip") or [])
            _ltx_te = next((f for f in _te_cands if "gemma" in f.lower()),
                           next((f for f in _te_cands if "t5xxl" in f.lower()), None))
            if not _ltx_te:
                return web.json_response({
                    "error": "Gemma / T5XXL text encoder eksik — otomatik indiriliyor",
                    "install_id": "ltx_video_2_3_stack",
                    "install_hint_tr": "Gemma 3 text encoder (~7.5GB)",
                }, status=424)
            models = {"checkpoint": _ltx_ckpt, "text_encoder": _ltx_te, "vae": _ltx_vae}

            # Image'ı input/ altına kopyala (LoadImage orada arar)
            _img_base = os.path.basename(init_path)
            _img_dest_dir = os.path.join(folder_paths.get_input_directory(), "fikoyt_mvd_input")
            os.makedirs(_img_dest_dir, exist_ok=True)
            _img_dest = os.path.join(_img_dest_dir, _img_base)
            if not os.path.isfile(_img_dest) or \
               os.path.getmtime(init_path) > os.path.getmtime(_img_dest):
                shutil.copy2(init_path, _img_dest)
            _img_rel = f"fikoyt_mvd_input/{_img_base}"

            # Frame hesabı: LTX 2.3 → 8k+1 format
            _raw = duration * fps + 1
            _frames = max(49, ((_raw - 1 + 7) // 8) * 8 + 1)

            _ltx_mode = (body.get("ltx_mode") or "distilled").strip().lower()

            if _ltx_mode == "distilled":
                # ── 2-pass distilled pipeline (golden workflow pattern) ──
                _p1_sigmas = body.get("pass1_sigmas") or "1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0"
                _p2_sigmas = body.get("pass2_sigmas") or "0.85, 0.7250, 0.4219, 0.0"
                _p1_sampler = body.get("pass1_sampler") or "euler_ancestral_cfg_pp"
                _p2_sampler = body.get("pass2_sampler") or "euler_cfg_pp"
                _motion1 = float(body.get("motion_strength") or 0.7)
                _motion2 = float(body.get("motion_strength_2") or 1.0)
                _img_comp = int(body.get("img_compression") or 18)
                _neg = body.get("negative") or "completely blurred background, indistinguishable setting, smeared environment, motion-blur on character"

                wf["ckpt"]    = {"class_type": "CheckpointLoaderSimple",   "inputs": {"ckpt_name": _ltx_ckpt}}
                wf["te"]      = {"class_type": "LTXAVTextEncoderLoader",   "inputs": {"text_encoder": _ltx_te, "ckpt_name": _ltx_ckpt, "device": "default"}}
                wf["av_vae"]  = {"class_type": "LTXVAudioVAELoader",       "inputs": {"ckpt_name": _ltx_ckpt}}

                # Latent upscaler (opsiyonel — yoksa basit upscale)
                _up_files = folder_paths.get_filename_list("upscale_models") or []
                _upscaler = next((f for f in _up_files if "ltx" in f.lower() and "upscal" in f.lower()), None)
                _has_upscaler = bool(_upscaler) and bool(_NCM.get("LatentUpscaleModelLoader")) and bool(_NCM.get("LTXVLatentUpsampler"))
                if _has_upscaler:
                    wf["up_load"] = {"class_type": "LatentUpscaleModelLoader", "inputs": {"model_name": _upscaler}}

                # LoRA chain (oto-tespit, eksik olanlar atlanır)
                _lora_defs = [
                    ("distilled_1", "distilled-lora-384",  0.5),
                    ("distilled_2", "distilled-lora-384",  0.5),
                    ("transition",  "transition",          0.9),
                    ("detailer",    "detailer",            0.8),
                    ("licon",       "licon-vbvr",          0.6),
                    ("lipdub",      "lipdub",              0.6),
                    ("audio_react", "audioreactive",       0.6),
                    ("talkvid",     "talkvid",             0.5),
                ]
                _lora_files = folder_paths.get_filename_list("loras") or []
                _lora_overrides = body.get("lora_strengths") or {}
                _cur_model = ["ckpt", 0]
                _applied_loras = []
                for _lk, _lpat, _ldef in _lora_defs:
                    _lf = next((f for f in _lora_files if _lpat.lower() in f.lower() and "ltx" in f.lower()), None)
                    if not _lf:
                        _lf = next((f for f in _lora_files if _lpat.lower() in f.lower()), None)
                    if not _lf:
                        continue
                    _ls = float(_lora_overrides.get(_lk, _ldef))
                    _lid = f"lora_{_lk}"
                    wf[_lid] = {"class_type": "LoraLoaderModelOnly", "inputs": {
                        "lora_name": _lf, "strength_model": _ls, "model": _cur_model}}
                    _cur_model = [_lid, 0]
                    _applied_loras.append(_lk)

                # Image preprocess
                wf["load_img"] = {"class_type": "LoadImage", "inputs": {"image": _img_rel}}
                _img_out = ["load_img", 0]
                if _NCM.get("LTXVPreprocess"):
                    wf["preprocess"] = {"class_type": "LTXVPreprocess", "inputs": {"img_compression": _img_comp, "image": _img_out}}
                    _img_out = ["preprocess", 0]

                # Text encode + conditioning
                wf["pos_enc"] = {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["te", 0]}}
                wf["neg_enc"] = {"class_type": "CLIPTextEncode", "inputs": {"text": _neg, "clip": ["te", 0]}}
                wf["cond"]    = {"class_type": "LTXVConditioning", "inputs": {"frame_rate": float(fps), "positive": ["pos_enc", 0], "negative": ["neg_enc", 0]}}

                # Audio encode (ses varsa)
                _audio_input = None
                if audio_path and os.path.isfile(audio_path):
                    _aud_base = os.path.basename(audio_path)
                    _aud_dest = os.path.join(folder_paths.get_input_directory(), "fikoyt_mvd_input", _aud_base)
                    if not os.path.isfile(_aud_dest) or os.path.getmtime(audio_path) > os.path.getmtime(_aud_dest):
                        shutil.copy2(audio_path, _aud_dest)
                    wf["load_audio"] = {"class_type": "LoadAudio", "inputs": {"audio": f"fikoyt_mvd_input/{_aud_base}"}}
                    wf["audio_enc"]  = {"class_type": "LTXVAudioVAEEncode", "inputs": {"audio": ["load_audio", 0], "audio_vae": ["av_vae", 0]}}
                    _audio_input = ["audio_enc", 0]

                # ═══ 1st PASS — yarı çözünürlük ═══
                _w1 = max(64, (width // 2 // 32) * 32)
                _h1 = max(64, (height // 2 // 32) * 32)
                wf["empty_lat"] = {"class_type": "EmptyLTXVLatentVideo", "inputs": {"width": _w1, "height": _h1, "length": _frames, "batch_size": 1}}
                wf["i2v_1"]     = {"class_type": "LTXVImgToVideoInplace", "inputs": {"strength": _motion1, "bypass": False, "vae": ["ckpt", 2], "image": _img_out, "latent": ["empty_lat", 0]}}
                _lat1 = ["i2v_1", 0]
                if _audio_input:
                    wf["concat_av1"] = {"class_type": "LTXVConcatAVLatent", "inputs": {"video_latent": _lat1, "audio_latent": _audio_input}}
                    _lat1 = ["concat_av1", 0]

                wf["cfg1"]     = {"class_type": "CFGGuider", "inputs": {"cfg": float(cfg), "model": _cur_model, "positive": ["cond", 0], "negative": ["cond", 1]}}
                wf["samp1"]    = {"class_type": "KSamplerSelect", "inputs": {"sampler_name": _p1_sampler}}
                wf["sigma1"]   = {"class_type": "ManualSigmas", "inputs": {"sigmas": _p1_sigmas}}
                wf["noise1"]   = {"class_type": "RandomNoise", "inputs": {"noise_seed": seed}}
                wf["sample1"]  = {"class_type": "SamplerCustomAdvanced", "inputs": {"noise": ["noise1", 0], "guider": ["cfg1", 0], "sampler": ["samp1", 0], "sigmas": ["sigma1", 0], "latent_image": _lat1}}

                # ═══ 1st → 2nd geçiş ═══
                wf["sep1"] = {"class_type": "LTXVSeparateAVLatent", "inputs": {"av_latent": ["sample1", 0]}}

                # CropGuides (2nd pass'ta cropped conditioning)
                _has_crop = bool(_NCM.get("LTXVCropGuides"))
                if _has_crop:
                    wf["crop_guides"] = {"class_type": "LTXVCropGuides", "inputs": {"positive": ["cond", 0], "negative": ["cond", 1], "latent": ["sep1", 0]}}
                    _p2_pos, _p2_neg = ["crop_guides", 0], ["crop_guides", 1]
                else:
                    _p2_pos, _p2_neg = ["cond", 0], ["cond", 1]

                # Latent upscale
                if _has_upscaler:
                    wf["lat_up"] = {"class_type": "LTXVLatentUpsampler", "inputs": {"samples": ["sep1", 0], "upscale_model": ["up_load", 0], "vae": ["ckpt", 2]}}
                    _upscaled_lat = ["lat_up", 0]
                else:
                    _upscaled_lat = ["sep1", 0]

                # 2nd pass I2V inplace (tam çözünürlük)
                wf["i2v_2"] = {"class_type": "LTXVImgToVideoInplace", "inputs": {"strength": _motion2, "bypass": False, "vae": ["ckpt", 2], "image": _img_out, "latent": _upscaled_lat}}
                _lat2 = ["i2v_2", 0]
                if _audio_input:
                    wf["concat_av2"] = {"class_type": "LTXVConcatAVLatent", "inputs": {"video_latent": _lat2, "audio_latent": ["sep1", 1]}}
                    _lat2 = ["concat_av2", 0]

                # ═══ 2nd PASS — tam çözünürlük ═══
                wf["cfg2"]     = {"class_type": "CFGGuider", "inputs": {"cfg": float(cfg), "model": _cur_model, "positive": _p2_pos, "negative": _p2_neg}}
                wf["samp2"]    = {"class_type": "KSamplerSelect", "inputs": {"sampler_name": _p2_sampler}}
                wf["sigma2"]   = {"class_type": "ManualSigmas", "inputs": {"sigmas": _p2_sigmas}}
                wf["noise2"]   = {"class_type": "RandomNoise", "inputs": {"noise_seed": seed + 1}}
                wf["sample2"]  = {"class_type": "SamplerCustomAdvanced", "inputs": {"noise": ["noise2", 0], "guider": ["cfg2", 0], "sampler": ["samp2", 0], "sigmas": ["sigma2", 0], "latent_image": _lat2}}

                # ═══ Decode + Save ═══
                wf["sep2"]    = {"class_type": "LTXVSeparateAVLatent", "inputs": {"av_latent": ["sample2", 0]}}
                wf["vae_dec"] = {"class_type": "VAEDecodeTiled", "inputs": {"tile_size": 768, "overlap": 64, "temporal_size": 4096, "temporal_overlap": 4, "samples": ["sep2", 0], "vae": ["ckpt", 2]}}

                _vid_inputs = {"fps": float(fps), "images": ["vae_dec", 0]}
                if audio_path and os.path.isfile(audio_path):
                    _vid_inputs["audio"] = ["load_audio", 0]
                wf["create_vid"] = {"class_type": "CreateVideo", "inputs": _vid_inputs}
                wf["save_video"] = {"class_type": "SaveVideo", "inputs": {"video": ["create_vid", 0], "filename_prefix": prefix, "format": "auto", "codec": "auto"}}

                models["mode"] = "distilled"
                models["loras"] = _applied_loras
                models["pass1_steps"] = len([s.strip() for s in _p1_sigmas.split(",") if s.strip()]) - 1
                models["pass2_steps"] = len([s.strip() for s in _p2_sigmas.split(",") if s.strip()]) - 1
                models["has_upscaler"] = _has_upscaler

            else:
                # ── Standard single-pass (ltx_segment.json template) ──
                _tmpl_path = os.path.join(
                    os.path.dirname(os.path.abspath(__file__)), "..", "workflow", "ltx_segment.json")
                try:
                    with open(_tmpl_path, "r", encoding="utf-8") as _tf:
                        wf = json.load(_tf)
                except Exception as _e:
                    return web.json_response({"error": f"LTX workflow template yüklenemedi: {_e}"}, status=500)
                _motion = float(body.get("motion_strength") or 0.85)
                _wf_str = json.dumps(wf, ensure_ascii=False)
                _repl = {
                    "@@CHECKPOINT@@": _ltx_ckpt, "@@TEXT_ENCODER@@": _ltx_te,
                    "@@VAE_NAME@@": _ltx_vae,
                    "@@POSITIVE@@": prompt,
                    "@@NEGATIVE@@": body.get("negative") or "low quality, blurry, static",
                    "@@IMAGE@@": _img_rel,
                    "@@WIDTH@@": width, "@@HEIGHT@@": height,
                    "@@FRAMES@@": _frames, "@@FPS@@": fps, "@@FPS_INT@@": fps,
                    "@@STEPS@@": steps, "@@SEED@@": seed,
                    "@@GUIDANCE@@": cfg, "@@MOTION@@": _motion,
                    "@@OUTPUT_PREFIX@@": prefix,
                }
                for _k, _v in _repl.items():
                    if isinstance(_v, str):
                        _wf_str = _wf_str.replace(f'"{_k}"', json.dumps(_v))
                        _wf_str = _wf_str.replace(_k, _v)
                    else:
                        _wf_str = _wf_str.replace(f'"{_k}"', json.dumps(_v))
                        _wf_str = _wf_str.replace(_k, str(_v))
                try:
                    wf = json.loads(_wf_str)
                except Exception as _e:
                    return web.json_response({"error": f"Workflow inject hata: {_e}"}, status=500)

        elif engine == "wan_s2v":
            unet_cands = (folder_paths.get_filename_list("diffusion_models") or [])
            unet_cands += (folder_paths.get_filename_list("unet") or [])
            wan_unets = [f for f in unet_cands if "wan" in f.lower() and ("s2v" in f.lower() or "humo" in f.lower() or "2_2" in f.lower() or "2.2" in f.lower())]
            if not wan_unets:
                return web.json_response({
                    "error": "Wan 2.2-S2V modeli eksik — otomatik indiriliyor",
                    "install_id": "wan_2_2_i2v_14b",
                    "install_hint_tr": "Wan 2.2 I2V 14B (~32GB)",
                }, status=424)
            wan_unet = wan_unets[0]
            vae_cands = folder_paths.get_filename_list("vae") or []
            wan_vae = next((f for f in vae_cands if "wan" in f.lower()), None)
            if not wan_vae:
                return web.json_response({"error": "Wan VAE yok"}, status=400)
            clip_cands = (folder_paths.get_filename_list("text_encoders") or [])
            wan_clip = next((f for f in clip_cands if "umt5" in f.lower()),
                            next((f for f in clip_cands if "t5xxl" in f.lower()), None))
            if not wan_clip:
                return web.json_response({"error": "umt5 / t5xxl encoder yok"}, status=400)
            models = {"unet": wan_unet, "clip": wan_clip, "vae": wan_vae, "audio": audio_path}
            # Wan 2.2-S2V workflow placeholder — custom node spesifik
            return web.json_response({"error": "Wan 2.2-S2V workflow şu an Quick Lab'da hazır değil. Lütfen Video Stüdyo (project mode) kullanın."}, status=501)

        elif engine == "infinitetalk":
            return web.json_response({"error": "InfiniteTalk Quick Lab'da hazır değil. Lütfen Video Stüdyo (project mode) kullanın."}, status=501)

        else:
            return web.json_response({"error": f"Bilinmeyen engine: {engine}"}, status=400)

        import uuid as _u
        from execution import validate_prompt as _vp
        pid = str(_u.uuid4())
        # Sprint 53: validate_prompt(pid, wf, None)
        valid = await _vp(pid, wf, None) if asyncio.iscoroutinefunction(_vp) else _vp(pid, wf, None)
        if not valid[0]:
            return web.json_response({"error": "Workflow validate fail",
                                       "engine": engine, "details": str(valid[1]) if len(valid) > 1 else "unknown",
                                       "node_errors": valid[3] if len(valid) > 3 else {}}, status=500)
        outputs_to_exec = valid[2] if len(valid) > 2 else []
        _server.prompt_queue.put((0, pid, wf,
                                   {"client_id": "fikoyt_quicklab",
                                     "create_time": int(time.time() * 1000)},
                                   outputs_to_exec, {}))
        return web.json_response({
            "ok": True, "prompt_id": pid, "seed": seed,
            "engine": engine, "duration_sec": duration, "fps": fps,
            "size": {"w": width, "h": height},
            "denoise": denoise,
            "expected_prefix": prefix, "output_dir": "output/fikoyt_quick/",
            "models": models,
        })

    @_server.routes.get("/fikoyt_mvd/quick/ltx_models")
    async def _quick_ltx_models(request):
        """LTX checkpoint + LoRA + upscaler listesi (frontend dropdown için)."""
        import folder_paths as _fp
        _ckpts = _fp.get_filename_list("checkpoints") or []
        _unets = (_fp.get_filename_list("diffusion_models") or []) + (_fp.get_filename_list("unet") or [])
        ltx_ckpts = sorted([f for f in _ckpts if "ltx" in f.lower() and f.lower().endswith(".safetensors")])
        ltx_unets = sorted([f for f in _unets if "ltx" in f.lower()])
        _lora_files = _fp.get_filename_list("loras") or []
        _lora_defs = [
            ("distilled_1", "distilled-lora-384", 0.5),
            ("distilled_2", "distilled-lora-384", 0.5),
            ("transition",  "transition",         0.9),
            ("detailer",    "detailer",            0.8),
            ("licon",       "licon-vbvr",          0.6),
            ("lipdub",      "lipdub",              0.6),
            ("audio_react", "audioreactive",       0.6),
            ("talkvid",     "talkvid",             0.5),
        ]
        loras = []
        for _lk, _lpat, _ldef in _lora_defs:
            _lf = next((f for f in _lora_files if _lpat.lower() in f.lower() and "ltx" in f.lower()), None)
            if not _lf:
                _lf = next((f for f in _lora_files if _lpat.lower() in f.lower()), None)
            loras.append({"key": _lk, "pattern": _lpat, "file": _lf, "default_strength": _ldef})
        _up_files = (_fp.get_filename_list("upscale_models") or [])
        _upscaler = next((f for f in _up_files if "ltx" in f.lower() and "upscal" in f.lower()), None)
        return web.json_response({
            "checkpoints": ltx_ckpts + ltx_unets,
            "loras": loras,
            "upscaler": _upscaler,
        })

    @_server.routes.post("/fikoyt_mvd/quick/image_resize")
    async def _quick_image_resize(request):
        """Görseli AI (ESRGAN 4x) veya Lanczos ile hedef çözünürlüğe boyutlandır.
        Body: {image, target_width, target_height, method: "esrgan"|"lanczos"|"auto"}"""
        import folder_paths as _fp
        import time, asyncio
        from PIL import Image as _PIL
        from nodes import NODE_CLASS_MAPPINGS as _NCM

        body = await request.json()
        image_rel = (body.get("image") or "").strip()
        tw = int(body.get("target_width") or 0)
        th = int(body.get("target_height") or 0)
        method = (body.get("method") or "auto").strip().lower()

        if not image_rel:
            return web.json_response({"error": "image gerekli"}, status=400)
        if tw < 64 or th < 64:
            return web.json_response({"error": "target_width/height min 64"}, status=400)
        tw = (tw // 8) * 8
        th = (th // 8) * 8

        def _resolve(s):
            s = (s or "").strip()
            if not s: return None
            if s.startswith("/view?") or s.startswith("view?"):
                try:
                    from urllib.parse import urlparse, parse_qs
                    q = parse_qs(urlparse(s).query)
                    fn = (q.get("filename") or [""])[0]
                    tp = (q.get("type") or ["output"])[0]
                    sub = (q.get("subfolder") or [""])[0]
                    base = _fp.get_output_directory() if tp == "output" else _fp.get_input_directory()
                    cand = os.path.join(base, sub, fn)
                    return cand if os.path.isfile(cand) else None
                except Exception:
                    return None
            if os.path.isabs(s) and os.path.isfile(s): return s
            for base in (_fp.get_input_directory(), _fp.get_output_directory()):
                cand = os.path.join(base, s)
                if os.path.isfile(cand): return cand
            return None

        abs_path = _resolve(image_rel)
        if not abs_path:
            return web.json_response({"error": f"Görsel bulunamadı: {image_rel}"}, status=404)

        try:
            with _PIL.open(abs_path) as _im:
                sw, sh = _im.size
        except Exception as e:
            return web.json_response({"error": f"Görsel okunamadı: {e}"}, status=400)

        import shutil as _sh
        inp_base = os.path.basename(abs_path)
        inp_dest = os.path.join(_fp.get_input_directory(), inp_base)
        try:
            if not os.path.isfile(inp_dest) or os.path.getsize(inp_dest) != os.path.getsize(abs_path):
                _sh.copyfile(abs_path, inp_dest)
        except Exception:
            pass

        need_upscale = (tw * th) > (sw * sh)
        if method == "auto":
            method = "esrgan" if need_upscale else "lanczos"

        ts = int(time.time())
        prefix = f"fikoyt_quick/resize_{ts}"
        wf = {}
        wf["load"] = {"class_type": "LoadImage", "inputs": {"image": inp_base}}
        img_ref = ["load", 0]

        if method == "esrgan":
            up_files = _fp.get_filename_list("upscale_models") or []
            up_model = next((f for f in up_files if "ultrasharp" in f.lower()),
                       next((f for f in up_files if "realesrgan_x4" in f.lower()),
                       next((f for f in up_files if "esrgan" in f.lower() and "x4" in f.lower()),
                       next((f for f in up_files if "esrgan" in f.lower()), None))))
            if not up_model:
                return web.json_response({
                    "error": "ESRGAN modeli yok",
                    "hint": "models/upscale_models/ altına RealESRGAN_x4plus.pth indirin"
                }, status=400)
            wf["up_load"] = {"class_type": "UpscaleModelLoader", "inputs": {"model_name": up_model}}
            wf["up_apply"] = {"class_type": "ImageUpscaleWithModel",
                              "inputs": {"upscale_model": ["up_load", 0], "image": img_ref}}
            img_ref = ["up_apply", 0]

        wf["scale"] = {"class_type": "ImageScale",
                       "inputs": {"image": img_ref, "width": tw, "height": th,
                                  "upscale_method": "lanczos", "crop": "disabled"}}
        wf["save"] = {"class_type": "SaveImage",
                      "inputs": {"images": ["scale", 0], "filename_prefix": prefix}}

        import uuid as _u
        from execution import validate_prompt as _vp
        pid = str(_u.uuid4())
        valid = await _vp(pid, wf, None) if asyncio.iscoroutinefunction(_vp) else _vp(pid, wf, None)
        if not valid[0]:
            return web.json_response({"error": "Workflow validate fail",
                                       "details": str(valid[1]) if len(valid) > 1 else "unknown"}, status=500)
        outputs_to_exec = valid[2] if len(valid) > 2 else []
        _server.prompt_queue.put((0, pid, wf,
                                   {"client_id": "fikoyt_quicklab",
                                     "create_time": int(time.time() * 1000)},
                                   outputs_to_exec, {}))
        return web.json_response({
            "ok": True, "prompt_id": pid,
            "method": method, "source": {"w": sw, "h": sh}, "target": {"w": tw, "h": th},
            "expected_prefix": prefix, "output_dir": "output/fikoyt_quick/",
        })

    @_server.routes.get("/fikoyt_mvd/quick/files")
    async def _quick_files(request):
        """Output veya input klasörünü tara, dosyaları listele.
        Query: ?type=output|input  &kind=image|audio|video|all  &limit=N"""
        import folder_paths as _fp
        ftype = request.rel_url.query.get("type", "output")
        kind_filter = request.rel_url.query.get("kind", "all")
        try:
            limit = int(request.rel_url.query.get("limit", "200"))
        except Exception:
            limit = 200
        if ftype == "input":
            base = _fp.get_input_directory()
        else:
            base = _fp.get_output_directory()
        if not os.path.isdir(base):
            return web.json_response({"items": [], "base": base})

        IMG_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp")
        AUDIO_EXTS = (".flac", ".wav", ".mp3", ".ogg", ".m4a", ".opus")
        VIDEO_EXTS = (".mp4", ".webm", ".mkv", ".mov", ".gif")

        def _classify(ext):
            e = ext.lower()
            if e in IMG_EXTS: return "image"
            if e in AUDIO_EXTS: return "audio"
            # webp/gif: animated olabilir ama Quick Lab'da hep video kabul edelim
            if e == ".webp" or e == ".gif": return "video"
            if e in VIDEO_EXTS: return "video"
            return "other"

        items = []
        for root, dirs, files in os.walk(base):
            # Çok derin gitme
            depth = os.path.relpath(root, base).count(os.sep)
            if depth > 3:
                dirs[:] = []
                continue
            # Cache/temp klasörleri skip
            dirs[:] = [d for d in dirs if not d.startswith(("_temp", "__pycache", "._"))]
            rel_sub = os.path.relpath(root, base).replace("\\", "/")
            if rel_sub == ".":
                rel_sub = ""
            for f in files:
                if f.startswith("."): continue
                ext = os.path.splitext(f)[1]
                kind = _classify(ext)
                if kind == "other": continue
                if kind_filter != "all" and kind != kind_filter: continue
                full = os.path.join(root, f)
                try:
                    items.append({
                        "filename": f,
                        "subfolder": rel_sub,
                        "type": ftype,
                        "kind": kind,
                        "ext": ext.lstrip("."),
                        "size_kb": os.path.getsize(full) // 1024,
                        "mtime": int(os.path.getmtime(full)),
                    })
                except OSError:
                    continue
                if len(items) >= limit * 3:
                    break
            if len(items) >= limit * 3:
                break
        items.sort(key=lambda x: x["mtime"], reverse=True)
        items = items[:limit]
        return web.json_response({"items": items, "base": base, "type": ftype, "kind": kind_filter})

    @_server.routes.post("/fikoyt_mvd/quick/prompt_enhance")
    async def _quick_prompt_enhance(request):
        """LLM Prompt Sihirbazı (domain-aware).
        Body: {
          domain: 'music'|'image'|'video',
          seed: string,    // kısa kullanıcı fikri
          lyrics?: string, // sadece music
          duration_sec?: number,
          character?: string, // image için karakter bilgisi
        }
        Döner: {prompt: enhanced_text} veya {error}
        """
        try:
            body = await request.json()
        except Exception as e:
            return web.json_response({"error": f"JSON: {e}"}, status=400)

        domain = (body.get("domain") or "image").lower()
        seed_text = (body.get("seed") or "").strip()
        lyrics = (body.get("lyrics") or "").strip()
        duration = body.get("duration_sec")
        character = (body.get("character") or "").strip()

        # System prompts per domain
        if domain == "music":
            sys_prompt = (
                "You are a professional music style prompt builder for the ACE-Step audio diffusion model. "
                "Given a user's brief idea (and optionally the lyrics), output a SINGLE LINE of comma-separated style tags. "
                "Include: genre, sub-genre, era, vocal style (male/female/choir/whisper), 2-3 instruments, mood, tempo (BPM number), key (musical key). "
                "Keep tags SHORT (1-3 words each), 7-12 tags total. NO markdown, NO explanation, ONLY the comma list.\n"
                "Example output: turkish pop, female vocal, melancholic, 120 BPM, A minor, acoustic guitar, soft piano, warm winter mood, intimate, breathy whisper"
            )
            user_prompt = f"Brief idea: {seed_text}"
            if lyrics:
                user_prompt += f"\n\nLyrics (first 200 chars):\n{lyrics[:200]}"
            if duration:
                user_prompt += f"\n\nTarget duration: {duration}s"
        elif domain == "image":
            sys_prompt = (
                "You are a professional Flux image prompt builder. Given a user's brief idea, "
                "output ONE flowing cinematic English sentence (40-70 words) describing: "
                "shot type, subject (character if given), outfit, pose, expression, specific location/setting, "
                "lighting, lens/camera, color grade, atmosphere. "
                "End with 'sharp focus, detailed, cinematic'. NO markdown, NO labels, ONLY the sentence."
            )
            user_prompt = f"Brief idea: {seed_text}"
            if character:
                user_prompt += f"\n\nCharacter: {character}"
        elif domain == "video":
            sys_prompt = (
                "You are a professional video motion prompt builder for LTX/Wan video diffusion. "
                "Given a user's brief idea, output ONE concise motion description sentence (20-40 words): "
                "camera movement (pan/dolly/zoom/static), subject motion, atmosphere, particles/environmental motion. "
                "NO markdown, NO labels, ONLY the sentence."
            )
            user_prompt = f"Brief idea: {seed_text}"
        else:
            return web.json_response({"error": f"Bilinmeyen domain: {domain}"}, status=400)

        # LLM call — _llm_chat (Ollama) kullan
        try:
            from ._legacy import _llm_chat
            # Ollama URL ve model — default qwen2.5
            ollama_url = "http://127.0.0.1:11434"
            ollama_model = "qwen2.5:7b-instruct-q4_K_M"
            # Body'den override (opsiyonel)
            ollama_url = body.get("ollama_url") or ollama_url
            ollama_model = body.get("ollama_model") or ollama_model

            raw = _llm_chat(ollama_url, ollama_model, sys_prompt, user_prompt, timeout=120)
            if not raw:
                return web.json_response({"error": "LLM cevap dönmedi (Ollama çalışıyor mu?)"}, status=500)
            # Temizle
            import re as _re
            cleaned = _re.sub(r"<think>.*?</think>", "", raw, flags=_re.DOTALL).strip()
            cleaned = _re.sub(r"```(?:\w+)?", "", cleaned).strip("`\n ")
            # İlk paragraf
            cleaned = cleaned.split("\n\n")[0].strip()
            return web.json_response({
                "ok": True,
                "prompt": cleaned,
                "domain": domain,
                "model_used": ollama_model,
            })
        except Exception as e:
            import traceback; traceback.print_exc()
            return web.json_response({"error": f"LLM hata: {e}",
                                       "hint": "Ollama kuruluysa http://127.0.0.1:11434 çalıştığını kontrol et"}, status=500)

    # =========================================================================
    # Sprint W22+ — Style Bundle Presets
    # Tek tıkla koordineli stil paketleri: LoRA stack + prompt suffix +
    # negative_extra + sampler overrides + color grade (A15 LUT preset)
    # Frontend bunları çekip body'ye additif (kullanıcı promptu + suffix) merge eder.
    # =========================================================================
    STYLE_BUNDLES = [
        {
            "id": "cinematic",
            "name": {"tr": "Sinematik", "en": "Cinematic"},
            "thumbnail": "🎬",
            "description": {
                "tr": "Anamorfik 35mm film hissi, teal & orange grading",
                "en": "Anamorphic 35mm film look, teal & orange grading",
            },
            "lora_stack": [
                {"name": "flux_cinematic.safetensors", "strength": 0.85},
            ],
            "prompt_suffix": ("cinematic lighting, anamorphic lens, 35mm film grain, "
                              "shallow depth of field, teal and orange color grading, "
                              "rim light, dramatic mood, professional cinematography"),
            "negative_extra": "flat lighting, amateur photo, low contrast, video game, plastic",
            "sampler_overrides": {
                "steps": 32, "cfg": 1.0, "sampler_name": "dpmpp_2m_sde",
                "scheduler": "karras", "flux_guidance": 3.8,
            },
            "color_grade": {"lut_preset": "cinematic", "lut_strength": 0.85,
                             "vignette_intensity": 0.30, "vignette_falloff": 0.6,
                             "chromatic_aberration": 1, "letterbox_ratio": 2.39},
        },
        {
            "id": "anime",
            "name": {"tr": "Anime", "en": "Anime"},
            "thumbnail": "🎌",
            "description": {
                "tr": "Estetik anime FLUX, canlı renkler, temiz çizgi",
                "en": "Aesthetic anime FLUX, vibrant colors, clean line art",
            },
            "lora_stack": [
                {"name": "aesthetic_anime_flux.safetensors", "strength": 0.95},
            ],
            "prompt_suffix": ("anime style, vibrant colors, clean line art, cel shading, "
                              "expressive eyes, detailed background, studio anime production quality"),
            "negative_extra": ("photo, photorealistic, realistic, 3d render, blurry, "
                                "deformed, bad anatomy, watermark, muted colors"),
            "sampler_overrides": {
                "steps": 28, "cfg": 1.0, "sampler_name": "euler_ancestral",
                "scheduler": "simple", "flux_guidance": 3.5,
            },
            "color_grade": {"lut_preset": "vibrant", "lut_strength": 0.7,
                             "vignette_intensity": 0.0, "vignette_falloff": 0.5,
                             "chromatic_aberration": 0, "letterbox_ratio": 0.0},
        },
        {
            "id": "photorealistic",
            "name": {"tr": "Fotorealistik", "en": "Photorealistic"},
            "thumbnail": "📷",
            "description": {
                "tr": "DSLR 50mm, profesyonel fotoğraf, keskin odak",
                "en": "DSLR 50mm, professional photography, sharp focus",
            },
            "lora_stack": [],
            "prompt_suffix": ("photorealistic, dslr photo, sharp focus, 50mm lens, "
                              "professional photography, natural skin texture, "
                              "high dynamic range, color graded, ISO 100, f/1.8"),
            "negative_extra": ("cartoon, anime, illustration, painting, drawing, 3d render, "
                                "cgi, plastic skin, oversaturated, low quality, watermark, "
                                "deformed, extra fingers"),
            "sampler_overrides": {
                "steps": 32, "cfg": 1.0, "sampler_name": "dpmpp_2m",
                "scheduler": "karras", "flux_guidance": 3.5,
            },
            "color_grade": {"lut_preset": "none", "lut_strength": 0.0,
                             "vignette_intensity": 0.10, "vignette_falloff": 0.7,
                             "chromatic_aberration": 0, "letterbox_ratio": 0.0},
        },
        {
            "id": "vintage_70s",
            "name": {"tr": "Vintage 70'ler", "en": "Vintage 70s"},
            "thumbnail": "📼",
            "description": {
                "tr": "70'ler analog film, sıcak tonlar, hafif soluk",
                "en": "70s analog film, warm tones, slight fade",
            },
            "lora_stack": [
                {"name": "vintage_film_flux.safetensors", "strength": 0.80},
            ],
            "prompt_suffix": ("vintage 1970s photograph, kodachrome film, warm color palette, "
                              "faded analog tones, soft grain, retro fashion, sun-soaked, "
                              "nostalgic atmosphere, period accurate"),
            "negative_extra": "modern, digital, sharp, cool tones, neon, high contrast, 4k clean",
            "sampler_overrides": {
                "steps": 26, "cfg": 1.0, "sampler_name": "dpmpp_2m",
                "scheduler": "simple", "flux_guidance": 3.5,
            },
            "color_grade": {"lut_preset": "warm", "lut_strength": 0.85,
                             "vignette_intensity": 0.40, "vignette_falloff": 0.55,
                             "chromatic_aberration": 2, "letterbox_ratio": 0.0},
        },
        {
            "id": "y2k_cyber",
            "name": {"tr": "Y2K Siber", "en": "Y2K Cyber"},
            "thumbnail": "💿",
            "description": {
                "tr": "2000'ler erken dijital, metalik krom, soğuk tonlar",
                "en": "Early 2000s digital, chrome metallic, cool tones",
            },
            "lora_stack": [
                {"name": "y2k_aesthetic_flux.safetensors", "strength": 0.85},
            ],
            "prompt_suffix": ("y2k aesthetic, early 2000s digital, metallic chrome textures, "
                              "translucent plastic, holographic accents, cyber fashion, "
                              "frutiger aero, glossy, futuristic but retro"),
            "negative_extra": "warm tones, vintage film, rustic, brown, sepia, organic",
            "sampler_overrides": {
                "steps": 28, "cfg": 1.0, "sampler_name": "dpmpp_2m",
                "scheduler": "simple", "flux_guidance": 3.6,
            },
            "color_grade": {"lut_preset": "cool", "lut_strength": 0.80,
                             "vignette_intensity": 0.15, "vignette_falloff": 0.6,
                             "chromatic_aberration": 1, "letterbox_ratio": 0.0},
        },
        {
            "id": "watercolor",
            "name": {"tr": "Suluboya", "en": "Watercolor"},
            "thumbnail": "🎨",
            "description": {
                "tr": "Suluboya boyama, yumuşak fırça darbeleri",
                "en": "Watercolor painting, soft brushstrokes",
            },
            "lora_stack": [
                {"name": "watercolor_flux.safetensors", "strength": 0.90},
            ],
            "prompt_suffix": ("watercolor painting, soft brushstrokes, paper texture, "
                              "delicate washes, bleeding pigments, traditional media, "
                              "loose painterly style, light translucent layers"),
            "negative_extra": ("photo, photorealistic, 3d render, sharp digital, harsh lines, "
                                "vector art, plastic, glossy"),
            "sampler_overrides": {
                "steps": 26, "cfg": 1.0, "sampler_name": "euler",
                "scheduler": "simple", "flux_guidance": 3.4,
            },
            "color_grade": {"lut_preset": "none", "lut_strength": 0.0,
                             "vignette_intensity": 0.0, "vignette_falloff": 0.5,
                             "chromatic_aberration": 0, "letterbox_ratio": 0.0},
        },
        {
            "id": "comic_book",
            "name": {"tr": "Çizgi Roman", "en": "Comic Book"},
            "thumbnail": "💥",
            "description": {
                "tr": "Çizgi roman stili, halftone, kalın konturlar",
                "en": "Comic book style, halftone, bold outlines",
            },
            "lora_stack": [
                {"name": "comic_book_flux.safetensors", "strength": 0.90},
            ],
            "prompt_suffix": ("comic book style, halftone dots, bold black outlines, "
                              "flat cel shading, vibrant primary colors, ink lines, "
                              "dynamic action panel composition, graphic novel art"),
            "negative_extra": ("photo, photorealistic, soft gradients, watercolor, blurry, "
                                "muted colors, 3d render"),
            "sampler_overrides": {
                "steps": 26, "cfg": 1.0, "sampler_name": "euler",
                "scheduler": "sgm_uniform", "flux_guidance": 3.6,
            },
            "color_grade": {"lut_preset": "vibrant", "lut_strength": 0.6,
                             "vignette_intensity": 0.0, "vignette_falloff": 0.5,
                             "chromatic_aberration": 0, "letterbox_ratio": 0.0},
        },
        {
            "id": "cyberpunk_neon",
            "name": {"tr": "Cyberpunk Neon", "en": "Cyberpunk Neon"},
            "thumbnail": "🌃",
            "description": {
                "tr": "Neon ışıklı cyberpunk, yağmurlu sokaklar, soğuk vurgular",
                "en": "Neon-lit cyberpunk, rainy streets, cool highlights",
            },
            "lora_stack": [
                {"name": "cyberpunk_flux.safetensors", "strength": 0.88},
            ],
            "prompt_suffix": ("cyberpunk aesthetic, neon lighting, glowing signs in japanese, "
                              "rain-soaked streets, reflective puddles, magenta and cyan rim light, "
                              "futuristic megacity, blade runner atmosphere, atmospheric haze"),
            "negative_extra": ("daylight, rural, vintage, sepia, warm only, low detail, "
                                "muted colors, flat lighting"),
            "sampler_overrides": {
                "steps": 32, "cfg": 1.0, "sampler_name": "dpmpp_2m_sde",
                "scheduler": "karras", "flux_guidance": 3.8,
            },
            "color_grade": {"lut_preset": "teal_orange", "lut_strength": 0.90,
                             "vignette_intensity": 0.35, "vignette_falloff": 0.55,
                             "chromatic_aberration": 2, "letterbox_ratio": 2.39},
        },
    ]

    @_server.routes.get("/fikoyt_mvd/styles/bundles")
    async def _styles_bundles(request):
        """Style Bundle preset listesi — image.js dropdown'u bunu çekip render eder.

        Response: {"bundles": [...], "count": N, "color_grade_lut_presets": [...]}
        Frontend bundle seçince body'ye additif merge:
          - prompt   = user_prompt   + ", " + bundle.prompt_suffix
          - negative = user_negative + ", " + bundle.negative_extra
          - loras    = user_loras + bundle.lora_stack (dedupe by name)
          - sampler params: bundle.sampler_overrides ile override (toggle ile opsiyonel)
          - color_grade: post-processing için body.color_grade'e kopyala (A15 LUT)
        """
        return web.json_response({
            "bundles": STYLE_BUNDLES,
            "count": len(STYLE_BUNDLES),
            "color_grade_lut_presets": ["none", "cinematic", "vintage", "vibrant",
                                          "teal_orange", "noir", "warm", "cool"],
            "version": 1,
        })

    # Sprint W22+ — Vision LLM: görselden prompt öner (Ollama vision modeli ile)
    @_server.routes.post("/fikoyt_mvd/quick/prompt_from_image")
    async def _quick_prompt_from_image(request):
        """Bir görseli Ollama vision LLM'ine yollayıp prompt önerisi al.

        Body: {
          image_path: str (input/ veya output/ relative path veya absolute),
          domain?: 'image'|'video' (default 'video' — Standalone Video için),
          ollama_url?, ollama_model?  (default 'llava' veya 'qwen2.5-vl')
        }
        Returns: { ok, prompt, model_used }
        """
        try:
            body = await request.json()
        except Exception as e:
            return web.json_response({"error": f"JSON: {e}"}, status=400)
        image_rel = (body.get("image_path") or "").strip()
        domain = (body.get("domain") or "video").lower()
        if not image_rel:
            return web.json_response({"error": "image_path zorunlu"}, status=400)
        # Path resolve (input/ veya output/)
        import folder_paths as _fp
        cand = None
        if os.path.isabs(image_rel) and os.path.isfile(image_rel):
            cand = image_rel
        else:
            for base in (_fp.get_input_directory(), _fp.get_output_directory()):
                p = os.path.join(base, image_rel)
                if os.path.isfile(p):
                    cand = p; break
        if not cand:
            return web.json_response({
                "error": f"Görsel bulunamadı: {image_rel}",
                "hint": "input/ veya output/ klasöründe olmalı"
            }, status=404)

        # Image → base64
        try:
            import base64
            with open(cand, "rb") as f:
                img_b64 = base64.b64encode(f.read()).decode("ascii")
        except Exception as e:
            return web.json_response({"error": f"Görsel okunamadı: {e}"}, status=500)

        # Domain'e göre system prompt
        if domain == "video":
            sys_prompt = (
                "You are a professional video motion prompt builder. Look at the image and write "
                "ONE concise English sentence (20-40 words) describing how this scene could come alive "
                "in a video: camera movement (pan/dolly/zoom/static), subject motion, atmosphere, "
                "particles/environmental motion. NO markdown, NO labels, ONLY the sentence."
            )
            user_prompt = "What motion would best bring this image to life?"
        else:  # image
            sys_prompt = (
                "You are a professional Flux/Qwen image prompt builder. Look at this image and write "
                "ONE flowing cinematic English sentence (40-70 words) describing the visible: "
                "shot type, subject, outfit, pose, expression, setting, lighting, lens, color grade, mood. "
                "End with 'sharp focus, detailed, cinematic'. NO markdown, NO labels."
            )
            user_prompt = "Describe this image as a generative AI prompt."

        # Ollama vision API call — qwen2.5vl benchmark + güncellik bakımından llava'dan üstün (2026-05)
        ollama_url = (body.get("ollama_url") or "http://127.0.0.1:11434").rstrip("/")
        ollama_model = body.get("ollama_model") or "qwen2.5vl:7b"
        # Otomatik fallback: kurulu olan ilk vision modelini tespit et
        try:
            import urllib.request as _ur
            with _ur.urlopen(f"{ollama_url}/api/tags", timeout=3) as r:
                tags = json.loads(r.read().decode("utf-8"))
                installed = [m.get("name", "") for m in (tags.get("models") or [])]
            if ollama_model not in installed:
                # Vision modeli ara (qwen2.5vl, llava, llama3.2-vision, gemma3 vision priorities)
                priorities = ["qwen2.5vl", "llava-next", "llava", "llama3.2-vision", "gemma3"]
                for p in priorities:
                    found = next((m for m in installed if p in m.lower()), None)
                    if found:
                        ollama_model = found
                        break
        except Exception:
            pass

        try:
            import urllib.request, urllib.error, json as _json
            payload = {
                "model": ollama_model,
                "messages": [
                    {"role": "system", "content": sys_prompt},
                    {"role": "user",   "content": user_prompt, "images": [img_b64]},
                ],
                "stream": False,
            }
            req = urllib.request.Request(
                f"{ollama_url}/api/chat",
                data=_json.dumps(payload).encode("utf-8"),
                headers={"Content-Type": "application/json"},
            )
            with urllib.request.urlopen(req, timeout=120) as r:
                resp = _json.loads(r.read().decode("utf-8"))
            raw = (resp.get("message") or {}).get("content", "").strip()
            if not raw:
                return web.json_response({"error": "Vision LLM boş döndü"}, status=500)
            import re as _re
            cleaned = _re.sub(r"<think>.*?</think>", "", raw, flags=_re.DOTALL).strip()
            cleaned = _re.sub(r"```(?:\w+)?", "", cleaned).strip("`\n ")
            cleaned = cleaned.split("\n\n")[0].strip()
            return web.json_response({
                "ok": True, "prompt": cleaned, "domain": domain,
                "model_used": ollama_model, "image": cand,
            })
        except urllib.error.HTTPError as e:
            return web.json_response({
                "error": f"Ollama HTTP {e.code}: {e.reason}",
                "hint": (f"Vision modeli kurulu mu? `ollama pull {ollama_model}` "
                         "(önerilen: qwen2.5vl:7b — llava'dan benchmark'larda üstün)"),
            }, status=500)
        except Exception as e:
            return web.json_response({
                "error": f"Vision LLM hata: {e}",
                "hint": "Ollama çalışıyor mu? http://127.0.0.1:11434 — qwen2.5vl:7b önerilen",
            }, status=500)

    # Sprint W23 — Prompt Library CRUD (kullanıcı favori promptları)
    _PROMPT_LIB_FILE = os.path.join(
        os.path.dirname(__file__), "..", "..", "..",
        "output", "fikoyt_mvd", "prompt_library.json"
    )

    def _load_prompt_lib():
        try:
            with open(_PROMPT_LIB_FILE, "r", encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            return {"prompts": []}

    def _save_prompt_lib(data):
        try:
            os.makedirs(os.path.dirname(_PROMPT_LIB_FILE), exist_ok=True)
            with open(_PROMPT_LIB_FILE, "w", encoding="utf-8") as f:
                json.dump(data, f, indent=2, ensure_ascii=False)
            return True
        except Exception as e:
            print(f"[FikoYT][PromptLib] save fail: {e}")
            return False

    @_server.routes.get("/fikoyt_mvd/prompts/library")
    async def _prompt_lib_list(request):
        data = _load_prompt_lib()
        prompts = data.get("prompts", [])
        q = (request.query.get("q") or "").strip().lower()
        tag = (request.query.get("tag") or "").strip().lower()
        domain = (request.query.get("domain") or "").strip().lower()
        if q:    prompts = [p for p in prompts if q in (p.get("text","")+" "+p.get("title","")).lower()]
        if tag:  prompts = [p for p in prompts if tag in [t.lower() for t in p.get("tags",[])]]
        if domain: prompts = [p for p in prompts if p.get("domain") == domain]
        return web.json_response({"prompts": prompts, "total": len(prompts)})

    @_server.routes.post("/fikoyt_mvd/prompts/library")
    async def _prompt_lib_add(request):
        try:
            body = await request.json()
        except Exception:
            return web.json_response({"error": "Geçersiz JSON"}, status=400)
        text = (body.get("text") or "").strip()
        if not text:
            return web.json_response({"error": "text zorunlu"}, status=400)
        data = _load_prompt_lib()
        prompts = data.get("prompts", [])
        pid = body.get("id") or f"p_{int(time.time())}_{len(prompts)}"
        # Aynı id varsa güncelle
        prompts = [p for p in prompts if p.get("id") != pid]
        entry = {
            "id": pid,
            "text": text,
            "title": (body.get("title") or "").strip() or text[:40],
            "negative": (body.get("negative") or "").strip(),
            "domain": (body.get("domain") or "image").lower(),  # image/video/music
            "tags": [t.lower() for t in (body.get("tags") or []) if t.strip()],
            "engine_hint": body.get("engine_hint") or "",
            "created_at": int(time.time()),
            "uses": int(body.get("uses") or 0),
        }
        prompts.insert(0, entry)
        data["prompts"] = prompts[:500]  # max 500 prompt
        _save_prompt_lib(data)
        return web.json_response({"ok": True, "id": pid, "entry": entry})

    @_server.routes.delete("/fikoyt_mvd/prompts/library/{pid}")
    async def _prompt_lib_del(request):
        pid = request.match_info["pid"]
        data = _load_prompt_lib()
        before = len(data.get("prompts", []))
        data["prompts"] = [p for p in data.get("prompts", []) if p.get("id") != pid]
        _save_prompt_lib(data)
        return web.json_response({"ok": True, "removed": before - len(data["prompts"])})

    @_server.routes.post("/fikoyt_mvd/prompts/library/{pid}/use")
    async def _prompt_lib_use(request):
        pid = request.match_info["pid"]
        data = _load_prompt_lib()
        for p in data.get("prompts", []):
            if p.get("id") == pid:
                p["uses"] = int(p.get("uses", 0)) + 1
                p["last_used"] = int(time.time())
                break
        _save_prompt_lib(data)
        return web.json_response({"ok": True})

    # Sprint W23 — Render Stats Dashboard
    @_server.routes.get("/fikoyt_mvd/stats/dashboard")
    async def _stats_dashboard(request):
        """Son 7-30 günlük render istatistikleri.
        ComfyUI /history endpoint'inden veri çeker.
        """
        # Doğrudan ComfyUI'nin in-memory history'sine eriş (HTTP self-call timeout sorunu)
        try:
            from server import PromptServer as _PS
            srv_inst = _PS.instance
            # ComfyUI history dict — prompt_queue üzerinden
            hist = {}
            try:
                # ComfyUI internal: server.history dict veya prompt_queue.history
                if hasattr(srv_inst, "history"):
                    hist = dict(srv_inst.history)
                elif hasattr(srv_inst, "prompt_queue") and hasattr(srv_inst.prompt_queue, "history"):
                    hist = dict(srv_inst.prompt_queue.history)
                else:
                    # HTTP fallback (kısa timeout)
                    import urllib.request as _ur
                    with _ur.urlopen("http://127.0.0.1:8188/history", timeout=3) as r:
                        hist = json.loads(r.read().decode("utf-8"))
            except Exception:
                hist = {}
        except Exception as e:
            return web.json_response({"error": f"History okunamadı: {e}"}, status=500)

        now = int(time.time())
        day_sec = 86400
        days = int(request.query.get("days") or 7)
        cutoff = now - (days * day_sec)

        items = []
        for pid, e in (hist or {}).items():
            status = (e.get("status") or {}).get("status_str", "")
            msgs = (e.get("status") or {}).get("messages", []) or []
            t_start = None; t_end = None
            for m in msgs:
                if isinstance(m, list) and len(m) >= 2:
                    if m[0] == "execution_start": t_start = m[1].get("timestamp", 0) / 1000
                    elif m[0] == "execution_success": t_end = m[1].get("timestamp", 0) / 1000
                    elif m[0] == "execution_error": t_end = m[1].get("timestamp", 0) / 1000
            if not t_start or t_start < cutoff:
                continue
            outs = e.get("outputs", {}) or {}
            kinds = set()
            for o in outs.values():
                for k in ("images", "videos", "gifs", "audio"):
                    if o.get(k): kinds.add(k)
            items.append({
                "prompt_id": pid,
                "status": status,
                "duration_sec": (t_end - t_start) if (t_start and t_end) else None,
                "started_at": int(t_start) if t_start else 0,
                "kinds": list(kinds),
                "node_count": len(outs),
            })

        # Aggregate
        successes = [x for x in items if x["status"] == "success"]
        errors = [x for x in items if x["status"] == "error"]
        avg_dur = (sum(x["duration_sec"] for x in successes if x["duration_sec"])
                   / max(1, len([x for x in successes if x["duration_sec"]])))
        # Per-day bucket
        buckets = {}
        for x in items:
            day = time.strftime("%Y-%m-%d", time.localtime(x["started_at"]))
            buckets.setdefault(day, {"total": 0, "success": 0, "error": 0, "total_dur": 0.0})
            b = buckets[day]
            b["total"] += 1
            if x["status"] == "success": b["success"] += 1
            elif x["status"] == "error": b["error"] += 1
            if x["duration_sec"]: b["total_dur"] += x["duration_sec"]
        # Kind breakdown
        kind_counts = {}
        for x in items:
            for k in x["kinds"]:
                kind_counts[k] = kind_counts.get(k, 0) + 1

        return web.json_response({
            "ok": True,
            "days": days,
            "total": len(items),
            "success": len(successes),
            "error": len(errors),
            "success_rate": round(len(successes) / max(1, len(items)) * 100, 1),
            "avg_duration_sec": round(avg_dur, 1),
            "kind_counts": kind_counts,
            "daily_buckets": buckets,
            "recent": items[:20],
        })

    # Sprint W22+ — Disk space check (auto-install pre-flight için)
    @_server.routes.get("/fikoyt_mvd/system/disk")
    async def _system_disk(request):
        """models/ klasörünün diskindeki free space + toplam."""
        try:
            import shutil
            import folder_paths as _fp
            base = _fp.models_dir
            total, used, free = shutil.disk_usage(base)
            return web.json_response({
                "ok": True,
                "models_dir": base,
                "total_gb": round(total / 1024**3, 2),
                "used_gb": round(used / 1024**3, 2),
                "free_gb": round(free / 1024**3, 2),
                "free_pct": round((free / total) * 100, 1) if total else 0,
            })
        except Exception as e:
            return web.json_response({"ok": False, "error": str(e)}, status=500)

    @_server.routes.get("/fikoyt_mvd/quick/recent")
    async def _quick_recent(request):
        """Quick Lab klasörünün son üretilen dosyalarını listele."""
        import folder_paths as _fp
        out_dir = os.path.join(_fp.get_output_directory(), "fikoyt_quick")
        if not os.path.isdir(out_dir):
            return web.json_response({"items": []})
        items = []
        for f in sorted(os.listdir(out_dir), reverse=True):
            full = os.path.join(out_dir, f)
            if not os.path.isfile(full): continue
            ext = f.split(".")[-1].lower()
            kind = "image" if ext in ("png", "jpg", "jpeg", "webp") else \
                   "audio" if ext in ("flac", "wav", "mp3", "ogg") else \
                   "video" if ext in ("mp4", "webm", "webp", "gif") else "other"
            items.append({
                "filename": f, "kind": kind, "ext": ext,
                "size_kb": os.path.getsize(full) // 1024,
                "mtime": int(os.path.getmtime(full)),
                "url": f"/api/view?filename={f}&subfolder=fikoyt_quick&type=output",
            })
            if len(items) >= 50: break
        return web.json_response({"items": items, "dir": out_dir})

    # =========================================================================
    # Sprint W28 — SAM2 Smart Inpaint (Segment Anything 2 — kijai)
    # =========================================================================
    _sam2_install_state = {"in_progress": False, "log": "", "done": False, "error": ""}

    SAM2_MODELS = {
        "sam2_hiera_large": (
            "sams/sam2_hiera_large.pt",
            "https://huggingface.co/Kijai/sam2-safetensors/resolve/main/sam2_hiera_large.safetensors",
            900_000_000,
        ),
        "sam2_hiera_base_plus": (
            "sams/sam2_hiera_base_plus.pt",
            "https://huggingface.co/Kijai/sam2-safetensors/resolve/main/sam2_hiera_base_plus.safetensors",
            300_000_000,
        ),
    }

    def _sam2_status_dict():
        from nodes import NODE_CLASS_MAPPINGS as _NCM
        ok_nodes = bool(_NCM.get("DownloadAndLoadSAM2Model") or _NCM.get("Sam2Segmentation"))
        models_dir = folder_paths.models_dir
        models = {}
        for key, (rel, url, min_sz) in SAM2_MODELS.items():
            p = os.path.join(models_dir, rel)
            # safetensors veya .pt kabul et
            alt = p.replace(".pt", ".safetensors")
            models[key] = (os.path.isfile(p) and os.path.getsize(p) >= min_sz) or \
                          (os.path.isfile(alt) and os.path.getsize(alt) >= min_sz)
        return {
            "node_installed": ok_nodes,
            "models": models,
            "ready": ok_nodes and any(models.values()),
            "needs_restart": any(models.values()) and not ok_nodes,
        }

    @_server.routes.get("/fikoyt_mvd/check_sam2")
    async def _check_sam2(request):
        return web.json_response(_sam2_status_dict())

    @_server.routes.post("/fikoyt_mvd/install_sam2")
    async def _install_sam2(request):
        with _install_lock:
            if _sam2_install_state["in_progress"]:
                return web.json_response({"error": "Zaten yükleniyor"}, status=409)
            _sam2_install_state.update({
                "in_progress": True, "log": "", "done": False, "error": "",
                "current_file": "", "current_pct": 0,
                "downloaded_mb": 0, "total_mb": 0,
                "started_at": datetime.datetime.now().isoformat(),
            })

        async def _bg():
            try:
                await _git_clone_node(
                    "https://github.com/kijai/ComfyUI-segment-anything-2.git",
                    _sam2_install_state,
                    label="ComfyUI-segment-anything-2")
                _sam2_install_state["log"] += "\n[pip] SAM2 bağımlılıkları\n"
                for pkg in ("sam-2", "iopath"):
                    try:
                        await _stream_subprocess(
                            [sys.executable, "-s", "-m", "pip", "install", pkg],
                            _sam2_install_state)
                    except Exception as e:
                        _sam2_install_state["log"] += f"  ⚠ {pkg}: {e}\n"
                _sam2_install_state["log"] += "\n[hf] SAM2 modelleri (Kijai safetensors)\n"
                models_dir = folder_paths.models_dir
                for key, (rel, url, min_sz) in SAM2_MODELS.items():
                    # .safetensors olarak indir
                    dest = os.path.join(models_dir, rel).replace(".pt", ".safetensors")
                    os.makedirs(os.path.dirname(dest), exist_ok=True)
                    try:
                        await _hf_download(url, dest, _sam2_install_state, min_sz)
                    except Exception as e:
                        _sam2_install_state["log"] += f"  ❌ {key}: {e}\n"
                _sam2_install_state["log"] += "\n✓ SAM2 yüklemesi tamam. ComfyUI'yi RESTART et!\n"
            except Exception as e:
                _sam2_install_state["error"] = str(e)
                _sam2_install_state["log"] += f"\n❌ HATA: {e}\n"
            finally:
                _sam2_install_state["done"] = True
                _sam2_install_state["in_progress"] = False

        asyncio.create_task(_bg())
        return web.json_response({"ok": True})

    @_server.routes.get("/fikoyt_mvd/install_sam2_status")
    async def _install_sam2_status(request):
        return web.json_response(_sam2_install_state)

    # =========================================================================
    # Sprint W27 — HunyuanVideo Wrapper (kijai)
    # =========================================================================
    _hunyuan_install_state = {"in_progress": False, "log": "", "done": False, "error": ""}

    HUNYUAN_MODELS = {
        "hunyuan_video_unet": (
            "diffusion_models/hunyuan_video_720_cfgdistill_fp8_e4m3fn.safetensors",
            "https://huggingface.co/Kijai/HunyuanVideo_comfy/resolve/main/hunyuan_video_720_cfgdistill_fp8_e4m3fn.safetensors",
            12_000_000_000,
        ),
        "hunyuan_video_i2v_unet": (
            "diffusion_models/hunyuan_video_I2V_720_fixed_fp8_e4m3fn.safetensors",
            "https://huggingface.co/Kijai/HunyuanVideo_comfy/resolve/main/hunyuan_video_I2V_720_fixed_fp8_e4m3fn.safetensors",
            12_000_000_000,
        ),
        "hunyuan_video_vae": (
            "vae/hunyuan_video_vae_bf16.safetensors",
            "https://huggingface.co/Kijai/HunyuanVideo_comfy/resolve/main/hunyuan_video_vae_bf16.safetensors",
            450_000_000,
        ),
        "llava_llama3": (
            "LLM/llava-llama-3-8b-text-encoder-tokenizer",
            "hf_snapshot:Kijai/llava-llama-3-8b-text-encoder-tokenizer",
            0,
        ),
        "clip_l": (
            "clip/clip_l.safetensors",
            "https://huggingface.co/openai/clip-vit-large-patch14/resolve/main/model.safetensors",
            200_000_000,
        ),
    }

    def _hunyuan_status_dict():
        from nodes import NODE_CLASS_MAPPINGS as _NCM
        ok_nodes = bool(_NCM.get("HyVideoModelLoader") or _NCM.get("HyVideoSampler"))
        models_dir = folder_paths.models_dir
        models = {}
        for key, (rel, url, min_sz) in HUNYUAN_MODELS.items():
            p = os.path.join(models_dir, rel)
            if min_sz == 0:
                models[key] = os.path.isdir(p) and bool(
                    [f for f in os.listdir(p) if not f.startswith(".")] if os.path.isdir(p) else [])
            else:
                models[key] = os.path.isfile(p) and os.path.getsize(p) >= min_sz
        critical_ok = models.get("hunyuan_video_unet") and models.get("hunyuan_video_vae")
        return {
            "node_installed": ok_nodes,
            "models": models,
            "ready": ok_nodes and bool(critical_ok),
            "needs_restart": critical_ok and not ok_nodes,
            "i2v_ready": bool(models.get("hunyuan_video_i2v_unet")),
            "size_note": "T2V UNET ~13GB + I2V UNET ~13GB + VAE ~500MB + llava ~16GB + clip_l ~1.6GB (toplam ~44GB)",
        }

    @_server.routes.get("/fikoyt_mvd/check_hunyuan_video")
    async def _check_hunyuan_video(request):
        return web.json_response(_hunyuan_status_dict())

    @_server.routes.post("/fikoyt_mvd/install_hunyuan_video")
    async def _install_hunyuan_video(request):
        with _install_lock:
            if _hunyuan_install_state["in_progress"]:
                return web.json_response({"error": "Zaten yükleniyor"}, status=409)
            _hunyuan_install_state.update({
                "in_progress": True, "log": "", "done": False, "error": "",
                "current_file": "", "current_pct": 0,
                "downloaded_mb": 0, "total_mb": 0,
                "started_at": datetime.datetime.now().isoformat(),
            })

        async def _bg():
            try:
                await _git_clone_node(
                    "https://github.com/kijai/ComfyUI-HunyuanVideoWrapper.git",
                    _hunyuan_install_state,
                    label="ComfyUI-HunyuanVideoWrapper")
                _hunyuan_install_state["log"] += "\n[pip] HunyuanVideo bağımlılıkları\n"
                for pkg in ("accelerate", "diffusers>=0.31.0", "sentencepiece"):
                    try:
                        await _stream_subprocess(
                            [sys.executable, "-s", "-m", "pip", "install", "--upgrade", pkg],
                            _hunyuan_install_state)
                    except Exception as e:
                        _hunyuan_install_state["log"] += f"  ⚠ {pkg}: {e}\n"
                _hunyuan_install_state["log"] += (
                    "\n[hf] HunyuanVideo modelleri — Toplam ~13GB · uzun sürer (15-45 dk)\n")
                models_dir = folder_paths.models_dir
                for key, (rel, url, min_sz) in HUNYUAN_MODELS.items():
                    dest = os.path.join(models_dir, rel)
                    if url and url.startswith("hf_snapshot:"):
                        repo_id = url.split(":", 1)[1]
                        os.makedirs(dest, exist_ok=True)
                        _hunyuan_install_state["log"] += (
                            f"  [hf-snapshot] {repo_id} → models/{rel}/\n")
                        _hunyuan_install_state["current_file"] = f"{key} (snapshot)"
                        try:
                            from huggingface_hub import snapshot_download
                            await asyncio.get_event_loop().run_in_executor(
                                None,
                                lambda: snapshot_download(
                                    repo_id=repo_id, local_dir=dest,
                                    local_dir_use_symlinks=False))
                            _hunyuan_install_state["log"] += f"  ✓ {key} snapshot indi\n"
                        except Exception as e:
                            _hunyuan_install_state["log"] += f"  ❌ {key}: {e}\n"
                        finally:
                            _hunyuan_install_state["current_file"] = ""
                        continue
                    if url is None:
                        _hunyuan_install_state["log"] += f"  ⚠ {key}: URL yok, atlandı\n"
                        continue
                    os.makedirs(os.path.dirname(dest), exist_ok=True)
                    try:
                        await _hf_download(url, dest, _hunyuan_install_state, min_sz)
                    except Exception as e:
                        _hunyuan_install_state["log"] += f"  ❌ {key}: {e}\n"
                _hunyuan_install_state["log"] += (
                    "\n✓ HunyuanVideo modelleri indi (UNET + VAE + llava snapshot + clip_l).\n"
                    "  ComfyUI'yi RESTART et!\n")
            except Exception as e:
                _hunyuan_install_state["error"] = str(e)
                _hunyuan_install_state["log"] += f"\n❌ HATA: {e}\n"
            finally:
                _hunyuan_install_state["done"] = True
                _hunyuan_install_state["in_progress"] = False

        asyncio.create_task(_bg())
        return web.json_response({"ok": True})

    @_server.routes.get("/fikoyt_mvd/install_hunyuan_video_status")
    async def _install_hunyuan_video_status(request):
        return web.json_response(_hunyuan_install_state)

    # =========================================================================
    # Sprint W26 — Storyboard AI: script → shot list (LLM yardımıyla)
    # =========================================================================
    @_server.routes.post("/fikoyt_mvd/storyboard/generate")
    async def _storyboard_generate(request):
        """LLM ile script'i shot list'e çevir.
        Body: { script, character_ids?: [], style?, atmosphere?,
                total_duration?, shot_count?, model? }
        Response: { shots: [{prompt, negative, duration, camera, characters}] }
        """
        try:
            body = await request.json()
        except Exception:
            body = {}
        script = (body.get("script") or "").strip()
        if not script:
            return web.json_response({"error": "script zorunlu"}, status=400)
        char_ids = body.get("character_ids") or []
        style = body.get("style") or "Music Video"
        atmosphere = body.get("atmosphere") or "Cinematic"
        total_dur = int(body.get("total_duration") or 60)
        shot_count = max(2, min(int(body.get("shot_count") or 8), 32))
        model = (body.get("model") or "qwen2.5vl:7b").strip()

        avg_dur = round(total_dur / shot_count, 1)
        sys_prompt = (
            "You are a music video director. Given a song lyrics or story script, "
            f"create a shot list of EXACTLY {shot_count} shots. "
            f"Total duration: {total_dur}s. Avg shot duration: {avg_dur}s. "
            f"Style: {style}. Atmosphere: {atmosphere}. "
            "For each shot return a JSON object with:\n"
            "  prompt: detailed English image prompt for Flux model (visual scene, lighting, mood)\n"
            "  negative: short negative prompt\n"
            "  duration: float seconds (around avg)\n"
            "  camera: one of [close-up, medium, wide, dolly-in, dolly-out, crane, pan-left, pan-right, tilt-up, tilt-down]\n"
            "  characters: array of character IDs from this list: " + str(char_ids) + "\n"
            "Return ONLY a valid JSON array, no markdown, no explanation. Example:\n"
            '[{"prompt":"cinematic close-up of woman walking in rain, neon lights","negative":"low quality","duration":4.5,"camera":"close-up","characters":["Zekiye"]}]'
        )
        user_msg = f"SCRIPT:\n{script}\n\nGenerate {shot_count} shots."

        try:
            import aiohttp
            async with aiohttp.ClientSession() as sess:
                async with sess.post(
                    "http://127.0.0.1:11434/api/chat",
                    json={
                        "model": model,
                        "messages": [
                            {"role": "system", "content": sys_prompt},
                            {"role": "user", "content": user_msg},
                        ],
                        "stream": False,
                        "options": {"temperature": 0.8},
                    },
                    timeout=aiohttp.ClientTimeout(total=120),
                ) as r:
                    if r.status != 200:
                        return web.json_response(
                            {"error": f"Ollama: HTTP {r.status}"}, status=502)
                    j = await r.json()
                    content = (j.get("message") or {}).get("content", "")
        except Exception as e:
            return web.json_response(
                {"error": f"Ollama bağlantısı: {e}",
                 "hint": "Ollama çalışıyor mu? ollama serve"},
                status=502)

        # JSON parse — model bazen markdown code block içinde döner
        import re as _re
        clean = content.strip()
        # ```json ... ``` veya ``` ... ``` çıkar
        m = _re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", clean)
        if m:
            clean = m.group(1).strip()
        # İlk [ ile son ] arasını al
        s_idx = clean.find("[")
        e_idx = clean.rfind("]")
        if s_idx >= 0 and e_idx > s_idx:
            clean = clean[s_idx:e_idx + 1]
        try:
            shots = json.loads(clean)
        except Exception as e:
            return web.json_response({
                "error": f"JSON parse: {e}",
                "raw": content[:500],
            }, status=502)

        # Normalize
        out = []
        for s in shots:
            if not isinstance(s, dict): continue
            out.append({
                "prompt": str(s.get("prompt") or "").strip(),
                "negative": str(s.get("negative") or "low quality, blurry").strip(),
                "duration": float(s.get("duration") or avg_dur),
                "camera": str(s.get("camera") or "medium").strip(),
                "characters": s.get("characters") or [],
            })

        return web.json_response({
            "shots": out,
            "model": model,
            "total_duration": sum(s["duration"] for s in out),
            "style": style, "atmosphere": atmosphere,
        })

    # =========================================================================
    # Storyboard Timeline — çok-katmanlı render köprüsü
    # tracks[] payload'ını video_edit/render'ın flat şemasına çevirir.
    # =========================================================================
    @_server.routes.post("/fikoyt_mvd/timeline/render")
    async def _timeline_render(request):
        try:
            body = await request.json()
        except Exception as e:
            return web.json_response({"error": f"JSON: {e}"}, status=400)

        tracks = body.get("tracks") or []
        # Ana video track'i (en çok klipli 'video' track) → sıralı clips
        video_tracks = [t for t in tracks
                        if t.get("kind") == "video" and t.get("visible", True)]
        ana = max(video_tracks, key=lambda t: len(t.get("clips") or []),
                  default=None)
        clips = []
        transitions = []
        if ana:
            sirali = sorted(ana.get("clips") or [],
                            key=lambda c: c.get("start", 0))
            for idx, c in enumerate(sirali):
                if c.get("hide_visual"):
                    continue
                clips.append({
                    "path": c.get("src"),
                    "trim_in": c.get("trim_in", 0),
                    "trim_out": (c.get("trim_in", 0) + c.get("dur", 0))
                    if c.get("dur") else None,
                    "speed": 1.0,
                    "volume": c.get("volume", 1.0),
                })
                tr = c.get("transition_in")
                if tr and idx > 0:
                    transitions.append(
                        {"type": tr, "at": len(clips) - 1, "duration": 0.5})

        # İlk ses track'inin ilk klibi → master audio
        audio = {}
        for t in tracks:
            if t.get("kind") == "audio" and t.get("visible", True):
                cl = t.get("clips") or []
                if cl:
                    audio = {"path": cl[0].get("src"),
                             "volume": cl[0].get("volume", 1.0),
                             "start_at": cl[0].get("start", 0)}
                    break

        # Yazı track'leri → text_overlays
        overlays = []
        for t in tracks:
            if t.get("kind") == "text" and t.get("visible", True):
                for c in (t.get("clips") or []):
                    if c.get("text"):
                        overlays.append({
                            "text": c.get("text"),
                            "start": c.get("start", 0),
                            "end": c.get("start", 0) + c.get("dur", 3),
                            "position": "bottom",
                            "font_size": 48,
                        })

        if not clips:
            return web.json_response(
                {"error": "Render için ana video track'inde klip yok"},
                status=400)

        flat = {
            "clips": clips,
            "audio": audio,
            "text_overlays": overlays,
            "transitions": transitions,
            "output": body.get("output")
            or {"filename": f"timeline_{int(time.time())}.mp4"},
        }

        # Mevcut video_edit/render handler'ını sahte request ile yeniden kullan
        class _SahteReq:
            def __init__(self, d):
                self._d = d

            async def json(self):
                return self._d

        return await _video_edit_render(_SahteReq(flat))

    # =========================================================================
    # Sprint W26 — Video Editor: FFmpeg multi-clip render
    # =========================================================================
    @_server.routes.post("/fikoyt_mvd/video_edit/render")
    async def _video_edit_render(request):
        """Multi-clip timeline render. Body: {
          clips: [{path, trim_in, trim_out, speed, volume}],
          audio: {path, volume, start_at?},
          text_overlays: [{text, start, end, position, font_size}],
          transitions: [{type:"cross_fade", at:i, duration:0.5}],
          output: {filename, resolution:"1920x1080", fps:30,
                   video_codec:"libx264", audio_codec:"aac"}
        }
        FFmpeg ile timeline render eder. Cross-fade için xfade filter,
        text overlay için drawtext, ses-mux için amix kullanır.
        """
        try:
            body = await request.json()
        except Exception as e:
            return web.json_response({"error": f"JSON: {e}"}, status=400)

        clips = body.get("clips") or []
        if not clips:
            return web.json_response({"error": "clips zorunlu"}, status=400)
        audio = body.get("audio") or {}
        overlays = body.get("text_overlays") or []
        out_opts = body.get("output") or {}
        out_name = out_opts.get("filename") or f"edit_{int(time.time())}.mp4"
        res = out_opts.get("resolution") or "1920x1080"
        fps = int(out_opts.get("fps") or 30)
        vcodec = out_opts.get("video_codec") or "libx264"
        acodec = out_opts.get("audio_codec") or "aac"

        # FFmpeg yolunu bul
        ffmpeg_bin = shutil.which("ffmpeg")
        if not ffmpeg_bin:
            try:
                import imageio_ffmpeg
                ffmpeg_bin = imageio_ffmpeg.get_ffmpeg_exe()
            except Exception:
                pass
        if not ffmpeg_bin:
            return web.json_response({
                "error": "ffmpeg bulunamadı",
                "hint": "imageio-ffmpeg pip install et veya ffmpeg PATH'e ekle",
            }, status=500)

        # Output path
        out_dir = os.path.join(folder_paths.get_output_directory(), "fikoyt_quick")
        os.makedirs(out_dir, exist_ok=True)
        out_path = os.path.join(out_dir, out_name)

        # Input dosyalarını çöz
        def _resolve_path(p):
            if not p: return None
            if os.path.isfile(p): return p
            # input/ veya output/ altında ara
            for base in (folder_paths.get_input_directory(),
                         folder_paths.get_output_directory()):
                cand = os.path.join(base, p)
                if os.path.isfile(cand): return cand
            return None

        input_paths = []
        for c in clips:
            p = _resolve_path(c.get("path"))
            if not p:
                return web.json_response(
                    {"error": f"Clip bulunamadı: {c.get('path')}"}, status=400)
            input_paths.append((p, c))

        audio_path = _resolve_path(audio.get("path")) if audio.get("path") else None

        # Basit yaklaşım: filter_complex ile concat + xfade + drawtext + amix
        # Trim için her input -ss/-t ile
        cmd = [ffmpeg_bin, "-y"]
        filter_parts = []
        durs = []
        for i, (p, c) in enumerate(input_paths):
            trim_in = float(c.get("trim_in") or 0)
            trim_out = c.get("trim_out")
            speed = float(c.get("speed") or 1.0)
            if trim_out is not None:
                trim_out = float(trim_out)
                dur = max(0.1, trim_out - trim_in)
                cmd += ["-ss", str(trim_in), "-t", str(dur), "-i", p]
            elif trim_in > 0:
                cmd += ["-ss", str(trim_in), "-i", p]
            else:
                cmd += ["-i", p]
            eff = (max(0.1, (trim_out - trim_in)) / max(0.1, speed)) if trim_out is not None else None
            durs.append(eff)

            # Speed + fade in/out (klip başına) — xfade için format/sar normalize
            fade_in = float(c.get("fade_in") or 0)
            fade_out = float(c.get("fade_out") or 0)
            vf = ""
            if abs(speed - 1.0) > 0.01:
                vf += f"setpts={1/speed:.3f}*PTS,"
            vf += f"fps={fps},scale={res.replace('x', ':')},format=yuv420p,setsar=1"
            if fade_in > 0:
                vf += f",fade=t=in:st=0:d={fade_in:.2f}"
            if fade_out > 0 and eff is not None:
                vf += f",fade=t=out:st={max(0, eff - fade_out):.2f}:d={fade_out:.2f}"
            filter_parts.append(f"[{i}:v]{vf}[v{i}]")

        # Geçiş süresi (cross-fade) — transitions[0].duration
        xfd = 0.0
        try:
            _trs = body.get("transitions") or []
            if _trs:
                xfd = float(_trs[0].get("duration") or 0)
        except Exception:
            xfd = 0.0
        use_xfade = (len(input_paths) > 1 and xfd > 0.05 and all(d is not None for d in durs))

        # Concat / xfade → [vmix]
        if len(input_paths) == 1:
            filter_parts.append("[v0]copy[vmix]")
        elif use_xfade:
            # xfade zinciri: her klip bir öncekiyle çapraz geçiş (offset = kümülatif süre - geçiş)
            prev = "v0"
            cum = durs[0]
            for i in range(1, len(input_paths)):
                offset = max(0.0, cum - xfd)
                out = f"xf{i}"
                filter_parts.append(
                    f"[{prev}][v{i}]xfade=transition=fade:duration={xfd:.2f}:offset={offset:.2f}[{out}]")
                cum = cum + durs[i] - xfd
                prev = out
            filter_parts.append(f"[{prev}]copy[vmix]")
        else:
            inputs = "".join(f"[v{i}]" for i in range(len(input_paths)))
            filter_parts.append(f"{inputs}concat=n={len(input_paths)}:v=1:a=0[vmix]")

        # Text overlay (her durumda — tek klipte de çalışır)
        last = "vmix"
        for j, ov in enumerate(overlays):
            txt = (ov.get("text") or "")
            for _ch, _esc in (("\\", "\\\\"), ("'", "\\'"), (":", "\\:"),
                              ("[", "\\["), ("]", "\\]"), ("%", "\\%"),
                              ("\n", " "), ("\r", " "), (";", " ")):
                txt = txt.replace(_ch, _esc)
            start = float(ov.get("start") or 0)
            end = float(ov.get("end") or 5)
            fs = int(ov.get("font_size") or 48)
            pos = (ov.get("position") or "bottom").lower()
            y = "h-100" if pos == "bottom" else "50" if pos == "top" else "(h-text_h)/2"
            filter_parts.append(
                f"[{last}]drawtext=text='{txt}':fontsize={fs}:fontcolor=white:"
                f"borderw=2:bordercolor=black:x=(w-text_w)/2:y={y}:"
                f"enable='between(t,{start},{end})'[t{j}]")
            last = f"t{j}"
        filter_parts.append(f"[{last}]copy[vout]")

        # Audio: master audio varsa direkt onu kullan, yoksa clip'lerin ses'leri
        audio_idx = None
        if audio_path:
            cmd += ["-i", audio_path]
            audio_idx = len(input_paths)
            avol = float(audio.get("volume") or 1.0)
            filter_parts.append(f"[{audio_idx}:a]volume={avol:.2f}[aout]")

        cmd += ["-filter_complex", ";".join(filter_parts), "-map", "[vout]"]
        if audio_path:
            cmd += ["-map", "[aout]", "-c:a", acodec, "-shortest"]
        cmd += ["-c:v", vcodec, "-pix_fmt", "yuv420p", "-r", str(fps), out_path]

        # Çalıştır
        try:
            proc = await asyncio.create_subprocess_exec(
                *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
            out, _ = await proc.communicate()
            log = out.decode("utf-8", errors="replace")[-2000:]
            if proc.returncode != 0:
                return web.json_response({
                    "error": f"ffmpeg exit {proc.returncode}",
                    "log": log,
                    "cmd": " ".join(cmd[:8]) + " ...",
                }, status=500)
        except Exception as e:
            return web.json_response({"error": f"ffmpeg subprocess: {e}"}, status=500)

        return web.json_response({
            "ok": True,
            "filename": out_name,
            "subfolder": "fikoyt_quick",
            "type": "output",
            "url": f"/api/view?filename={out_name}&subfolder=fikoyt_quick&type=output",
            "size_kb": os.path.getsize(out_path) // 1024,
        })

except RuntimeError as e:
    print(f"[FikoYT] Sprint 1 endpoint registration skipped: {e}")
except ImportError:
    pass


__all__: list[str] = []
