#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
=============================================================================
THẬP NGŨ NIÊN (THE FIFTEEN SPRINGS) - AI CINEMA PRODUCTION ORCHESTRATOR
=============================================================================
Cỗ máy điều phối sản xuất tự động toàn diện:
- Quản lý danh mục shot/scene từ 02_AI_Prompts/muse_ai_video_prompts.json.
- Render đơn shot hoặc batch toàn cảnh (Agent-Browser --session muse).
- Tự động nối tiếp Head-Tail (Tail Frame N -> Start Frame N+1).
- Kiểm tra tính liên tục thị giác (Visual Continuity Checker).
- Ghép nối Master cảnh quay bảo toàn 100% âm thanh (FFmpeg Filter Complex Concat).
- Master âm thanh điện ảnh chuẩn YouTube Green Dollar (-14 LUFS).
=============================================================================
"""

import os
import sys
import re
import json
import argparse
import subprocess
import shutil
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Any, Union

# Thiết lập UTF-8 Console Windows
if sys.platform == "win32":
    try:
        sys.stdout.reconfigure(encoding="utf-8")
        sys.stderr.reconfigure(encoding="utf-8")
    except Exception:
        pass

BASE_DIR = Path(__file__).resolve().parent.parent
PROMPTS_DIR = BASE_DIR / "02_AI_Prompts"
ASSETS_DIR = BASE_DIR / "04_Assets"
VIDEOS_DIR = ASSETS_DIR / "videos"
KEYFRAMES_DIR = ASSETS_DIR / "keyframes"
CHARACTERS_DIR = ASSETS_DIR / "characters"
EXPORTS_DIR = BASE_DIR / "06_Exports"
EPISODES_DIR = BASE_DIR / "episodes"
MUSE_PROMPTS_FILE = PROMPTS_DIR / "muse_ai_video_prompts.json"
BANANA_PROMPTS_FILE = PROMPTS_DIR / "gemini_banana_prompts.json"

# Import critic gate module
try:
    from antigravity_critic_gate import evaluate_shot_gate, evaluate_scene_gate, VideoCriticVerdict
except ImportError:
    try:
        from .antigravity_critic_gate import evaluate_shot_gate, evaluate_scene_gate, VideoCriticVerdict
    except Exception:
        evaluate_shot_gate = None
        evaluate_scene_gate = None
        VideoCriticVerdict = None

# Import FlowKit autonomous components
try:
    from prompt_healer import DiagnosticPromptHealer, CinematicShotPacer, MAX_HEALING_CYCLES
except ImportError:
    try:
        from .prompt_healer import DiagnosticPromptHealer, CinematicShotPacer, MAX_HEALING_CYCLES
    except Exception:
        DiagnosticPromptHealer = None
        CinematicShotPacer = None
        MAX_HEALING_CYCLES = 2

try:
    from contact_sheet_engine import generate_zero_waste_contact_sheet
except ImportError:
    try:
        from .contact_sheet_engine import generate_zero_waste_contact_sheet
    except Exception:
        generate_zero_waste_contact_sheet = None

# Import helper modules
try:
    from run_shot import (
        run_shot_pipeline, ensure_audio_guard, AUDIO_GUARD_CANONICAL, 
        classify_shot_engine, ensure_closed_lips_guard, CLOSED_LIPS_CANONICAL
    )
except ImportError:
    try:
        from .run_shot import (
            run_shot_pipeline, ensure_audio_guard, AUDIO_GUARD_CANONICAL, 
            classify_shot_engine, ensure_closed_lips_guard, CLOSED_LIPS_CANONICAL
        )
    except Exception:
        run_shot_pipeline = None
        classify_shot_engine = lambda sid, sdata=None, prompt=None: "muse"
        AUDIO_GUARD_CANONICAL = " Quy tắc âm thanh: Tuyệt đối KHÔNG sinh nhạc nền (no music/BGM), không âm thanh điện tử, không tạp âm rè nhiễu. Chỉ sinh âm thanh môi trường tự nhiên (foley, ambience) và thoại nhân vật chân thực."
        CLOSED_LIPS_CANONICAL = " [KHÓA KHẨU HÌNH BẮT BUỘC / LIPS CLOSED]: Đôi môi các nhân vật trong khung hình khép chặt tự nhiên (lips closed), không mấp máy."
        def ensure_audio_guard(p: str) -> str:
            if not p: return AUDIO_GUARD_CANONICAL.strip()
            if "Tuyệt đối KHÔNG sinh nhạc nền" not in p and "Strictly NO background music" not in p:
                return p.rstrip() + AUDIO_GUARD_CANONICAL
            return p
        def ensure_closed_lips_guard(p: str, force: bool = False) -> str:
            return p

def validate_audio_guard(prompt: str) -> bool:
    """Xác thực prompt tuân thủ chỉ thị cấm nhạc nền (No-BGM) theo AGENTS.md §3."""
    if not prompt:
        return False
    return ("Tuyệt đối KHÔNG sinh nhạc nền" in prompt or "Strictly NO background music" in prompt)

def _extract_tail_frame(video_path: Path, shot_id: str) -> Optional[Path]:
    """Trích xuất frame cuối cùng (Tail Frame 239) từ video để nối tiếp Head-Tail."""
    try:
        import cv2
        cap = cv2.VideoCapture(str(video_path))
        total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
        if total_frames <= 0:
            cap.release()
            return None
        cap.set(cv2.CAP_PROP_POS_FRAMES, max(0, total_frames - 1))
        ret, frame = cap.read()
        cap.release()
        if ret and frame is not None:
            kf_dir = KEYFRAMES_DIR / shot_id
            kf_dir.mkdir(parents=True, exist_ok=True)
            tail_file = kf_dir / "clean_frame_239.jpg"
            cv2.imwrite(str(tail_file), frame)
            return tail_file
    except Exception as e:
        print(f"      [!] Lỗi trích xuất Tail Frame cho {shot_id}: {e}")
    return None

try:
    from continuity_checker import check_shot_continuity
except ImportError:
    check_shot_continuity = None

try:
    from audio_continuity_engine import AudioContinuityEngine, get_ffmpeg, get_ffprobe
except ImportError:
    AudioContinuityEngine = None
    get_ffmpeg = lambda: "ffmpeg"
    get_ffprobe = lambda: "ffprobe"


def load_muse_prompts(episode_id: Optional[str] = None) -> Dict:
    """Tải danh mục prompt chuyển động video, hỗ trợ nạp theo Episode riêng biệt hoặc file Master."""
    if episode_id:
        ep_file = EPISODES_DIR / episode_id.lower() / "prompts" / "muse_prompts.json"
        if ep_file.exists():
            with open(ep_file, "r", encoding="utf-8") as f:
                return json.load(f)
    if not MUSE_PROMPTS_FILE.exists():
        print(f"[!] Không tìm thấy file prompt: {MUSE_PROMPTS_FILE}")
        return {}
    with open(MUSE_PROMPTS_FILE, "r", encoding="utf-8") as f:
        return json.load(f)


def get_all_shots(episode_id: Optional[str] = None) -> Dict[str, Dict]:
    """Lấy danh sách toàn bộ các shot được định nghĩa."""
    data = load_muse_prompts(episode_id=episode_id)
    return data.get("motion_prompts", {})


def get_shots_for_scene(scene_id: str) -> List[Tuple[str, Dict]]:
    """Lọc danh sách các shot thuộc về một Scene cụ thể theo thứ tự."""
    m_ep = re.search(r"^(ep\d+)", scene_id, re.IGNORECASE)
    ep_id = m_ep.group(1).lower() if m_ep else None
    shots = get_all_shots(episode_id=ep_id)
    if not shots:
        shots = get_all_shots()
    matched = []
    pattern = re.compile(rf"^{scene_id}_shot(\d+)", re.IGNORECASE)
    
    for shot_id, shot_data in shots.items():
        m = pattern.search(shot_id)
        if m:
            shot_num = int(m.group(1))
            matched.append((shot_num, shot_id, shot_data))
    
    matched.sort(key=lambda x: x[0])
    return [(shot_id, shot_data) for _, shot_id, shot_data in matched]


def find_rendered_video(shot_id: str) -> Optional[Path]:
    """Tìm video đã render của một shot, ưu tiên phiên bản cao nhất (_v3 > _v2 > _v1 > unversioned)."""
    pattern = re.compile(rf"^{re.escape(shot_id)}(?:_10s)?(?:_v(\d+))?\.mp4$", re.IGNORECASE)
    matches = []
    for p in VIDEOS_DIR.glob(f"{shot_id}*.mp4"):
        if p.is_file() and p.stat().st_size > 1000 and "master" not in p.name.lower():
            m = pattern.match(p.name)
            v_num = int(m.group(1)) if (m and m.group(1)) else 0
            matches.append((v_num, p.stat().st_mtime, p))
    if matches:
        matches.sort(key=lambda x: (x[0], x[1]), reverse=True)
        return matches[0][2]
    return None


def find_tail_frame(shot_id: str) -> Optional[Path]:
    """Tìm ảnh Tail Frame (frame cuối) của shot đã render."""
    kf_dir = KEYFRAMES_DIR / shot_id
    if kf_dir.exists():
        candidates = list(kf_dir.glob("clean_frame_*.jpg")) + list(kf_dir.glob("*.png")) + list(kf_dir.glob("*.jpg"))
        if candidates:
            return candidates[0]
    return None


BANANA_PROMPTS_CACHE: Optional[Dict[str, Any]] = None


def get_banana_prompt_data(shot_id: str) -> Dict[str, Any]:
    """Lấy dữ liệu prompt từ gemini_banana_prompts.json."""
    global BANANA_PROMPTS_CACHE
    if BANANA_PROMPTS_CACHE is None:
        if BANANA_PROMPTS_FILE.exists():
            try:
                BANANA_PROMPTS_CACHE = json.loads(BANANA_PROMPTS_FILE.read_text(encoding="utf-8"))
            except Exception:
                BANANA_PROMPTS_CACHE = {}
        else:
            BANANA_PROMPTS_CACHE = {}

    if not BANANA_PROMPTS_CACHE:
        return {}

    if shot_id in BANANA_PROMPTS_CACHE:
        return BANANA_PROMPTS_CACHE[shot_id]

    for k in ("ep01_start_frames", "ep02_start_frames", "ep03_start_frames", "ep04_start_frames", "ep05_start_frames", "ep06_start_frames"):
        bucket = BANANA_PROMPTS_CACHE.get(k, {})
        if isinstance(bucket, dict) and shot_id in bucket:
            return bucket[shot_id]

    return {}


def get_character_anchor(shot_id: str, shot_data: Optional[Dict] = None) -> str:
    """Trích xuất character_anchor từ shot_data hoặc từ kho prompt Banana."""
    if shot_data and "character_anchor" in shot_data:
        val = str(shot_data["character_anchor"]).strip()
        if val and val.lower() != "none":
            return val

    # Ưu tiên character_asset_ref từ chính shot_data trước khi đọc fallback Banana
    if shot_data and shot_data.get("character_asset_ref"):
        ref = str(shot_data["character_asset_ref"]).lower()
        for k in ["thuy_kieu", "thuy_van", "kim_trong", "vuong_ong", "vuong_ba", "vuong_quan", "dam_tien", "tieu_dong", "quan_gia"]:
            if k in ref:
                return k

    bdata = get_banana_prompt_data(shot_id)
    if bdata and "character_anchor" in bdata:
        val = str(bdata["character_anchor"]).strip()
        if val and val.lower() != "none":
            return val

    # Heuristic fallback dựa trên tiêu đề cảnh nếu anchor không tồn tại hoặc là none
    if shot_data:
        raw_title = str(shot_data.get("scene_title", "")).lower()
        prompt = str(shot_data.get("motion_prompt", "")).lower()
        if " - " in raw_title:
            shot_action = raw_title.split(" - ", 1)[1]
        else:
            m_action = re.search(r"shot\s*\d+:\s*(.*)", raw_title)
            shot_action = m_action.group(1) if m_action else raw_title
        action_combined = f"{shot_action} {prompt}"
        full_combined = f"{raw_title} {prompt}"
        if ("thúy vân" in action_combined and "thúy kiều" not in action_combined) or ("thúy vân" in full_combined and "thúy kiều" not in full_combined):
            return "thuy_van"
        if "kim trọng" in full_combined and "thúy kiều" not in full_combined and "nàng kiều" not in full_combined:
            return "kim_trong"
        if "vương quan" in full_combined and "thúy kiều" not in full_combined:
            return "vuong_quan"
        if "vương ông" in full_combined and "thúy kiều" not in full_combined:
            return "vuong_ong"
        if ("thúy kiều" in full_combined or "nàng kiều" in full_combined) and "kim trọng" not in full_combined:
            return "thuy_kieu_maiden"

    return ""


def classify_shot_take(shot_id: str, prev_shot_id: Optional[str] = None, shot_data: Optional[Dict] = None) -> str:
    """
    Phân loại cú máy: CINEMATIC_CUT hoặc CONTINUOUS_TAKE.
    - shot_num == 1: Bắt đầu cảnh mới -> CINEMATIC_CUT
    - current character_anchor != prev character_anchor: Đổi vai -> CINEMATIC_CUT
    - character_asset_ref khác nhóm nhân vật: Đổi vai -> CINEMATIC_CUT
    - current character_anchor == 'none' hoặc rỗng: Cảnh vật/đạo cụ -> CINEMATIC_CUT
    - current character_anchor == prev character_anchor (và khác 'none'): Cùng nhân vật -> CONTINUOUS_TAKE
    """
    m = re.search(r"^(ep\d+_scene\d+)_shot(\d+)$", shot_id, re.IGNORECASE)
    if not m:
        return "CINEMATIC_CUT"

    scene_prefix = m.group(1)
    shot_num = int(m.group(2))
    if shot_num == 1:
        return "CINEMATIC_CUT"

    if not prev_shot_id:
        prev_shot_id = f"{scene_prefix}_shot{shot_num - 1:02d}"

    m_ep = re.search(r"^(ep\d+)", shot_id, re.IGNORECASE)
    ep_id = m_ep.group(1).lower() if m_ep else "ep01"
    all_ep_shots = get_all_shots(ep_id)
    prev_data = all_ep_shots.get(prev_shot_id, {})
    curr_data = shot_data if shot_data is not None else all_ep_shots.get(shot_id, {})

    # Check character asset reference change (Hard Gate)
    prev_ref = prev_data.get("character_asset_ref", "")
    curr_ref = curr_data.get("character_asset_ref", "")
    if prev_ref and curr_ref:
        def _char_group(name: str) -> str:
            n = Path(name).stem.lower()
            for k in ["thuy_van", "thuy_kieu", "kim_trong", "vuong_ong", "vuong_ba", "vuong_quan", "dam_tien", "tieu_dong", "quan_gia"]:
                if k in n:
                    return k
            return n

        if _char_group(prev_ref) != _char_group(curr_ref):
            return "CINEMATIC_CUT"

    curr_anchor = get_character_anchor(shot_id, curr_data)
    prev_anchor = get_character_anchor(prev_shot_id, prev_data)

    # Nếu anchor là 'none' hoặc không xác định -> xem như Cinematic Cut
    if curr_anchor == "none" or not curr_anchor:
        return "CINEMATIC_CUT"

    if prev_anchor == "none" or not prev_anchor:
        return "CINEMATIC_CUT"

    if curr_anchor != prev_anchor:
        return "CINEMATIC_CUT"

    # Actor-switch keywords check between previous and current shot titles/prompts
    def _detect_actor_from_text(data: Dict) -> str:
        raw_t = str(data.get("scene_title", "")).lower()
        pr = str(data.get("motion_prompt", "")).lower()
        if " - " in raw_t:
            action_t = raw_t.split(" - ", 1)[1]
        else:
            m_a = re.search(r"shot\s*\d+:\s*(.*)", raw_t)
            action_t = m_a.group(1) if m_a else raw_t
        combo = f"{action_t} {pr}"
        full = f"{raw_t} {pr}"
        if ("thúy vân" in combo and "thúy kiều" not in combo) or ("thúy vân" in full and "thúy kiều" not in full):
            return "thuy_van"
        if "kim trọng" in full and "thúy kiều" not in full and "nàng kiều" not in full:
            return "kim_trong"
        if "vương quan" in full and "thúy kiều" not in full:
            return "vuong_quan"
        if "vương ông" in full and "thúy kiều" not in full:
            return "vuong_ong"
        if "vương bà" in full and "thúy kiều" not in full:
            return "vuong_ba"
        if ("thúy kiều" in full or "nàng kiều" in full) and "thúy vân" not in full and "kim trọng" not in full:
            return "thuy_kieu"
        return ""

    prev_actor_text = _detect_actor_from_text(prev_data)
    curr_actor_text = _detect_actor_from_text(curr_data)
    if prev_actor_text and curr_actor_text and prev_actor_text != curr_actor_text:
        return "CINEMATIC_CUT"

    return "CONTINUOUS_TAKE"


def ensure_16x9_start_frame(image_path: Optional[Union[str, Path]], shot_id: Optional[str] = None) -> Optional[str]:
    """
    Hard Gate bảo đảm 100% Start Frame gửi vào Video Engine đạt chuẩn 16:9 Cinema Widescreen (w >= h * 1.33).
    Nếu ảnh đầu vào có tỷ lệ dọc hoặc sai chuẩn:
    1. Ưu tiên map sang tệp 1280x720 Master Portrait (*_720p.png) của nhân vật tương ứng.
    2. Nếu không có tệp master 16:9, tự động tạo composite 16:9 (1280x720) với nền mờ Gaussian blur 30% và lưu cache.
    """
    if not image_path:
        return None
    p = Path(image_path)
    if not p.exists() or not p.is_file():
        return str(image_path)

    try:
        import cv2
        import numpy as np
    except ImportError:
        return str(p.resolve())

    # Đọc kích thước
    img = cv2.imread(str(p))
    if img is None:
        return str(p.resolve())

    h, w = img.shape[:2]
    if w >= h * 1.33:
        # Đã là chuẩn 16:9 Cinema Widescreen
        return str(p.resolve())

    # Phát hiện ảnh dọc hoặc không đủ 16:9 (aspect ratio < 1.33)
    print(f"      [!] CẢNH BÁO: Phát hiện Start Frame không đạt chuẩn 16:9 ({w}x{h}, aspect={w/h:.2f}): {p.name}")

    # 1. Thử ánh xạ sang Master 720p 16:9 của nhân vật
    p_lower = str(p).lower()
    char_map = {
        "vuong_ba": CHARACTERS_DIR / "02_Vuong_Family_And_Fate" / "vuong_ba_50yo" / "vuong_ba_50yo_720p.png",
        "vuong_ong": CHARACTERS_DIR / "02_Vuong_Family_And_Fate" / "vuong_ong_55yo" / "vuong_ong_55yo_720p.png",
        "vuong_quan": CHARACTERS_DIR / "02_Vuong_Family_And_Fate" / "vuong_quan_16yo" / "vuong_quan_16yo_720p.png",
        "thuy_van": CHARACTERS_DIR / "01_Main_Protagonists" / "thuy_van_maiden_16yo" / "thuy_van_maiden_16yo_720p.png",
        "kim_trong": CHARACTERS_DIR / "01_Main_Protagonists" / "kim_trong_18yo" / "kim_trong_18yo_720p.png",
        "thuy_kieu": CHARACTERS_DIR / "01_Main_Protagonists" / "thuy_kieu_maiden_16yo" / "thuy_kieu_maiden_16yo_720p.png",
        "dam_tien": CHARACTERS_DIR / "02_Vuong_Family_And_Fate" / "dam_tien" / "dam_tien_720p.png",
        "sai_nha": CHARACTERS_DIR / "05_Imperial_Court_And_Officials" / "sai_nha" / "sai_nha_720p.png",
        "thang_ban_to": CHARACTERS_DIR / "05_Imperial_Court_And_Officials" / "thang_ban_to" / "thang_ban_to_720p.png",
        "ma_giam_sinh": CHARACTERS_DIR / "04_Brokers_And_Brothels" / "ma_giam_sinh" / "ma_giam_sinh_720p.png",
        "tu_ba": CHARACTERS_DIR / "04_Brokers_And_Brothels" / "tu_ba" / "tu_ba_720p.png",
        "so_khanh": CHARACTERS_DIR / "04_Brokers_And_Brothels" / "so_khanh" / "so_khanh_720p.png",
        "thuc_sinh": CHARACTERS_DIR / "03_Thuc_Sinh_And_Hoan_Thu" / "thuc_sinh" / "thuc_sinh_720p.png",
        "hoan_thu": CHARACTERS_DIR / "03_Thuc_Sinh_And_Hoan_Thu" / "hoan_thu" / "hoan_thu_720p.png",
        "tu_hai": CHARACTERS_DIR / "06_Rebellion_And_Hero" / "tu_hai" / "tu_hai_720p.png",
        "giac_duyen": CHARACTERS_DIR / "02_Vuong_Family_And_Fate" / "giac_duyen" / "giac_duyen_720p.png",
        "ho_ton_hien": CHARACTERS_DIR / "06_Rebellion_And_Hero" / "ho_ton_hien" / "ho_ton_hien_720p.png",
    }

    # Check by path keywords
    for k, master_path in char_map.items():
        if k in p_lower and master_path.exists():
            print(f"      ✓ Đã tự động thay thế bằng Master 16:9 chuẩn: {master_path.name}")
            return str(master_path.resolve())

    # Check by shot_id keywords if provided
    if shot_id:
        try:
            shots = get_all_shots()
            shot_data = shots.get(shot_id, {})
            anchor = get_character_anchor(shot_id, shot_data).lower()
            for k, master_path in char_map.items():
                if k in anchor and master_path.exists():
                    print(f"      ✓ Đã thay thế bằng Master 16:9 của nhân vật {k}: {master_path.name}")
                    return str(master_path.resolve())
        except Exception:
            pass

    # 2. Không có Master 720p trực tiếp -> Tạo 16:9 Composite chất lượng điện ảnh
    cache_dir = KEYFRAMES_DIR / "_16x9_cinema_cache"
    cache_dir.mkdir(parents=True, exist_ok=True)
    out_name = f"{p.stem}_{shot_id or 'frame'}_16x9.jpg"
    out_path = cache_dir / out_name

    # Nền mờ 1280x720: resize phủ đầy 1280x720, làm mờ Gaussian (51x51), giảm độ sáng 30%
    bg = cv2.resize(img, (1280, 720), interpolation=cv2.INTER_CUBIC)
    bg = cv2.GaussianBlur(bg, (51, 51), 0)
    bg = (bg * 0.7).astype(np.uint8)

    # Tiền cảnh: căn chỉnh theo chiều cao 720, giữ nguyên tỷ lệ
    scale = 720.0 / h
    new_w = int(w * scale)
    fg = cv2.resize(img, (new_w, 720), interpolation=cv2.INTER_LANCZOS4)
    x_offset = max(0, (1280 - new_w) // 2)
    bg[:, x_offset:x_offset + new_w] = fg[:, :min(new_w, 1280 - x_offset)]

    cv2.imwrite(str(out_path), bg, [cv2.IMWRITE_JPEG_QUALITY, 95])
    print(f"      ✓ Đã tạo 16:9 Cinema Composite thành công: {out_path.name}")
    return str(out_path.resolve())


def find_character_turnaround_view(anchor_or_name: str, view_type: str = "portrait") -> Optional[Path]:
    """
    Tìm góc nhìn chuẩn (portrait, front, profile, back) từ kho Character Turnaround Sheet
    trong 04_Assets/characters/<group>/<character>/views/.
    """
    if not anchor_or_name or anchor_or_name == "none":
        return None
    clean = anchor_or_name.lower().strip()

    char_map = {
        "tieu_dong": "tieu_dong_kim_trong",
        "quan_gia": "quan_gia_kim_trong",
        "kim_trong": "kim_trong_18yo",
        "thuy_kieu": "thuy_kieu_maiden_16yo",
        "thuy_van": "thuy_van_maiden_16yo",
        "vuong_quan": "vuong_quan_16yo",
        "vuong_ong": "vuong_ong_55yo",
        "vuong_ba": "vuong_ba_50yo",
        "dam_tien": "dam_tien",
        "sai_nha": "sai_nha",
        "thang_ban_to": "thang_ban_to",
        "ma_giam_sinh": "ma_giam_sinh",
        "tu_ba": "tu_ba",
        "so_khanh": "so_khanh",
        "thuc_sinh": "thuc_sinh",
        "hoan_thu": "hoan_thu",
        "tu_hai": "tu_hai",
        "giac_duyen": "giac_duyen",
        "ho_ton_hien": "ho_ton_hien",
    }

    # 1. Tra cứu theo map chuẩn
    for key, folder_name in char_map.items():
        if key in clean:
            cands = list(CHARACTERS_DIR.glob(f"**/{folder_name}/views/*_{view_type}.png"))
            if cands and cands[0].exists():
                return cands[0]

    # 2. Tìm đệ quy theo chuỗi con trong thư mục views
    cands = list(CHARACTERS_DIR.glob(f"**/*{clean}*/views/*_{view_type}.png"))
    if cands and cands[0].exists():
        return cands[0]

    return None


def resolve_character_portrait_from_anchor(anchor: str, view_type: Optional[str] = None) -> Optional[Path]:
    """
    Ánh xạ character_anchor thành đường dẫn ảnh chân dung chuẩn trong 04_Assets/characters/<group>/<character>/.
    Ưu tiên tuyệt đối tệp Master 16:9 (*_720p.png) để bảo toàn Visual DNA điện ảnh.
    """
    if not anchor or anchor == "none":
        return None

    clean = anchor.lower().strip()

    # 1. Attendants và tiểu đồng kiểm tra TRƯỚC Kim Trọng để tránh bị nuốt chuỗi con (substring shadowing)
    if "tieu_dong" in clean:
        cand = CHARACTERS_DIR / "02_Vuong_Family_And_Fate" / "tieu_dong_kim_trong" / "tieu_dong_kim_trong_720p.png"
        if cand.exists():
            return cand

    if "quan_gia_kim_trong" in clean:
        cand = CHARACTERS_DIR / "02_Vuong_Family_And_Fate" / "quan_gia_kim_trong" / "quan_gia_kim_trong_720p.png"
        if cand.exists():
            return cand

    # 2. Nhân vật chính
    if "kim_trong" in clean and "thuy_kieu" not in clean and "tieu_dong" not in clean and "quan_gia" not in clean:
        cand = CHARACTERS_DIR / "01_Main_Protagonists" / "kim_trong_18yo" / "kim_trong_18yo_720p.png"
        if cand.exists():
            return cand

    if "vuong_ong" in clean and "sai_nha" not in clean:
        cand = CHARACTERS_DIR / "02_Vuong_Family_And_Fate" / "vuong_ong_55yo" / "vuong_ong_55yo_720p.png"
        if cand.exists():
            return cand

    if "vuong_ba" in clean and "sai_nha" not in clean:
        cand = CHARACTERS_DIR / "02_Vuong_Family_And_Fate" / "vuong_ba_50yo" / "vuong_ba_50yo_720p.png"
        if cand.exists():
            return cand

    if "vuong_quan" in clean and "sai_nha" not in clean and "thuy_kieu" not in clean:
        cand = CHARACTERS_DIR / "02_Vuong_Family_And_Fate" / "vuong_quan_16yo" / "vuong_quan_16yo_720p.png"
        if cand.exists():
            return cand

    if "dam_tien" in clean:
        cand = CHARACTERS_DIR / "02_Vuong_Family_And_Fate" / "dam_tien" / "dam_tien_720p.png"
        if cand.exists():
            return cand

    if "thuy_van" in clean and "thuy_kieu" not in clean:
        cand = CHARACTERS_DIR / "01_Main_Protagonists" / "thuy_van_maiden_16yo" / "thuy_van_maiden_16yo_720p.png"
        if cand.exists():
            return cand

    if "thuy_kieu" in clean and "kim_trong" not in clean:
        cand = CHARACTERS_DIR / "01_Main_Protagonists" / "thuy_kieu_maiden_16yo" / "thuy_kieu_maiden_16yo_720p.png"
        if cand.exists():
            return cand

    if "sai_nha" in clean:
        cand = CHARACTERS_DIR / "05_Imperial_Court_And_Officials" / "sai_nha" / "sai_nha_720p.png"
        if cand.exists():
            return cand

    if "thang_ban_to" in clean:
        cand = CHARACTERS_DIR / "05_Imperial_Court_And_Officials" / "thang_ban_to" / "thang_ban_to_720p.png"
        if cand.exists():
            return cand

    if "ma_giam_sinh" in clean:
        cand = CHARACTERS_DIR / "04_Brokers_And_Brothels" / "ma_giam_sinh" / "ma_giam_sinh_720p.png"
        if cand.exists():
            return cand

    if "tu_ba" in clean:
        cand = CHARACTERS_DIR / "04_Brokers_And_Brothels" / "tu_ba" / "tu_ba_720p.png"
        if cand.exists():
            return cand

    if "so_khanh" in clean:
        cand = CHARACTERS_DIR / "04_Brokers_And_Brothels" / "so_khanh" / "so_khanh_720p.png"
        if cand.exists():
            return cand

    # 3. Dynamic recursive search fallback
    for p in CHARACTERS_DIR.glob(f"**/*{clean}*720p.png"):
        return p

    return None



def classify_start_frame_strategy(
    shot_id: str,
    shot_data: Optional[Dict] = None,
    take_type: str = "CINEMATIC_CUT",
    resolved_path: Optional[str] = None
) -> Tuple[str, Optional[str]]:
    """
    Phân loại chiến lược Start Frame theo chuẩn ma trận KieuStory:
    1. TEXT_TO_VIDEO: Shot mở màn không có frame tham chiếu hoặc prompt Text-to-Video
    2. HEAD_TAIL_PREV_FRAME: Continuous Take cùng nhân vật
    3. CHARACTER_TURNAROUND_VIEW: Cận cảnh/MCU/Chân dung/Đổi vai đơn nhân vật
    4. GEMINI_BANANA_KEYFRAME: Cảnh toàn, đại cảnh, cảnh nhiều nhân vật, phong cảnh/đạo cụ
    
    Trả về: (strategy_name, view_type_if_turnaround)
    """
    if shot_data is None:
        shot_data = {}

    mode = str(shot_data.get("mode", "")).lower()
    if mode == "text-to-video":
        return "TEXT_TO_VIDEO", None

    if take_type == "CONTINUOUS_TAKE" and resolved_path and "clean_frame_239" in str(resolved_path).lower():
        return "HEAD_TAIL_PREV_FRAME", None

    # Phân loại cho Cinematic Cut:
    anchor = get_character_anchor(shot_id, shot_data)
    title = str(shot_data.get("scene_title", "")).lower()
    prompt = str(shot_data.get("motion_prompt", "")).lower()
    camera = str(shot_data.get("camera_motion", "")).lower()
    combined = f"{shot_id} {title} {prompt} {camera}"

    # Đa nhân vật hoặc cảnh vật (anchor chứa '+' hoặc tiêu đề ghi rõ two-shot/trung cảnh đôi)
    is_two_shot = any(kw in title for kw in ["two-shot", "trung cảnh đôi", "hai vợ chồng", "hai chị em", "đôi lứa", "song hành", "đoàn người"])
    is_multi_char = ("+" in anchor) or (is_two_shot and not any(kw in title for kw in ["cận cảnh", "close-up", "mcu", "cu", "entrance", "bước vào"]))
    is_scenery_or_prop = (not anchor or anchor == "none") and not any(k in combined for k in ["thúy kiều", "kim trọng", "thúy vân", "vương quan", "vương ông", "vương bà", "đạm tiên"])

    wide_kws = ['toàn cảnh', 'wide shot', 'extreme wide', 'aerial', 'đại cảnh', 'kinh thành', 'bầu trời', 'dòng sông', 'sông biếc', 'cảnh rộng', 'khung cảnh', 'phố thị', 'quân lính', 'hành động']
    is_wide = any(kw in title or kw in camera for kw in wide_kws) and not any(kw in title for kw in ["cận cảnh", "close-up", "mcu", "cu", "chân dung", "portrait", "entrance", "bước vào"])

    if is_scenery_or_prop or is_multi_char or is_wide:
        return "GEMINI_BANANA_KEYFRAME", None

    # Đơn nhân vật -> CHARACTER_TURNAROUND_VIEW
    desired_view = shot_data.get("turnaround_view")
    if not desired_view:
        if any(kw in combined for kw in ["bước vào", "entrance", "full body", "đứng", "toàn thân", "bái chào", "bái tạ", "xuất hành"]):
            desired_view = "front"
        else:
            desired_view = "portrait"

    return "CHARACTER_TURNAROUND_VIEW", desired_view


def resolve_start_frame(
    shot_id: str,
    shot_data: Dict,
    explicit_frame: Optional[str] = None,
    return_classification: bool = False,
    return_strategy: bool = False
) -> Union[Optional[str], Tuple[Optional[str], str], Tuple[Optional[str], str, str]]:
    """
    Xác định tự động Start Frame cho một Shot với cơ chế phân loại Cú Máy & Chiến Lược Start Frame:
    - Cinematic Cut (shot_num == 1 hoặc đổi nhân vật):
      Nạp Start Frame độc lập từ Character Turnaround Sheet hoặc Gemini Banana Keyframe. TUYỆT ĐỐI KHÔNG dùng prev_tail.
    - Continuous Take (cùng nhân vật nối tiếp):
      Nạp Tail Frame của shot trước (clean_frame_239.jpg).
    - return_strategy: Trả về (resolved_path, take_type, start_frame_strategy)
    """
    if explicit_frame and os.path.exists(explicit_frame):
        res = str(Path(explicit_frame).resolve())
        res = ensure_16x9_start_frame(res, shot_id=shot_id)
        if return_strategy:
            return (res, "CINEMATIC_CUT", "CHARACTER_TURNAROUND_VIEW")
        return (res, "CINEMATIC_CUT") if return_classification else res

    take_type = classify_shot_take(shot_id, shot_data=shot_data)

    m = re.search(r"^(ep\d+_scene\d+)_shot(\d+)$", shot_id, re.IGNORECASE)
    scene_prefix = m.group(1) if m else None
    shot_num = int(m.group(2)) if m else 1

    # 0. Ưu tiên Start Frame được thiết kế chuyên biệt cho shot nếu tồn tại
    dedicated_start = KEYFRAMES_DIR / shot_id / "start_frame_720p.png"
    if not dedicated_start.exists():
        dedicated_start = KEYFRAMES_DIR / shot_id / "start_frame.png"
    if not dedicated_start.exists():
        dedicated_start = KEYFRAMES_DIR / f"{shot_id}_start.jpg"
    if not dedicated_start.exists():
        dedicated_start = KEYFRAMES_DIR / f"{shot_id}_start.png"
    if dedicated_start.exists() and (take_type == "CINEMATIC_CUT" or shot_num == 1):
        res = str(dedicated_start.resolve())
        res = ensure_16x9_start_frame(res, shot_id=shot_id)
        if return_strategy:
            strat, vtype = classify_start_frame_strategy(shot_id, shot_data, take_type=take_type, resolved_path=res)
            return (res, take_type, strat)
        return (res, take_type) if return_classification else res

    # 1. Continuous Take: Chỉ lấy prev_tail khi take_type == CONTINUOUS_TAKE
    if take_type == "CONTINUOUS_TAKE" and scene_prefix and shot_num > 1:
        prev_shot_id = f"{scene_prefix}_shot{shot_num - 1:02d}"
        prev_tail = find_tail_frame(prev_shot_id)
        if prev_tail:
            # Defensive check: ensure bilateral character safeguards are not violated before returning prev_tail
            m_ep = re.search(r"^(ep\d+)", shot_id, re.IGNORECASE)
            ep_id = m_ep.group(1).lower() if m_ep else "ep01"
            all_ep_shots = get_all_shots(ep_id)
            prev_data = all_ep_shots.get(prev_shot_id, {})
            curr_data = shot_data if shot_data is not None else all_ep_shots.get(shot_id, {})

            prev_ref = prev_data.get("character_asset_ref", "")
            curr_ref = curr_data.get("character_asset_ref", "")

            def _get_group(ref: str) -> str:
                n = Path(ref).stem.lower()
                for k in ["thuy_van", "thuy_kieu", "kim_trong", "vuong_ong", "vuong_ba", "vuong_quan", "dam_tien", "tieu_dong", "quan_gia"]:
                    if k in n:
                        return k
                return n

            safeguard_violated = False
            if prev_ref and curr_ref and _get_group(prev_ref) != _get_group(curr_ref):
                safeguard_violated = True

            curr_anchor_check = get_character_anchor(shot_id, curr_data)
            prev_anchor_check = get_character_anchor(prev_shot_id, prev_data)
            if curr_anchor_check and prev_anchor_check and curr_anchor_check != "none" and prev_anchor_check != "none" and curr_anchor_check != prev_anchor_check:
                safeguard_violated = True

            if not safeguard_violated:
                res = str(prev_tail.resolve())
                res = ensure_16x9_start_frame(res, shot_id=shot_id)
                if return_strategy:
                    strat, vtype = classify_start_frame_strategy(shot_id, shot_data, take_type=take_type, resolved_path=res)
                    return (res, take_type, strat)
                return (res, take_type) if return_classification else res
            else:
                take_type = "CINEMATIC_CUT"

    # 2. Cinematic Cut (hoặc Continuous Take nhưng chưa có prev_tail):
    # Nạp Start Frame độc lập từ Character Bible hoặc character_asset_ref
    curr_anchor = get_character_anchor(shot_id, shot_data)
    raw_title = str(shot_data.get("scene_title", "")).lower() if shot_data else ""
    prompt = str(shot_data.get("motion_prompt", "")).lower() if shot_data else ""

    # Xác định góc nhìn ưu tiên từ Character Turnaround Sheet (portrait hoặc front)
    desired_view = shot_data.get("turnaround_view") if shot_data else None
    if not desired_view and shot_data:
        combo_text = f"{raw_title} {prompt}".lower()
        if any(kw in combo_text for kw in ["bước vào", "entrance", "full body", "đứng", "toàn thân", "bái chào", "bái tạ", "xuất hành"]):
            desired_view = "front"
        elif any(kw in combo_text for kw in ["cận cảnh", "close-up", "mcu", "cu", "chân dung", "portrait", "ánh mắt", "gương mặt", "khuôn mặt", "nụ cười"]):
            desired_view = "portrait"
        else:
            desired_view = "portrait"

    # Tách biệt tiêu đề phòng (room header) và hành động phân cảnh (shot action)
    # Tránh để tiêu đề phòng (vd: "KHUÊ PHÒNG THÚY KIỀU & THÚY VÂN") vô hiệu hóa title_tv_only khi cảnh tập trung vào Thúy Vân
    if " - " in raw_title:
        shot_action = raw_title.split(" - ", 1)[1]
    else:
        m_action = re.search(r"shot\s*\d+:\s*(.*)", raw_title)
        shot_action = m_action.group(1) if m_action else raw_title

    action_combined = f"{shot_action} {prompt}"
    full_combined = f"{raw_title} {prompt}"

    title_kt_only = ("kim trọng" in full_combined and "thúy kiều" not in full_combined and "nàng kiều" not in full_combined)
    title_tk_only = (("thúy kiều" in full_combined or "nàng kiều" in full_combined) and "kim trọng" not in full_combined)
    title_vq_only = ("vương quan" in full_combined and "thúy kiều" not in full_combined)
    title_vo_only = ("vương ông" in full_combined and "thúy kiều" not in full_combined and "vương bà" not in full_combined and "mẫu thân" not in full_combined)
    title_vb_only = (
        ("vương bà" in full_combined or "mẫu thân" in full_combined or "bà vương" in full_combined) and
        "thúy kiều" not in full_combined and "vương ông" not in full_combined
    )
    title_tv_only = (
        ("thúy vân" in action_combined and "thúy kiều" not in action_combined) or
        ("thúy vân" in full_combined and "thúy kiều" not in full_combined)
    )

    has_anchor = bool(curr_anchor and curr_anchor != "none")
    is_compound_anchor = ("+" in curr_anchor) if curr_anchor else False
    allow_title_override = (not has_anchor) or is_compound_anchor

    is_kim_trong = ("kim_trong" in curr_anchor and "thuy_kieu" not in curr_anchor and "tieu_dong" not in curr_anchor and "quan_gia" not in curr_anchor) or (allow_title_override and title_kt_only)
    is_thuy_kieu = ("thuy_kieu" in curr_anchor and "kim_trong" not in curr_anchor) or (allow_title_override and title_tk_only)
    is_vuong_quan = ("vuong_quan" in curr_anchor and "thuy_kieu" not in curr_anchor and "sai_nha" not in curr_anchor) or (allow_title_override and title_vq_only)
    is_vuong_ong = ("vuong_ong" in curr_anchor and "thuy_kieu" not in curr_anchor and "sai_nha" not in curr_anchor) or (allow_title_override and title_vo_only)
    is_vuong_ba = ("vuong_ba" in curr_anchor and "thuy_kieu" not in curr_anchor and "sai_nha" not in curr_anchor) or (allow_title_override and title_vb_only)
    is_thuy_van = ("thuy_van" in curr_anchor and "thuy_kieu" not in curr_anchor) or (allow_title_override and title_tv_only)
    resolved_path: Optional[str] = None

    # A. Tìm asset tham chiếu trong prompt data (kèm alias mapping và Bilateral Safeguards)
    # Nếu shot_data chỉ định tường minh turnaround_view hoặc start_frame_strategy == "CHARACTER_TURNAROUND_VIEW":
    if shot_data and (shot_data.get("turnaround_view") or shot_data.get("start_frame_strategy") == "CHARACTER_TURNAROUND_VIEW"):
        target_char = str(shot_data.get("character_asset_ref", "")) or curr_anchor
        portrait_master = resolve_character_portrait_from_anchor(target_char)
        if portrait_master and portrait_master.exists():
            resolved_path = str(portrait_master.resolve())
        else:
            ta_view = find_character_turnaround_view(target_char, desired_view or "portrait")
            if ta_view and ta_view.exists():
                resolved_path = str(ta_view.resolve())

    asset_ref = shot_data.get("character_asset_ref")
    if not resolved_path and asset_ref:
        ref_lower = str(asset_ref).lower()
        # Bilateral & Multi-character Safeguards chống gán nhầm chéo:
        if is_kim_trong and "thuy_kieu" in ref_lower:
            asset_ref = "04_Assets/characters/01_Main_Protagonists/kim_trong_18yo/kim_trong_18yo_720p.png"
        elif is_thuy_kieu and "kim_trong" in ref_lower:
            asset_ref = "04_Assets/characters/01_Main_Protagonists/thuy_kieu_maiden_16yo/thuy_kieu_maiden_16yo_720p.png"
        elif is_vuong_quan and "thuy_kieu" in ref_lower:
            asset_ref = "04_Assets/characters/02_Vuong_Family_And_Fate/vuong_quan_16yo/vuong_quan_16yo_720p.png"
        elif is_vuong_ong and "thuy_kieu" in ref_lower:
            asset_ref = "04_Assets/characters/02_Vuong_Family_And_Fate/vuong_ong_55yo/vuong_ong_55yo_720p.png"
        elif is_vuong_ba and "thuy_kieu" in ref_lower:
            asset_ref = "04_Assets/characters/02_Vuong_Family_And_Fate/vuong_ba_50yo/vuong_ba_50yo_720p.png"
        elif is_thuy_van and "thuy_kieu" in ref_lower:
            asset_ref = "04_Assets/characters/01_Main_Protagonists/thuy_van_maiden_16yo/thuy_van_maiden_16yo_720p.png"

        asset_path = BASE_DIR / asset_ref
        if asset_path.exists():
            resolved_path = str(asset_path.resolve())
        else:
            # Tự động tìm kiếm trong kho Character-Centric mới
            fname = Path(asset_ref).name
            char_cands = list(CHARACTERS_DIR.glob(f"**/{fname}"))
            if char_cands:
                resolved_path = str(char_cands[0].resolve())
            else:
                alias_map = {
                    "vuong_ong_patriarch_55yo_720p.png": "04_Assets/characters/02_Vuong_Family_And_Fate/vuong_ong_55yo/vuong_ong_55yo_720p.png",
                    "vuong_ba_matriarch_50yo_720p.png": "04_Assets/characters/02_Vuong_Family_And_Fate/vuong_ba_50yo/vuong_ba_50yo_720p.png",
                    "vuong_quan_young_14yo_720p.png": "04_Assets/characters/02_Vuong_Family_And_Fate/vuong_quan_16yo/vuong_quan_16yo_720p.png",
                    "vuong_quan_14yo_720p.png": "04_Assets/characters/02_Vuong_Family_And_Fate/vuong_quan_16yo/vuong_quan_16yo_720p.png",
                    "imperial_guards_brutal_officials_720p.png": "04_Assets/characters/05_Imperial_Court_And_Officials/sai_nha/sai_nha_720p.png",
                    "sai_nha_feudal_guards_720p.png": "04_Assets/characters/05_Imperial_Court_And_Officials/sai_nha/sai_nha_720p.png",
                    "crowd_qingming_festival_720p.png": "04_Assets/keyframes/crowd_qingming_festival_720p.png",
                }
                if fname in alias_map:
                    alias_path = BASE_DIR / alias_map[fname]
                    if alias_path.exists():
                        resolved_path = str(alias_path.resolve())
                elif asset_ref in alias_map:
                    alias_path = BASE_DIR / alias_map[asset_ref]
                    if alias_path.exists():
                        resolved_path = str(alias_path.resolve())

    # B. Nếu chưa tìm thấy và có character_anchor: tra cứu trực tiếp trong kho Characters (ưu tiên turnaround views/)
    if not resolved_path and curr_anchor:
        portrait = resolve_character_portrait_from_anchor(curr_anchor, view_type=desired_view)
        if portrait and portrait.exists():
            resolved_path = str(portrait.resolve())

    # C. Kiểm tra reference_start_frame nếu là đường dẫn hợp lệ (chỉ khi không vi phạm nhân vật)
    if not resolved_path:
        ref_sf = shot_data.get("reference_start_frame", "")
        if ref_sf:
            if take_type == "CONTINUOUS_TAKE":
                m_ref = re.search(r"(ep\d+_scene\d+_shot\d+)", ref_sf, re.IGNORECASE)
                if m_ref:
                    ref_shot_id = m_ref.group(1)
                    ref_tail = find_tail_frame(ref_shot_id)
                    if ref_tail:
                        resolved_path = str(ref_tail.resolve())
            ref_path = BASE_DIR / ref_sf
            if ref_path.exists() and ref_path.is_file():
                ref_name = ref_path.name.lower()
                violation = (
                    (is_kim_trong and "thuy_kieu" in ref_name) or
                    (is_thuy_kieu and "kim_trong" in ref_name) or
                    (is_vuong_quan and "thuy_kieu" in ref_name) or
                    (is_vuong_ong and "thuy_kieu" in ref_name) or
                    (is_thuy_van and "thuy_kieu" in ref_name)
                )
                if not violation:
                    resolved_path = str(ref_path.resolve())

    # D. Final Bilateral & Multi-character Safeguards: Ghi đè tuyệt đối nếu có vi phạm
    if resolved_path:
        res_lower = resolved_path.lower()
        if is_kim_trong and "thuy_kieu" in res_lower:
            cand = resolve_character_portrait_from_anchor("kim_trong", view_type=desired_view)
            resolved_path = str(cand.resolve()) if cand and cand.exists() else None
        elif is_thuy_kieu and "kim_trong" in res_lower:
            cand = resolve_character_portrait_from_anchor("thuy_kieu", view_type=desired_view)
            resolved_path = str(cand.resolve()) if cand and cand.exists() else None
        elif is_vuong_quan and "thuy_kieu" in res_lower:
            cand = resolve_character_portrait_from_anchor("vuong_quan", view_type=desired_view)
            resolved_path = str(cand.resolve()) if cand and cand.exists() else None
        elif is_vuong_ong and "thuy_kieu" in res_lower:
            cand = resolve_character_portrait_from_anchor("vuong_ong", view_type=desired_view)
            resolved_path = str(cand.resolve()) if cand and cand.exists() else None
        elif is_vuong_ba and "thuy_kieu" in res_lower:
            cand = resolve_character_portrait_from_anchor("vuong_ba", view_type=desired_view)
            resolved_path = str(cand.resolve()) if cand and cand.exists() else None
        elif is_thuy_van and "thuy_kieu" in res_lower:
            cand = resolve_character_portrait_from_anchor("thuy_van", view_type=desired_view)
            resolved_path = str(cand.resolve()) if cand and cand.exists() else None

    # Step D Fallback: Nếu vi phạm tại Step C khiến resolved_path là None, nạp chân dung chuẩn của nhân vật đích hoặc asset cảnh
    if not resolved_path:
        if is_kim_trong:
            p = resolve_character_portrait_from_anchor("kim_trong", view_type=desired_view)
            if p and p.exists(): resolved_path = str(p.resolve())
        elif is_thuy_kieu:
            p = resolve_character_portrait_from_anchor("thuy_kieu", view_type=desired_view)
            if p and p.exists(): resolved_path = str(p.resolve())
        elif is_vuong_quan:
            p = resolve_character_portrait_from_anchor("vuong_quan", view_type=desired_view)
            if p and p.exists(): resolved_path = str(p.resolve())
        elif is_vuong_ong:
            p = resolve_character_portrait_from_anchor("vuong_ong", view_type=desired_view)
            if p and p.exists(): resolved_path = str(p.resolve())
        elif is_vuong_ba:
            p = resolve_character_portrait_from_anchor("vuong_ba", view_type=desired_view)
            if p and p.exists(): resolved_path = str(p.resolve())
        elif is_thuy_van:
            p = resolve_character_portrait_from_anchor("thuy_van", view_type=desired_view)
            if p and p.exists(): resolved_path = str(p.resolve())
        # Crowd Qingming Festival fallback
        elif "quan_chung_thanh_minh" in curr_anchor or "crowd_qingming" in str(asset_ref).lower() or (scene_prefix and "scene04" in scene_prefix and shot_num in (1, 2, 3, 4, 12)):
            cand = KEYFRAMES_DIR / "crowd_qingming_festival_720p.png"
            if cand.exists(): resolved_path = str(cand.resolve())
        # Ep01 Scene 00 Prologue keyframes fallback
        elif scene_prefix and ("scene00" in scene_prefix or "prologue" in scene_prefix):
            cand_list = [
                KEYFRAMES_DIR / f"prologue_shot{shot_num}" / "start_frame_720p.png",
                KEYFRAMES_DIR / f"prologue_shot{shot_num}" / "frame_000.jpg",
                KEYFRAMES_DIR / f"prologue_shot{shot_num}" / "clean_frame_000.jpg",
                BASE_DIR / "04_Assets" / "backgrounds" / f"prologue_shot{shot_num}_720p.png",
                KEYFRAMES_DIR / "prologue_shot4" / f"prologue_shot{shot_num}" / "frame_000.jpg",
            ]
            for cand in cand_list:
                if cand.exists():
                    resolved_path = str(cand.resolve())
                    break
        # Ep01 Scene 01 Bức họa mùa xuân keyframes fallback
        elif scene_prefix and "scene01" in scene_prefix:
            cand_list = [
                KEYFRAMES_DIR / shot_id / "start_frame_720p.png",
                KEYFRAMES_DIR / shot_id / "clean_frame_239.jpg",
                BASE_DIR / "04_Assets" / "backgrounds" / f"{shot_id}_720p.png",
            ]
            for cand in cand_list:
                if cand.exists():
                    resolved_path = str(cand.resolve())
                    break
        # Scenery & Environment fallbacks
        elif shot_id == "ep01_scene05_shot07" or "suoi_ngoc_rang_lieu" in str(asset_ref).lower():
            cand = BASE_DIR / "04_Assets" / "backgrounds" / "suoi_ngoc_rang_lieu_720p.png"
            if cand.exists(): resolved_path = str(cand.resolve())
        elif shot_id == "ep01_scene05_shot10" or "mo_dam_tien" in str(asset_ref).lower():
            cand = BASE_DIR / "04_Assets" / "backgrounds" / "mo_dam_tien_co_ua_720p.png"
            if cand.exists(): resolved_path = str(cand.resolve())
        elif shot_id == "ep01_scene06_shot01" or "hoang_hon_rang_lieu" in str(asset_ref).lower():
            cand = BASE_DIR / "04_Assets" / "backgrounds" / "hoang_hon_rang_lieu_720p.png"
            if cand.exists(): resolved_path = str(cand.resolve())
        elif shot_id == "ep01_scene06_shot02" or "duong_mon_hoang_hon" in str(asset_ref).lower():
            cand = BASE_DIR / "04_Assets" / "backgrounds" / "duong_mon_hoang_hon_720p.png"
            if cand.exists(): resolved_path = str(cand.resolve())
        elif shot_id == "ep01_scene08_shot01" or "buc_tuong_hoa" in str(asset_ref).lower():
            cand = BASE_DIR / "04_Assets" / "backgrounds" / "buc_tuong_hoa_vuon_thuy_720p.png"
            if cand.exists(): resolved_path = str(cand.resolve())
        elif shot_id in ("ep01_scene11_shot01", "ep01_scene11_shot02") or "goc_lieu_vuon_thuy" in str(asset_ref).lower():
            cand = BASE_DIR / "04_Assets" / "backgrounds" / "vuon_thuy_spring_720p.png"
            if cand.exists(): resolved_path = str(cand.resolve())
        elif shot_id in ("ep01_scene12_shot01", "ep01_scene12_shot02") or "cong_chinh_gia_trang" in str(asset_ref).lower() or "storm" in str(asset_ref).lower():
            cand = BASE_DIR / "04_Assets" / "backgrounds" / "vuong_family_gate_storm_720p.png"
            if cand.exists(): resolved_path = str(cand.resolve())
        elif shot_id in ("ep01_scene13_shot01", "ep01_scene13_shot02") or "chiaroscuro" in str(asset_ref).lower():
            cand = BASE_DIR / "04_Assets" / "backgrounds" / "vuong_chiaroscuro_livingroom_720p.png"
            if cand.exists(): resolved_path = str(cand.resolve())
        elif shot_id in ("ep01_scene15_shot01", "ep01_scene15_shot02") or "gian_tho" in str(asset_ref).lower():
            cand = BASE_DIR / "04_Assets" / "backgrounds" / "vuong_chiaroscuro_livingroom_720p.png"
            if cand.exists(): resolved_path = str(cand.resolve())

    # E. Cinematic Cut invariant: NEVER return prev_tail!
    if take_type == "CINEMATIC_CUT" and resolved_path and scene_prefix and shot_num > 1:
        prev_shot_id = f"{scene_prefix}_shot{shot_num - 1:02d}"
        prev_tail_dir = KEYFRAMES_DIR / prev_shot_id
        if str(prev_tail_dir).lower() in resolved_path.lower():
            resolved_path = None
            if curr_anchor:
                p = resolve_character_portrait_from_anchor(curr_anchor, view_type=desired_view)
                if p and p.exists():
                    resolved_path = str(p.resolve())
            if not resolved_path and scene_prefix and ("scene00" in scene_prefix or "prologue" in scene_prefix):
                cand_list = [
                    KEYFRAMES_DIR / f"prologue_shot{shot_num}" / "start_frame_720p.png",
                    KEYFRAMES_DIR / f"prologue_shot{shot_num}" / "frame_000.jpg",
                    BASE_DIR / "04_Assets" / "backgrounds" / f"prologue_shot{shot_num}_720p.png",
                ]
                for cand in cand_list:
                    if cand.exists():
                        resolved_path = str(cand.resolve())
                        break
            elif not resolved_path and scene_prefix and "scene01" in scene_prefix:
                cand_list = [
                    KEYFRAMES_DIR / shot_id / "start_frame_720p.png",
                    KEYFRAMES_DIR / shot_id / "clean_frame_239.jpg",
                    BASE_DIR / "04_Assets" / "backgrounds" / f"{shot_id}_720p.png",
                ]
                for cand in cand_list:
                    if cand.exists():
                        resolved_path = str(cand.resolve())
                        break

    # Hard Gate 16:9 Cinema Aspect Ratio: đảm bảo không bao giờ trả về ảnh dọc cho Start Frame
    if resolved_path:
        resolved_path = ensure_16x9_start_frame(resolved_path, shot_id=shot_id)

    if return_strategy:
        strat, vtype = classify_start_frame_strategy(shot_id, shot_data, take_type=take_type, resolved_path=resolved_path)
        return (resolved_path, take_type, strat)
    if return_classification:
        return (resolved_path, take_type)
    return resolved_path


def resolve_start_frame_v2(
    shot_id: str,
    shot_data: Optional[Dict] = None,
    banana_prompts: Optional[Dict] = None
) -> Tuple[Optional[str], str]:
    """
    Giao diện chuẩn hóa v2 trả về (start_frame_path, take_type).
    take_type: 'CINEMATIC_CUT' hoặc 'CONTINUOUS_TAKE'.
    """
    if shot_data is None:
        shot_data = {}
    return resolve_start_frame(shot_id, shot_data, return_classification=True)


def resolve_start_frame_v3(
    shot_id: str,
    shot_data: Optional[Dict] = None,
    banana_prompts: Optional[Dict] = None
) -> Tuple[Optional[str], str, str]:
    """
    Giao diện chuẩn hóa v3 trả về (start_frame_path, take_type, start_frame_strategy).
    start_frame_strategy: 'CHARACTER_TURNAROUND_VIEW', 'GEMINI_BANANA_KEYFRAME', 'HEAD_TAIL_PREV_FRAME', 'TEXT_TO_VIDEO'.
    """
    if shot_data is None:
        shot_data = {}
    return resolve_start_frame(shot_id, shot_data, return_strategy=True)


def show_production_status(episode_id: Optional[str] = None):
    """Hiển thị bảng tổng kết tiến độ sản xuất toàn bộ các tập phim hoặc một tập cụ thể."""
    shots = get_all_shots(episode_id=episode_id)
    total_shots = len(shots)
    rendered_count = 0
    scenes_map = {}

    for shot_id, shot_data in shots.items():
        m = re.search(r"^(ep\d+_scene\d+)", shot_id, re.IGNORECASE)
        scene_id = m.group(1) if m else "special_prompts"
        if scene_id not in scenes_map:
            scenes_map[scene_id] = []
        
        is_rendered = find_rendered_video(shot_id) is not None
        if is_rendered:
            rendered_count += 1
        scenes_map[scene_id].append((shot_id, is_rendered, shot_data))

    ep_header = f" - TẬP {episode_id.upper()}" if episode_id else ""
    print("\n" + "=" * 78)
    print(f"🎬 THẬP NGŨ NIÊN (THE FIFTEEN SPRINGS) - BÁO CÁO TIẾN ĐỘ SẢN XUẤT{ep_header}")
    print("=" * 78)
    print(f"Tổng số shot định nghĩa: {total_shots} shots")
    pct_total = f"({rendered_count / total_shots:.1%})" if total_shots > 0 else "(0%)"
    print(f"Số shot đã render:       {rendered_count} / {total_shots} {pct_total}")
    
    # Kiểm tra các Master Video đã xuất
    pattern = f"{episode_id.lower()}*master*.mp4" if episode_id else "*master*.mp4"
    masters = list(VIDEOS_DIR.glob(pattern)) + list(EXPORTS_DIR.glob(pattern))
    print(f"Số Video Master hoàn tất: {len(masters)} video")
    print("-" * 78)
    print(f"{'CẢNH / PHÂN ĐOẠN':<24} | {'SỐ SHOT':<8} | {'TIẾN ĐỘ':<12} | {'TRẠNG THÁI MASTER'}")
    print("-" * 78)

    for scene_id, shot_list in sorted(scenes_map.items()):
        n_shots = len(shot_list)
        n_done = sum(1 for _, done, _ in shot_list if done)
        pct = f"{n_done}/{n_shots} ({n_done/n_shots:.0%})" if n_shots > 0 else "0/0"
        
        # Check master
        master_file = VIDEOS_DIR / f"{scene_id}_master.mp4"
        if not master_file.exists():
            master_file = VIDEOS_DIR / f"{scene_id}_master_50s.mp4"
        if not master_file.exists():
            master_file = EXPORTS_DIR / f"{scene_id}_cinematic_master.mp4"
        master_status = "✓ ĐÃ CÓ MASTER" if master_file.exists() else "⏳ Đang sản xuất"
        
        print(f"{scene_id:<24} | {n_shots:<8} | {pct:<12} | {master_status}")

    print("=" * 78 + "\n")


def list_shots_cmd(scene_id: Optional[str] = None, episode_id: Optional[str] = None):
    """Liệt kê chi tiết từng shot."""
    shots = get_all_shots(episode_id=episode_id)
    if scene_id:
        matched = [(sid, sdata) for sid, sdata in shots.items() if sid.startswith(scene_id)]
    else:
        matched = list(shots.items())

    ep_header = f" TẬP {episode_id.upper()}" if episode_id else ""
    print("\n" + "=" * 96)
    print(f"DANH SÁCH SHOT ({len(matched)} shots)" + (f" CHO PHÂN CẢNH: {scene_id}" if scene_id else ep_header))
    print("=" * 96)
    print(f"{'MÃ SHOT':<24} | {'ENGINE':<8} | {'THỜI LƯỢNG':<10} | {'START FRAME':<14} | {'RENDER':<10} | TIÊU ĐỀ")
    print("-" * 96)

    for shot_id, shot_data in matched:
        engine_label = "LTX" if classify_shot_engine(shot_id, shot_data) == "gradio_ltx" else "MUSE"
        duration = f"{shot_data.get('duration_sec', 10)}s"
        has_video = "✓ XONG" if find_rendered_video(shot_id) else "—"
        start_frame = resolve_start_frame(shot_id, shot_data)
        sf_label = Path(start_frame).name[:12] + ".." if start_frame else "Chưa có"
        title = shot_data.get("scene_title", "")[:32]
        print(f"{shot_id:<24} | [{engine_label:<4}] | {duration:<10} | {sf_label:<14} | {has_video:<10} | {title}")

    print("=" * 96 + "\n")


def reverse_video_file(input_video: str, output_video: Optional[str] = None) -> bool:
    """
    Đảo ngược video và audio bằng FFmpeg (-vf reverse -af areverse).
    Bảo toàn chất lượng video libx264 crf 18 và âm thanh AAC 192k 48000Hz.
    Nếu output_video không được chỉ định, ghi đè an toàn lên chính input_video.
    """
    in_path = Path(input_video).resolve()
    if not in_path.exists():
        print(f"[!] Không tìm thấy file video: {in_path}")
        return False
    
    out_path = Path(output_video).resolve() if output_video else in_path
    temp_out = in_path.with_name(f"{in_path.stem}_rev_temp_{os.getpid()}{in_path.suffix}")
    
    ffmpeg_exe = get_ffmpeg()
    cmd = [
        ffmpeg_exe, "-y",
        "-i", str(in_path),
        "-vf", "reverse",
        "-af", "areverse",
        "-c:v", "libx264",
        "-crf", "18",
        "-preset", "slow",
        "-c:a", "aac",
        "-b:a", "192k",
        "-ar", "48000",
        str(temp_out)
    ]
    print(f"\n🔄 Đang thực thi Reverse Motion cho: {in_path.name}...")
    res = subprocess.run(cmd, capture_output=True, text=True)
    if res.returncode == 0 and temp_out.exists() and temp_out.stat().st_size > 1000:
        import shutil
        shutil.move(str(temp_out), str(out_path))
        print(f"[✓] Đã đảo ngược video và audio thành công: {out_path.name}")
        return True
    else:
        print(f"[!] Lỗi khi reverse video bằng FFmpeg:\n{res.stderr}")
        if temp_out.exists():
            try:
                temp_out.unlink()
            except Exception:
                pass
        return False


def render_single_shot(
    shot_id: str, 
    input_frame: Optional[str] = None, 
    prompt_override: Optional[str] = None, 
    session_name: str = "muse", 
    download_dir: Optional[Path] = None,
    reverse_motion: Optional[bool] = None,
    enable_critic: bool = True,
    max_retakes: int = 2,
    dry_run: Optional[bool] = None,
    engine: str = "auto",
    pacing_3beat: bool = False
) -> bool:
    """Thực thi render 1 shot đơn lẻ qua Engine phù hợp (Muse.ai hoặc Gradio LTX-Video) kèm Shot Gate và FlowKit Prompt Healing."""
    shots = get_all_shots()
    shot_data = shots.get(shot_id, {})
    
    prompt = prompt_override or shot_data.get("motion_prompt")
    if not prompt:
        print(f"[!] Không tìm thấy prompt cho shot: {shot_id}")
        return False

    expected_char = get_character_anchor(shot_id, shot_data)

    # Hỗ trợ định dạng FlowKit Cinematic 3-Beat Shot Formula
    if pacing_3beat and CinematicShotPacer is not None:
        cam_motion = shot_data.get("camera_motion", "")
        prompt = CinematicShotPacer.format_3beat_prompt(
            prompt,
            camera_motion=cam_motion,
            character_name=expected_char
        )

    # Tiền kiểm duyệt & đồng bộ Audio Guard chuẩn AGENTS.md §3
    prompt = ensure_audio_guard(prompt)

    if reverse_motion is None:
        reverse_motion = bool(shot_data.get("reverse_motion", False))

    resolved_frame = resolve_start_frame(shot_id, shot_data, input_frame)
    
    # Tìm prev_video để kiểm tra continuity
    prev_video = None
    m = re.search(r"^(ep\d+_scene\d+)_shot(\d+)$", shot_id, re.IGNORECASE)
    if m:
        scene_prefix = m.group(1)
        shot_num = int(m.group(2))
        if shot_num > 1:
            prev_shot_id = f"{scene_prefix}_shot{shot_num - 1:02d}"
            pv = find_rendered_video(prev_shot_id)
            if pv:
                prev_video = str(pv.resolve())

    if run_shot_pipeline is None:
        print("[!] Lỗi: run_shot module không khả dụng.")
        return False

    assigned_engine = classify_shot_engine(shot_id, shot_data, prompt) if engine == "auto" else engine
    print(f"\n🚀 Đang kích hoạt Orchestrator cho {shot_id}:")
    print(f"   • Target Engine:  {assigned_engine.upper()} ({'Tự động phân loại' if engine == 'auto' else 'Thủ công'})")
    print(f"   • Worker Session: {session_name}")
    print(f"   • Start Frame:    {resolved_frame}")
    print(f"   • Prev Video:     {prev_video}")
    print(f"   • Reverse Motion: {reverse_motion}")
    print(f"   • Enable Critic:  {enable_critic}")
    print(f"   • FlowKit Pacing: {'3-Beat Formula' if pacing_3beat else 'Standard'}")
    
    retake_attempt = 0
    current_prompt = prompt

    while retake_attempt <= max_retakes:
        success, tail_frame = run_shot_pipeline(
            shot_id, 
            resolved_frame, 
            current_prompt, 
            prev_video,
            session_name=session_name,
            download_dir=download_dir,
            reverse_motion=reverse_motion,
            dry_run=dry_run,
            engine=engine
        )
        if not success:
            print(f"[!] Thất bại render tại shot {shot_id} (lần {retake_attempt + 1})")
            return False

        target_video = find_rendered_video(shot_id)
        if not target_video:
            print(f"[!] Không tìm thấy file video sau khi render shot: {shot_id}")
            return False

        if not enable_critic or evaluate_shot_gate is None:
            # Nếu không bật critic gate hoặc module không khả dụng, chấp thuận và trích tail frame
            _extract_tail_frame(target_video, shot_id)
            return True

        verdict = evaluate_shot_gate(
            shot_id=shot_id,
            video_path=str(target_video),
            expected_character=expected_char,
            prompt=current_prompt
        )
        print(f"   📊 Antigravity Shot Gate ({shot_id}): Score = {verdict.overall_score:.2f} | Approved = {verdict.approved}")

        if verdict.overall_score >= 0.8 and verdict.suggested_action != "RETAKE_SHOT":
            # Video đạt chuẩn chất lượng (>= 0.8), trích xuất tail frame và chấp thuận
            _extract_tail_frame(target_video, shot_id)
            print(f"   [✓] Shot {shot_id} ĐẠT CHUẨN Shot Gate (Score: {verdict.overall_score:.2f})!")
            return True
        else:
            retake_attempt += 1
            if retake_attempt <= max_retakes:
                print(f"   🔄 [RETAKE {retake_attempt}/{max_retakes}] Shot {shot_id} không đạt chuẩn ({verdict.critique_notes}). Kích hoạt Diagnostic Prompt Healing...")
                healed_ok = False
                if DiagnosticPromptHealer is not None and getattr(verdict, "detected_errors", None):
                    healed_p, therapies = DiagnosticPromptHealer.heal_prompt(
                        original_prompt=current_prompt,
                        detected_errors=verdict.detected_errors,
                        character_name=expected_char,
                        cycle=retake_attempt
                    )
                    if therapies:
                        print(f"      🩹 [PROMPT HEALER] Đã tiêm {len(therapies)} phác đồ: {', '.join(therapies)}")
                        current_prompt = healed_p
                        healed_ok = True
                if not healed_ok and verdict.critique_notes:
                    current_prompt = f"{prompt} [Critique Fix: {verdict.critique_notes}]"
            else:
                print(f"[!] Shot {shot_id} không vượt qua Shot Gate sau {max_retakes} lần retake (Score: {verdict.overall_score:.2f})")
                return False

    return False


def batch_render_scene(
    scene_id: str, 
    force: bool = False, 
    auto_concat: bool = True, 
    session_name: str = "muse", 
    download_dir: Optional[Path] = None,
    enable_critic: bool = True,
    max_retakes: int = 2,
    dry_run: Optional[bool] = None,
    engine: str = "auto",
    pacing_3beat: bool = False
):
    """
    Render tự động toàn bộ các shot trong 1 cảnh (Scene) theo chuỗi Head-Tail liên hoàn kèm Scene Gate.
    Hỗ trợ định tuyến engine: auto (tự động), muse (cảnh toàn), gradio_ltx (cận cảnh).
    Hỗ trợ chuẩn hóa nhịp điệu FlowKit Cinematic 3-Beat Shot Formula.
    """
    scene_shots = get_shots_for_scene(scene_id)
    if not scene_shots:
        print(f"[!] Không tìm thấy shot nào cho cảnh: {scene_id}")
        return False

    print("\n" + "=" * 70)
    print(f"🎬 BẮT ĐẦU CHUỖI SẢN XUẤT TỰ ĐỘNG BATCH CHO CẢNH: {scene_id}")
    print(f"   Engine Routing: {engine.upper()}")
    print(f"   Worker Session: {session_name}")
    print(f"   Số lượng shot:  {len(scene_shots)}")
    print(f"   Critic Gate:    {'BẬT' if enable_critic else 'TẮT'}")
    print(f"   FlowKit Pacing: {'3-Beat Formula' if pacing_3beat else 'Standard'}")
    print("=" * 70)

    for idx, (shot_id, shot_data) in enumerate(scene_shots, 1):
        print(f"\n>>> [{idx}/{len(scene_shots)}] XỬ LÝ SHOT: {shot_id}")
        video_path = find_rendered_video(shot_id)
        
        if video_path and not force:
            print(f"    [✓] Shot đã được render trước đó: {video_path.name}. Bỏ qua render.")
            # Đảm bảo tail frame đã có
            tail_frame = find_tail_frame(shot_id)
            if not tail_frame:
                print(f"    Trích xuất bổ sung Tail Frame từ video sẵn có...")
                _extract_tail_frame(video_path, shot_id)
            continue

        # Tiến hành render có critic gate và retake loop
        success = render_single_shot(
            shot_id, 
            session_name=session_name, 
            download_dir=download_dir,
            enable_critic=enable_critic,
            max_retakes=max_retakes,
            dry_run=dry_run,
            engine=engine,
            pacing_3beat=pacing_3beat
        )
        if not success:
            print(f"[!] Dừng chuỗi batch vì lỗi tại shot: {shot_id}")
            return False

    print(f"\n[✓] TOÀN BỘ CÁC SHOT CỦA {scene_id} ĐÃ HOÀN TẤT!")

    # Thẩm định chất lượng toàn cảnh qua Scene Gate (Tầng 2)
    if enable_critic and evaluate_scene_gate is not None:
        shot_video_paths = [str(find_rendered_video(sid)) for sid, _ in scene_shots if find_rendered_video(sid)]
        if len(shot_video_paths) == len(scene_shots):
            scene_verdict = evaluate_scene_gate(scene_id, shot_video_paths)
            print(f"\n🏛️ Antigravity Scene Gate ({scene_id}): Score = {scene_verdict.overall_score:.2f} | Action = {scene_verdict.suggested_action}")
            if not scene_verdict.approved:
                print(f"[!] Scene Gate từ chối phê duyệt cảnh {scene_id}: {scene_verdict.critique_notes}")
                return False

    if auto_concat:
        print(f"\n🔗 Tự động kích hoạt ghép nối Master cho {scene_id}...")
        res_master = concat_scene_shots(scene_id, enable_critic=enable_critic)
        return res_master is not None

    return True


def resolve_versioned_path(base_dir: Path, stem: str, suffix: str = ".mp4") -> Path:
    """
    Sinh đường dẫn file theo chuẩn versioning _v<N> (bắt đầu từ _v1).
    Nếu đã có _v1, tự động tăng lên _v2, _v3...
    """
    base_dir.mkdir(parents=True, exist_ok=True)
    pattern = re.compile(rf"^{re.escape(stem)}_v(\d+){re.escape(suffix)}$")
    existing_versions = []
    for f in base_dir.glob(f"{stem}*{suffix}"):
        m = pattern.match(f.name)
        if m:
            existing_versions.append(int(m.group(1)))
    next_ver = max(existing_versions) + 1 if existing_versions else 1
    return base_dir / f"{stem}_v{next_ver}{suffix}"


def resolve_audio_asset(asset_name_or_path: Optional[str]) -> Optional[Path]:
    """
    Tìm kiếm và định vị đường dẫn file âm thanh (BGM, Ambience, Foley)
    xuyên suốt các thư mục tài nguyên chuẩn:
    1. Đường dẫn tuyệt đối hoặc tương đối trực tiếp (nếu file tồn tại)
    2. 04_Assets/audio/
    3. 04_Assets/audio_sfx/
    4. 04_Assets/audio_voice/
    5. 04_Assets/archive/audio/
    6. Thư mục gốc dự án
    """
    if not asset_name_or_path:
        return None
    p = Path(asset_name_or_path)
    if p.exists() and p.is_file():
        return p.resolve()

    name = p.name
    search_dirs = [
        ASSETS_DIR / "audio",
        ASSETS_DIR / "audio_sfx",
        ASSETS_DIR / "audio_voice",
        ASSETS_DIR / "archive" / "audio",
        BASE_DIR,
    ]
    for d in search_dirs:
        if not d.exists():
            continue
        candidate = d / name
        if candidate.exists() and candidate.is_file():
            return candidate.resolve()
        # Thử tìm không phân biệt hoa thường
        for f in d.glob("*"):
            if f.name.lower() == name.lower() and f.is_file():
                return f.resolve()
    return None


EP01_CANONICAL_SCENES = [
    f"ep01_scene{i:02d}" for i in range(1, 16)
]


def resolve_scene_master(scene_id: str) -> Optional[Path]:
    """
    Tìm master video cho scene theo thứ tự ưu tiên chất lượng và phiên bản:
    1. 06_Exports/{scene_id}_master_audio_v*.mp4 (phiên bản cao nhất)
    2. 06_Exports/{scene_id}_cinematic_master_v*.mp4 (phiên bản cao nhất)
    3. 06_Exports/{scene_id}_master_v*.mp4 (phiên bản cao nhất)
    4. 04_Assets/videos/{scene_id}*master*.mp4 (phiên bản cao nhất)
    5. File unversioned tương ứng
    """
    candidates = []
    # 1. 06_Exports: master_audio
    for p in EXPORTS_DIR.glob(f"{scene_id}_master_audio*.mp4"):
        if p.is_file() and p.stat().st_size > 1000:
            candidates.append(p)
    # 2. 06_Exports: cinematic_master
    for p in EXPORTS_DIR.glob(f"{scene_id}_cinematic_master*.mp4"):
        if p.is_file() and p.stat().st_size > 1000:
            candidates.append(p)
    # 3. 06_Exports: regular master
    for p in EXPORTS_DIR.glob(f"{scene_id}_master*.mp4"):
        if p.is_file() and p.stat().st_size > 1000:
            candidates.append(p)
    # 4. 04_Assets/videos: scene master
    for p in VIDEOS_DIR.glob(f"{scene_id}*master*.mp4"):
        if p.is_file() and p.stat().st_size > 1000:
            candidates.append(p)

    if not candidates:
        return None

    def sort_key(p: Path):
        m = re.search(r"_v(\d+)\.mp4$", p.name, re.IGNORECASE)
        v_num = int(m.group(1)) if m else 0
        lname = p.name.lower()
        if "master_audio" in lname:
            type_score = 4
        elif "cinematic_master" in lname:
            type_score = 3
        elif p.parent == EXPORTS_DIR:
            type_score = 2
        else:
            type_score = 1
        return (type_score, v_num, p.stat().st_mtime)

    candidates.sort(key=sort_key, reverse=True)
    return candidates[0]


def concat_scene_shots(
    scene_id: str,
    output_path: Optional[str] = None,
    crossfade_dur: float = 1.0,
    enable_critic: bool = True,
    mode: str = "boundary_smoothing"
) -> Optional[Path]:
    """
    Ghép nối tất cả các shot của Scene thành video Master liền mạch
    sử dụng AudioContinuityEngine.stitch_with_audio_crossfade làm engine chính
    (mặc định Mode A: boundary_smoothing với 30ms micro-fade curve=qsin)
    để bảo toàn 100% âm thanh AAC, loại bỏ giật cụt, không gây co rút timeline và chuẩn hóa -14 LUFS.
    Hỗ trợ Scene Gate thẩm định trước khi xuất xưởng và chuẩn đặt tên versioning _v<N>.mp4.
    """
    scene_shots = get_shots_for_scene(scene_id)
    if not scene_shots:
        print(f"[!] Không tìm thấy shot nào cho: {scene_id}")
        return None

    video_files = []
    for shot_id, _ in scene_shots:
        v = find_rendered_video(shot_id)
        if not v:
            print(f"[!] Thiếu video cho shot {shot_id}! Chưa thể ghép master.")
            return None
        video_files.append(v)

    # Thẩm định Scene Gate trước khi ghép nối Master
    if enable_critic and evaluate_scene_gate is not None:
        video_paths_str = [str(v) for v in video_files]
        scene_verdict = evaluate_scene_gate(scene_id, video_paths_str)
        print(f"\n🏛️ Antigravity Scene Gate ({scene_id}): Score = {scene_verdict.overall_score:.2f} | Action = {scene_verdict.suggested_action}")
        if not scene_verdict.approved:
            print(f"[!] Scene Gate từ chối ghép Master cho {scene_id}: {scene_verdict.critique_notes}")
            return None

    if output_path:
        target_out = Path(output_path)
    else:
        # Áp dụng chuẩn versioning _v<N>
        target_out = resolve_versioned_path(VIDEOS_DIR, f"{scene_id}_master")

    # Ưu tiên sử dụng AudioContinuityEngine
    if AudioContinuityEngine is not None:
        try:
            engine = AudioContinuityEngine()
            if mode == "flowkit_smart" and hasattr(engine, "stitch_flowkit_smart"):
                shot_entries = []
                for sid, sdata in scene_shots:
                    v_p = find_rendered_video(sid)
                    if v_p:
                        tt = classify_shot_take(sid, shot_data=sdata)
                        shot_entries.append({"video_path": str(v_p), "take_type": tt, "shot_id": sid})
                print(f"\n🎬 Đang ghép nối FlowKit Smart Concat ({len(shot_entries)} shots) -> {target_out.name}...")
                success = engine.stitch_flowkit_smart(
                    shot_entries,
                    str(target_out),
                    xfade_dur=0.5,
                    normalize_lufs=True
                )
                if success and target_out.exists():
                    print(f"[✓] GHÉP MASTER FLOWKIT THÀNH CÔNG: {target_out}")
                    print(f"    Dung lượng: {target_out.stat().st_size / (1024*1024):.2f} MB")
                    return target_out
            else:
                video_paths_str = [str(v) for v in video_files]
                print(f"\n🎬 Đang ghép nối {len(video_files)} shot bằng AudioContinuityEngine crossfade ({crossfade_dur}s, mode={mode}) -> {target_out.name}...")
                success = engine.stitch_with_audio_crossfade(
                    video_paths_str,
                    str(target_out),
                    crossfade_dur=crossfade_dur,
                    normalize_lufs=True,
                    mode=mode
                )
                if success and target_out.exists():
                    print(f"[✓] GHÉP MASTER THÀNH CÔNG: {target_out}")
                    print(f"    Dung lượng: {target_out.stat().st_size / (1024*1024):.2f} MB")
                    return target_out
                else:
                    print("[!] AudioContinuityEngine crossfade thất bại, kích hoạt fallback FFmpeg filter_complex concat...")
        except Exception as e:
            print(f"[!] Lỗi AudioContinuityEngine ({e}), chuyển sang fallback FFmpeg filter_complex concat...")

    # Fallback: Hard-cut FFmpeg filter_complex concat bảo toàn âm thanh kèm silence fallback
    ffmpeg_exe = get_ffmpeg()
    inputs = []
    filter_parts = []
    n = len(video_files)

    for i, v in enumerate(video_files):
        inputs.extend(["-i", str(v)])

    # Kiểm tra audio track cho từng clip để tránh crash khi gặp clip câm (-an)
    has_audio_list = []
    for v in video_files:
        has_a = True
        try:
            ffprobe_exe = get_ffprobe()
            probe_cmd = [
                ffprobe_exe, "-v", "error",
                "-show_entries", "stream=codec_type",
                "-of", "json", str(v)
            ]
            p_res = subprocess.run(probe_cmd, capture_output=True, text=True)
            if p_res.returncode == 0:
                p_data = json.loads(p_res.stdout)
                has_a = any(s.get("codec_type") == "audio" for s in p_data.get("streams", []))
        except Exception:
            has_a = True
        has_audio_list.append(has_a)

    for i, has_a in enumerate(has_audio_list):
        filter_parts.append(
            f"[{i}:v:0]scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=24[v{i}_norm]"
        )
        if has_a:
            filter_parts.append(f"[{i}:a]aresample=48000,aformat=sample_rates=48000:channel_layouts=stereo[a{i}]")
        else:
            filter_parts.append(f"aevalsrc=0:d=10.0:s=48000:c=stereo[a{i}]")

    v_concat = "".join([f"[v{i}_norm]" for i in range(n)])
    a_concat = "".join([f"[a{i}]" for i in range(n)])
    concat_filter = f"{';'.join(filter_parts)};{v_concat}concat=n={n}:v=1:a=0[v];{a_concat}concat=n={n}:v=0:a=1[a]"

    cmd = [
        ffmpeg_exe, "-y",
        *inputs,
        "-filter_complex", concat_filter,
        "-map", "[v]",
        "-map", "[a]",
        "-c:v", "libx264",
        "-crf", "18",
        "-preset", "slow",
        "-c:a", "aac",
        "-b:a", "192k",
        "-ar", "48000",
        str(target_out)
    ]

    print(f"\n🎬 Đang ghép nối {n} shot thành Master ({target_out.name}) qua Fallback Concat...")
    res = subprocess.run(cmd, capture_output=True, text=True)
    if res.returncode == 0 and target_out.exists():
        print(f"[✓] GHÉP MASTER THÀNH CÔNG: {target_out}")
        print(f"    Dung lượng: {target_out.stat().st_size / (1024*1024):.2f} MB")
        return target_out
    else:
        print(f"[!] Lỗi khi ghép video bằng FFmpeg:\n{res.stderr}")
        return None


def master_scene_audio(
    scene_id: str,
    bgm_path: Optional[str] = None,
    ambience_path: Optional[str] = None,
    output_path: Optional[str] = None,
    naming_style: str = "master_audio"
) -> bool:
    """
    Master âm thanh toàn diện cho Scene video theo tiêu chuẩn YouTube Green Dollar (-14 LUFS).
    Hỗ trợ kiến trúc 4-Stem (Stem 1: BGM, Stem 2: Ambience) và Two-Pass Linear Normalization.
    Tự động giải quyết tài nguyên âm thanh qua resolve_audio_asset().
    Hỗ trợ chuẩn đặt tên _master_audio_v*.mp4 hoặc _cinematic_master_v*.mp4.
    """
    # Tìm master video gần nhất (ưu tiên version cao nhất _v*)
    master_candidates = sorted(
        list(VIDEOS_DIR.glob(f"{scene_id}*master*.mp4")),
        key=lambda p: p.stat().st_mtime,
        reverse=True
    )
    if master_candidates:
        master_video = master_candidates[0]
    else:
        print(f"[!] Không tìm thấy master video cho: {scene_id}")
        return False

    if output_path:
        final_out = Path(output_path)
    else:
        prefix = f"{scene_id}_{naming_style}"
        final_out = resolve_versioned_path(EXPORTS_DIR, prefix)

    EXPORTS_DIR.mkdir(parents=True, exist_ok=True)

    if AudioContinuityEngine is None:
        print("[!] Lỗi: AudioContinuityEngine không khả dụng.")
        return False

    resolved_bgm = resolve_audio_asset(bgm_path)
    resolved_amb = resolve_audio_asset(ambience_path)

    print(f"\n🎵 BẮT ĐẦU MASTER ÂM THANH CHO: {scene_id} (Nguồn: {master_video.name})")
    engine = AudioContinuityEngine()

    # Sử dụng 4-Stem mixing nếu có BGM hoặc Ambience
    if resolved_bgm or resolved_amb:
        print(f"   Áp dụng 4-Stem Mixing: BGM={resolved_bgm.name if resolved_bgm else 'None'}, Ambience={resolved_amb.name if resolved_amb else 'None'}...")
        success = engine.mix_four_stems(
            video_path=str(master_video),
            output_path=str(final_out),
            stem1_bgm=str(resolved_bgm) if resolved_bgm else None,
            stem2_ambience=str(resolved_amb) if resolved_amb else None,
            target_lufs=-14.0,
            two_pass=True
        )
    else:
        # Chuẩn hóa âm lượng EBU R128 Two-Pass Linear
        success = engine.normalize_loudness(
            str(master_video),
            str(final_out),
            target_lufs=-14.0,
            target_tp=-1.0,
            two_pass=True
        )

    if success and final_out.exists():
        print(f"[✓] HOÀN TẤT MASTER ÂM THANH! Xuất bản phẩm tại: {final_out}")
        return True
    else:
        print(f"[!] Master âm thanh thất bại cho {scene_id}.")
        return False


def assemble_episode_master(
    episode_id: str = "ep01",
    scene_ids: Optional[List[str]] = None,
    output_path: Optional[str] = None,
    dry_run: bool = False,
    check_only: bool = False,
    normalize_final: bool = True
) -> Optional[Path]:
    """
    Ghép nối tất cả Scene Masters của một Episode thành Grand Feature Master hoàn chỉnh.
    Bảo toàn 100% âm thanh AAC 48kHz qua FFmpeg filter_complex concat ([v][a])
    và chuẩn hóa âm thanh điện ảnh chuẩn EBU R128 (-14 LUFS, TP -1.0 dBTP).

    Tham số:
        episode_id: Mã tập phim (mặc định: 'ep01').
        scene_ids: Danh sách scene_id tùy chọn. Nếu None, nạp 10 scenes chuẩn (Scenes 01-10).
        output_path: Đường dẫn file xuất tùy chỉnh. Nếu None, sinh versioned ep01_full_feature_master_v*.mp4.
        dry_run: Chỉ mô phỏng và in lệnh FFmpeg mà không chạy render.
        check_only: Kiểm tra tính sẵn sàng của các file scene masters.
        normalize_final: Thực hiện Pass 2 EBU R128 Loudnorm cho video thành phẩm.
    """
    if scene_ids is None:
        if episode_id.lower() == "ep01":
            scene_ids = EP01_CANONICAL_SCENES
        else:
            scene_ids = [f"{episode_id}_scene{i:02d}" for i in range(1, 11)]

    EXPORTS_DIR.mkdir(parents=True, exist_ok=True)

    if output_path:
        target_out = Path(output_path)
    else:
        target_out = resolve_versioned_path(EXPORTS_DIR, f"{episode_id.lower()}_full_feature_master")

    print("=" * 80)
    print(f"🎬 KIEU STORY AI CINEMA — ASSEMBLE EPISODE MASTER ({episode_id.upper()})")
    print(f"   Quy mô phân cảnh: {len(scene_ids)} Scenes")
    print(f"   Đích xuất: {target_out}")
    print("=" * 80)

    available_scenes = []
    missing_scenes = []

    for sc in scene_ids:
        m_file = resolve_scene_master(sc)
        if m_file and m_file.exists():
            available_scenes.append((sc, m_file))
            print(f"  [✓] {sc:<18} -> {m_file.name} ({m_file.stat().st_size / (1024*1024):.1f} MB)")
        else:
            missing_scenes.append(sc)
            print(f"  [✗] {sc:<18} -> CHƯA CÓ MASTER (Thiếu file)")

    print("-" * 80)
    print(f"Tổng cảnh: {len(scene_ids)} | Sẵn sàng: {len(available_scenes)} | Thiếu: {len(missing_scenes)}")

    if check_only:
        print("[✓] Chế độ kiểm tra (--check): Báo cáo trạng thái hoàn tất.")
        return target_out if not missing_scenes else None

    if dry_run:
        print("\n[*] Chế độ mô phỏng (--dry-run):")
        inputs_str = " ".join([f"-i {p.name}" for _, p in available_scenes[:3]]) + " ..."
        print(f"    Lệnh FFmpeg dự kiến: ffmpeg -y {inputs_str} -filter_complex ... {target_out.name}")
        return target_out

    if missing_scenes:
        print(f"\n[!] Không thể ghép nối toàn vẹn Episode Master khi thiếu {len(missing_scenes)} cảnh:")
        for sc in missing_scenes:
            print(f"    - {sc}")
        return None

    # Thực hiện ghép nối FFmpeg filter_complex concat
    ffmpeg_exe = get_ffmpeg()
    inputs = []
    filter_parts = []
    for i, (_, p) in enumerate(available_scenes):
        inputs.extend(["-i", str(p)])
        filter_parts.append(
            f"[{i}:v:0]scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=24[v{i}_norm];"
            f"[{i}:a:0]aresample=48000,aformat=sample_rates=48000:channel_layouts=stereo[a{i}_norm]"
        )

    n = len(available_scenes)
    v_a_inputs = "".join([f"[v{i}_norm][a{i}_norm]" for i in range(n)])
    concat_filter = f"{';'.join(filter_parts)};{v_a_inputs}concat=n={n}:v=1:a=1[v][a]"
    raw_target = target_out.parent / f"{target_out.stem}_raw{target_out.suffix}"

    cmd = [
        ffmpeg_exe, "-y",
        *inputs,
        "-filter_complex", concat_filter,
        "-map", "[v]",
        "-map", "[a]",
        "-c:v", "libx264",
        "-crf", "18",
        "-preset", "slow",
        "-c:a", "aac",
        "-b:a", "192k",
        "-ar", "48000",
        str(raw_target)
    ]

    print(f"\n🎬 Đang ghép nối {n} Scene Masters thành Feature Master...")
    res = subprocess.run(cmd, capture_output=True, text=True)
    if res.returncode != 0 or not raw_target.exists():
        print(f"[!] Lỗi khi ghép nối FFmpeg:\n{res.stderr}")
        return None

    print(f"[✓] Ghép thô thành công ({raw_target.name}).")

    # Chuẩn hóa âm thanh toàn diện EBU R128 Two-Pass Linear
    if normalize_final and AudioContinuityEngine is not None:
        print(f"[*] Đang chuẩn hóa âm lượng EBU R128 (-14 LUFS Two-Pass) cho Feature Master hoàn thiện...")
        engine = AudioContinuityEngine()
        norm_ok = engine.normalize_loudness(
            str(raw_target),
            str(target_out),
            target_lufs=-14.0,
            target_tp=-1.0,
            target_lra=9.0,
            two_pass=True
        )
        if norm_ok and target_out.exists():
            if raw_target.exists():
                try:
                    raw_target.unlink()
                except Exception as e:
                    print(f"[*] Ghi chú: File tạm {raw_target.name} được giữ lại do Windows lock: {e}")
            print(f"\n🎉 HOÀN TẤT EPISODE GRAND FEATURE MASTER: {target_out}")
            print(f"   Dung lượng: {target_out.stat().st_size / (1024*1024):.2f} MB")
            return target_out
        else:
            print("[!] Chuẩn hóa âm lượng thất bại, giữ lại bản raw.")
            return raw_target
    else:
        if raw_target.exists():
            shutil.move(str(raw_target), str(target_out))
        return target_out


def main():
    parser = argparse.ArgumentParser(
        description="THẬP NGŨ NIÊN - Cỗ máy điều phối sản xuất AI Cinema tự động"
    )
    parser.add_argument("--status", action="store_true", help="Xem báo cáo tiến độ sản xuất tổng quan")
    parser.add_argument("--episode", type=str, default=None, help="Chỉ định tập phim cụ thể (vd: ep01, ep02)")
    parser.add_argument("--list-shots", nargs="?", const="ALL", default=None, help="Liệt kê danh mục shot (tùy chọn lọc theo scene)")
    parser.add_argument("--render-shot", type=str, help="Render 1 shot đơn lẻ qua Muse.ai")
    parser.add_argument("--input-frame", type=str, default=None, help="Start Frame chỉ định rõ ràng cho shot")
    parser.add_argument("--prompt", type=str, default=None, help="Prompt ghi đè (nếu có)")
    parser.add_argument("--batch-scene", type=str, help="Render tự động toàn bộ shot của 1 scene theo chuỗi Head-Tail")
    parser.add_argument("--force", action="store_true", help="Bắt buộc render lại kể cả khi đã có video")
    parser.add_argument("--concat-scene", type=str, help="Ghép nối các shot đã render thành Master Scene (bảo toàn âm thanh)")
    parser.add_argument("--master-audio", type=str, help="Master âm thanh EBU R128 -14 LUFS cho Scene Master")
    parser.add_argument("--bgm", type=str, default=None, help="Đường dẫn file BGM cho master-audio")
    parser.add_argument("--ambience", type=str, default=None, help="Đường dẫn file Ambience (Stem 2) cho master-audio")
    parser.add_argument("--crossfade-dur", type=float, default=1.0, help="Thời gian crossfade âm thanh giữa các shot (giây)")
    parser.add_argument("--output", type=str, default=None, help="Đường dẫn file xuất tùy chỉnh")
    parser.add_argument("--reverse-video", type=str, default=None, help="Đảo ngược 1 file video (video + audio) bằng FFmpeg")
    parser.add_argument("--reverse", "--reverse-motion", dest="reverse_motion", action="store_true", default=None, help="Bắt buộc cờ reverse_motion khi render shot")
    parser.add_argument("--engine", type=str, default="auto", choices=["auto", "muse", "muse_api", "gradio_ltx"], help="Chỉ định engine render: auto (tự động theo góc máy), muse/muse_api (Muse2API Gateway cảnh toàn), gradio_ltx (cận cảnh / previs)")
    parser.add_argument("--assemble-episode", type=str, help="Ghép nối tất cả Scene Masters của 1 Episode thành Feature Master (vd: ep01)")
    parser.add_argument("--scenes", nargs="+", default=None, help="Danh sách cảnh tùy chỉnh cho assemble-episode")
    parser.add_argument("--check", action="store_true", help="Kiểm tra tính sẵn sàng các file master mà không thực thi")
    parser.add_argument("--dry-run", action="store_true", help="Mô phỏng quy trình ghép nối và in pipeline")
    parser.add_argument("--mode", type=str, default="boundary_smoothing", choices=["boundary_smoothing", "acrossfade", "micro_crossfade", "flowkit_smart"], help="Chế độ crossfade âm thanh")
    parser.add_argument("--pacing-3beat", action="store_true", help="Kích hoạt định dạng FlowKit Cinematic 3-Beat Shot Formula (0-3s, 3-6s, 6-10s)")
    parser.add_argument("--naming-style", type=str, default="master_audio", choices=["master_audio", "cinematic_master"], help="Kiểu đặt tên master audio xuất bản")

    args = parser.parse_args()

    if args.status:
        show_production_status(episode_id=args.episode)
    elif args.list_shots:
        scene = None if args.list_shots == "ALL" else args.list_shots
        list_shots_cmd(scene, episode_id=args.episode)
    elif args.reverse_video:
        reverse_video_file(args.reverse_video, output_video=args.output)
    elif args.render_shot:
        render_single_shot(
            args.render_shot, 
            args.input_frame, 
            args.prompt, 
            reverse_motion=args.reverse_motion, 
            engine=args.engine,
            pacing_3beat=args.pacing_3beat
        )
    elif args.batch_scene:
        batch_render_scene(
            args.batch_scene, 
            force=args.force, 
            engine=args.engine,
            pacing_3beat=args.pacing_3beat
        )
    elif args.assemble_episode:
        assemble_episode_master(
            episode_id=args.assemble_episode,
            scene_ids=args.scenes,
            output_path=args.output,
            dry_run=args.dry_run,
            check_only=args.check,
            normalize_final=True
        )
    elif args.concat_scene:
        concat_scene_shots(
            args.concat_scene,
            args.output,
            crossfade_dur=args.crossfade_dur,
            mode=args.mode
        )
    elif args.master_audio:
        master_scene_audio(
            args.master_audio,
            bgm_path=args.bgm,
            ambience_path=args.ambience,
            output_path=args.output,
            naming_style=args.naming_style
        )
    else:
        parser.print_help()


if __name__ == "__main__":
    main()


