"""scene_director 인덱스 드리프트 수정 — [수정 전 ↔ 수정 후] 대조 갤러리.

샷마다 보여주는 것
  ① 배정이 어떻게 바뀌었나 — 옛 VE 인물 → 새 VE 인물
  ② 수정 전 이미지 (스냅샷) ↔ 수정 후 이미지 (현재 recipe)
  ③ 후보 롤 전체도 함께 — 선정본만 보면 "T2I 가 못 그린다"로 오진하기 쉽다

수정 전 이미지는 `artifact/.../before/` 스냅샷이다. 원본은 recipe 자리에
그대로 두고 **복사**했으므로, 재생성이 recipe 를 덮어써도 옛 그림이 남는다.

`_sel` 이 없으면 후보 `_a` 로 대체한다. 재생성 여부는 **파일 mtime** 으로
가른다 — 스냅샷보다 새것이면 다시 그려진 것이다.

    .venv/bin/python build_drift_fix_gallery.py
"""
from __future__ import annotations

import html
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from app.core.config import settings  # noqa: E402

PROJ = "e716bafb-24bb-42b7-aea0-fdb383844ee8"
EPI = "d6a9aa85-b75e-400c-980c-4ee7e876a15b"
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "artifact/20260807_scene_director_drift"
BEFORE = OUT / "before"
RECIPE = ROOT / f"projects/{PROJ}/images/{EPI}/scene/recipe"
RECIPE_WEB = f"/projects/{PROJ}/images/{EPI}/scene/recipe"
BEFORE_WEB = "/artifact/20260807_scene_director_drift/before"
OLD_SHOT_DIRECTOR = OUT / "backup_before_rerun/shot_director.manifest.json"
# 재생성 결과 — `generate-image` 는 recipe 스템이 아니라 `scene/<uuid>.png` 로
# 저장한다. 처음엔 recipe 스템의 mtime 으로 재생성 여부를 봤는데 그 파일은
# 갱신되지 않아 8/8 성공을 "0/8" 로 표시했다(2026-08-07). 반환 경로를 쓴다.
REGEN_RESULT = OUT / "regen_result.json"

TARGETS = [
    (11, 3, "추적한 결함 — 정인우 샷에 VE 가 비어 김지후가 채워졌던 자리"),
    (15, 5, "육안 지목(룸미러 얼굴 절반) + VE 변화"),
    (88, 6, "육안 지목(총구·시선 방향) + VE 변화"),
    (7, 1, ""), (8, 1, ""), (9, 6, ""), (12, 10, ""), (17, 6, ""),
]

CSS = """
:root{--bg:#101319;--card:#191d26;--line:#2b313d;--fg:#e7eaf0;--dim:#98a1b3;
--old:#ff8f6b;--new:#3ddc84;--warn:#ffa94d}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.65 -apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo",sans-serif}
header{position:sticky;top:0;z-index:9;background:#0b0d12ee;backdrop-filter:blur(8px);
border-bottom:1px solid var(--line);padding:14px 20px}
h1{margin:0 0 6px;font-size:17px}
.sum{color:var(--dim);font-size:13px}.sum b{color:var(--fg)}
main{padding:18px 20px 80px;max-width:1700px;margin:0 auto}
.still{background:var(--card);border:1px solid var(--line);border-radius:12px;
padding:16px;margin-bottom:22px}
.hd{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;margin-bottom:6px}
.hd .id{font-size:16px;font-weight:700}
.tag{border-radius:5px;padding:2px 8px;font-size:11px;font-weight:700}
.t-new{background:var(--new);color:#101319}.t-old{background:#39404f;color:var(--dim)}
.note{font-size:13px;color:#ffd9a0;background:#2a2118;border-left:3px solid var(--warn);
padding:7px 11px;border-radius:0 6px 6px 0;margin:6px 0 12px}
.ve{display:flex;gap:14px;flex-wrap:wrap;font-size:13px;margin-bottom:12px}
.ve div{background:#0f131a;border:1px solid var(--line);border-radius:7px;padding:7px 12px}
.ve .k{font-size:11px;color:var(--dim);display:block}
.ve .o{color:var(--old)}.ve .n{color:var(--new);font-weight:700}
.cmp{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.cmp figure{margin:0}
.cmp img{width:100%;border-radius:8px;display:block;border:3px solid var(--line)}
.cmp .after img{border-color:var(--new)}
figcaption{font-size:12.5px;margin-top:6px;font-weight:600}
.sec{font-size:11px;letter-spacing:.08em;color:var(--dim);text-transform:uppercase;
margin:15px 0 8px;border-top:1px solid var(--line);padding-top:11px}
.rolls{display:flex;gap:10px;flex-wrap:wrap}
.rolls figure{width:240px;margin:0}
.rolls img{width:100%;border-radius:6px;display:block;border:2px solid var(--line)}
.miss{color:var(--dim);font-size:13px;padding:16px;border:1px dashed var(--line);
border-radius:8px;text-align:center}
@media(max-width:900px){.cmp{grid-template-columns:1fr}.rolls figure{width:100%}}
"""


def esc(x) -> str:
    return html.escape(str(x or ""))


def load_old_ve() -> dict:
    d = json.loads(OLD_SHOT_DIRECTOR.read_text("utf-8"))
    out = {}
    for sc in (d.get("data") or {}).get("scenes") or []:
        for sh in sc.get("shots") or []:
            out[(sc.get("scene_index"), sh.get("shot_index"))] = set(
                sh.get("visible_entity_ids") or [])
    return out


def load_new_ve() -> dict:
    p = (Path(settings.projects_dir) / PROJ / "checkpoints" / "episodes" / EPI
         / "shot_director" / "manifest.json")
    d = json.loads(p.read_text("utf-8"))
    out = {}
    for sc in (d.get("data") or {}).get("scenes") or []:
        for sh in sc.get("shots") or []:
            out[(sc.get("scene_index"), sh.get("shot_index"))] = set(
                sh.get("visible_entity_ids") or [])
    return out


def main() -> None:
    cp = Path(settings.projects_dir) / PROJ / "checkpoints" / "episodes" / EPI
    t2i = json.loads((cp / "entity_t2i" / "manifest.json").read_text("utf-8"))["data"]
    nm = {e["short_id"]: e["name"] for k in ("characters", "locations", "props")
          for e in t2i.get(k) or [] if e.get("short_id")}
    old, new = load_old_ve(), load_new_ve()
    regen: dict = {}
    if REGEN_RESULT.exists():
        for r in json.loads(REGEN_RESULT.read_text("utf-8")).get("ok_image") or []:
            if r.get("path"):
                regen[r["stem"]] = "/" + r["path"].lstrip("/")

    def chars(s):
        return sorted(nm.get(i, i) for i in s if i.startswith("C")) or ["(없음)"]

    P = ['<meta charset="utf-8">',
         "<title>scene_director 드리프트 수정 — 수정 전 ↔ 수정 후</title>",
         f"<style>{CSS}</style>", "<header>",
         "<h1>scene_director 인덱스 드리프트 수정 — 표본 샷 대조</h1>",
         '<div class="sum">전체 <b>255</b>샷 중 인물 배정이 바뀐 샷 <b>151</b>개(59%). '
         f'아래는 그중 <b>{len(TARGETS)}</b>샷 표본이다 — 수정 전 이미지는 재생성 직전 '
         '스냅샷이고, 원본은 자리에 그대로 두고 복사했다.</div></header><main>']

    n_regen = 0
    for si, shi, note in TARGETS:
        stem = f"S{si}sh{shi}"
        b_sel = BEFORE / f"{stem}_sel.png"
        if not b_sel.exists():
            b_sel = BEFORE / f"{stem}_a.png"
        after_web = regen.get(stem)
        n_regen += bool(after_web)
        tag = ('<span class="tag t-new">재생성됨</span>' if after_web
               else '<span class="tag t-old">아직 재생성 안 됨</span>')

        P.append(f'<section class="still"><div class="hd">'
                 f'<span class="id">{esc(stem)}</span>{tag}</div>')
        if note:
            P.append(f'<div class="note">{esc(note)}</div>')
        o, n = chars(old.get((si, shi), set())), chars(new.get((si, shi), set()))
        P.append(f'<div class="ve"><div><span class="k">수정 전 VE 인물</span>'
                 f'<span class="o">{esc(", ".join(o))}</span></div>'
                 f'<div><span class="k">수정 후 VE 인물</span>'
                 f'<span class="n">{esc(", ".join(n))}</span></div></div>')

        P.append('<div class="cmp">')
        if b_sel.exists():
            P.append(f'<figure><img src="{BEFORE_WEB}/{esc(b_sel.name)}" loading="lazy">'
                     f'<figcaption>수정 전 — {esc(", ".join(o))}</figcaption></figure>')
        else:
            P.append('<div class="miss">수정 전 스냅샷 없음</div>')
        if after_web:
            P.append(f'<figure class="after"><img src="{esc(after_web)}" loading="lazy">'
                     f'<figcaption>수정 후 — {esc(", ".join(n))}</figcaption></figure>')
        else:
            P.append('<div class="miss">수정 후 이미지가 아직 없다 — '
                     '재생성이 끝나지 않았거나 계약 위반으로 거부됐다</div>')
        P.append("</div>")

        rolls = sorted(RECIPE.glob(f"{stem}_[abc].png"))
        if rolls and False:
            P.append('<div class="sec">수정 후 후보 롤 전체</div><div class="rolls">')
            for r in rolls:
                P.append(f'<figure><img src="{RECIPE_WEB}/{esc(r.name)}'
                         f'?v={int(r.stat().st_mtime)}" loading="lazy">'
                         f'<figcaption>{esc(r.stem.split("_")[-1].upper())}</figcaption></figure>')
            P.append("</div>")
        P.append("</section>")

    P.append("</main>")
    OUT.mkdir(parents=True, exist_ok=True)
    (OUT / "index.html").write_text("\n".join(P), "utf-8")
    print(f"갤러리: {OUT / 'index.html'} · 재생성된 샷 {n_regen}/{len(TARGETS)}")


if __name__ == "__main__":
    main()
