#!/usr/bin/env python3
"""E2E 갤러리 v2 — 새 이미지 구분 + 생성 형태(실제 입력 참조 체인) 시각화. 커밋금지.

카드마다: 출력 이미지 + 생성 시각(KST) + NEW 배지 + 생성 모델/모드 +
실제 첨부 입력 이미지 썸네일(역할 한글 라벨) + 이전 버전 히스토리.
배경 plate 는 건물 그룹 섹션으로 묶고 anchor plate 를 명시한다.
"""
import json
import shutil
import subprocess
from collections import defaultdict
from datetime import datetime, timedelta
from pathlib import Path

import psycopg2

PID = "b0ad5c18-5140-4022-a2b0-805de0e5a385"
EID = "0d30302a-51c8-4ac9-98e2-6cd9180512bf"
ROOT = Path("/Users/manta/Documents/Projects/TheRoad-I1")
OUT = ROOT / "scratchpad/e2e_final_gallery"
OUT.mkdir(parents=True, exist_ok=True)
IMGDIR = OUT / "img"
IMGDIR.mkdir(exist_ok=True)
CP_DIR = ROOT / "projects" / PID / "checkpoints" / "episodes" / EID

# 이번 세션(W-M bgr force 재트리거 23:52 KST = 14:52 UTC) 이후 = NEW
NEW_CUTOFF = datetime.fromisoformat("2026-07-03T14:52:00+00:00")
# W-L: 야외 loc 실사 aerial establishing (위상 SOT) — background_chain 에 저장.
CHAIN_DIR = (ROOT / "projects" / PID / "episodes" / EID / "images"
             / "background_chain")

try:
    from PIL import Image
    HAVE_PIL = True
except Exception:
    HAVE_PIL = False


def parse_ts(s):
    if not s:
        return None
    try:
        return datetime.fromisoformat(s)
    except Exception:
        return None


def kst(dt):
    if dt is None:
        return "?"
    return (dt + timedelta(hours=9)).strftime("%m-%d %H:%M")


def resolve_src(fp):
    src = ROOT / fp
    if not src.exists():
        src = ROOT / "projects" / fp
    return src if src.exists() else None


_thumb_cache = {}


def thumb(fp, key, maxw):
    """fp → img/<key>_<src mtime>.jpg (다운스케일). 실패 시 None.

    ★파일명에 원본 mtime 을 접어 캐시버스팅 — 같은 key(bg_L10B01 등)라도
    plate 가 재렌더되면 새 파일명/URL. 이전 빌드의 '파일 있으면 skip' 로직이
    옛 렌더 썸네일을 영구히 보여주던 결함(사용자 7차 "이미지가 이전 그대로")
    의 근본 수정.
    """
    ck = (key, maxw)
    if ck in _thumb_cache:
        return _thumb_cache[ck]
    src = resolve_src(fp)
    if src is None:
        _thumb_cache[ck] = None
        return None
    try:
        _mt = int(src.stat().st_mtime)
    except OSError:
        _mt = 0
    out_name = f"{key}_{maxw}_{_mt}.jpg"
    out_path = IMGDIR / out_name
    rel = "img/" + out_name
    if not out_path.exists():
        ok = False
        if HAVE_PIL:
            try:
                im = Image.open(src).convert("RGB")
                if im.width > maxw:
                    im = im.resize((maxw, int(im.height * maxw / im.width)))
                im.save(out_path, "JPEG", quality=85)
                ok = True
            except Exception:
                ok = False
        if not ok:
            r = subprocess.run(
                ["sips", "-Z", str(maxw), str(src), "--out", str(out_path)],
                capture_output=True)
            if r.returncode != 0:
                try:
                    shutil.copy(src, out_path)
                except Exception:
                    _thumb_cache[ck] = None
                    return None
    _thumb_cache[ck] = rel
    return rel


ROLE_KO = {
    "background_render": ("배경 plate", "#7fb8ff"),
    "composition_guide": ("구도 가이드(스케치)", "#ffc46b"),
    "indoor_pose_guide": ("실내 포즈 가이드", "#6cd5ff"),
    "registered_pose_guide": ("등록 포즈 가이드", "#6cd5ff"),
    "immobilized_pose_guide": ("포즈 가이드(고정 피사체)", "#6cd5ff"),
    "scene_prev_frame": ("이전 샷 프레임", "#8be28b"),
    "previous_shot_same_room": ("이전 샷 프레임(같은 공간)", "#8be28b"),
    "immobilized_prev_frame": ("이전 샷 프레임(고정 피사체)", "#ffab7a"),
    "reference_face": ("얼굴 ref", "#d9a7ff"),
    "reference_prop": ("소품 ref", "#c9c37a"),
    "character_state_variant": ("상태 변형 ref", "#ff9bb5"),
    "dead_state_passport": ("사망 상태 passport", "#ff7a7a"),
    "composite": ("합성 ref", "#a7d0c0"),
    "zoom_continuity_crop": ("줌 연속성 crop", "#8be2d5"),
}


def role_ko(role):
    return ROLE_KO.get(role or "", (role or "참조", "#9aa"))


# ── DB 로드 ──
c = psycopg2.connect(host="localhost", user="theroad",
                     password="theroad_dev_2026", dbname="theroad")
cur = c.cursor()

cur.execute("""
  SELECT ia.id, ia.file_path, ia.still_id, ss.scene_index, ss.shot_index,
         ss.shot_description, ia.is_primary, ia.created_at,
         ia.generation_model, ia.pipeline_metadata_json, ia.disposition,
         ia.status
  FROM image_asset ia JOIN scene_still ss ON ss.id = ia.still_id
  WHERE ia.episode_id=%s AND ia.asset_type='scene'
  ORDER BY (ss.scene_index)::int, ss.shot_index, ia.created_at
""", (EID,))
scene_rows = cur.fetchall()

cur.execute("""
  SELECT id, file_path, variant_type, created_at, input_image_ids::text,
         status
  FROM image_asset WHERE episode_id=%s AND asset_type='chain_bg'
  ORDER BY variant_type
""", (EID,))
bg_rows = cur.fetchall()

cur.execute("""
  SELECT id, file_path, variant_type
  FROM image_asset WHERE episode_id=%s AND asset_type='floor_plan'
""", (EID,))
fp_rows = cur.fetchall()
fp_by_variant = {vt: fp for _id, fp, vt in fp_rows}

cur.execute(
    "SELECT id, short_id, name FROM entity_canon WHERE project_id=%s",
    (PID,))
canon_name = {i: f"{s} {n}" for i, s, n in cur.fetchall()}

# 참조 asset resolve 맵 (실제 첨부 입력들)
ref_ids = set()
for row in scene_rows:
    meta = json.loads(row[9] or "{}")
    for r in meta.get("actual_attached_refs") or []:
        if r.get("asset_id"):
            ref_ids.add(r["asset_id"])
asset_info = {}
if ref_ids:
    cur.execute("""
      SELECT id, file_path, asset_type, variant_type, pipeline_role,
             entity_id, still_id
      FROM image_asset WHERE id = ANY(%s)
    """, (list(ref_ids),))
    for i, fp, at, vt, pr, eid_, sid in cur.fetchall():
        asset_info[i] = {"fp": fp, "asset_type": at, "variant_type": vt,
                         "pipeline_role": pr, "entity_id": eid_,
                         "still_id": sid}

# 스틸 id → (scene, shot) 라벨 (이전 샷 프레임 입력 라벨용)
still_label = {}
for (aid, fp, sid, scn, sh, *_rest) in scene_rows:
    if sid and sid not in still_label:
        still_label[sid] = f"S{scn}sh{sh}"

# ── checkpoint: bg 렌더 형태(attached_ref_labels, anchor/모드) ──
bgr_cp = json.loads((CP_DIR / "background_render/manifest.json").read_text())
bgr_groups = bgr_cp["data"]["groups"]

# building 그룹 (classify cp 구조 필드 — mixed 그룹만 섹션화)
building_sections = []  # (group_id, [loc_ids])
try:
    cls_cp = json.loads(
        (CP_DIR / "background_classify/manifest.json").read_text())
    for g in cls_cp["data"].get("building_groups") or []:
        members = [m for m in (g.get("members") or []) if isinstance(m, dict)]
        indoor = [m for m in members if m.get("is_indoor") is True]
        outdoor = [m for m in members if m.get("is_indoor") is False]
        if indoor and outdoor:
            building_sections.append(
                (str(g.get("group_id")),
                 [str(m.get("loc_id")) for m in members]))
except Exception:
    pass

# 그룹별 anchor bid (checkpoint building_anchor_ref 역산)
anchor_of_group = {}
for bid, e in bgr_groups.items():
    ar = e.get("building_anchor_ref") or {}
    if ar.get("group_id") and ar.get("anchor_bg_id"):
        anchor_of_group[ar["group_id"]] = ar["anchor_bg_id"]

bg_asset_by_bid = {vt: (i, fp, parse_ts(ct), st)
                   for i, fp, vt, ct, _iids, st in bg_rows}


def bg_png_mtime(bid):
    ent = bg_asset_by_bid.get(bid)
    if not ent:
        return None
    src = resolve_src(ent[1])
    if src is None:
        return None
    return datetime.fromtimestamp(src.stat().st_mtime).astimezone()


def badge(txt, color):
    return (f'<span class=badge style="background:{color}22;'
            f'border-color:{color};color:{color}">{txt}</span>')


def input_cell(img, label, color, sub=""):
    i = f'<img src="{img}">' if img else '<div class=noimg>이미지 없음</div>'
    s = f'<div class=sub>{sub}</div>' if sub else ""
    return (f'<div class=in><{"div"} class=inbox>{i}</div>'
            f'<div class=inlab style="color:{color}">{label}</div>{s}</div>')


# ── 배경 plate 카드 ──
def bg_card(bid):
    e = bgr_groups.get(bid) or {}
    ent = bg_asset_by_bid.get(bid)
    if not ent or e.get("status") != "ok":
        return ""
    _aid, fp, _ct, _st = ent
    mt = bg_png_mtime(bid)
    is_new = mt is not None and mt.astimezone(NEW_CUTOFF.tzinfo) >= NEW_CUTOFF
    mode = e.get("shot_aware_plan_mode") or e.get("ref_used") or ""
    labels = (e.get("attached_reference_lineage") or {}).get(
        "attached_ref_labels") or []
    ar = e.get("building_anchor_ref") or {}
    is_anchor = any(a == bid for a in anchor_of_group.values())

    big = thumb(fp, f"bg_{bid}", 900)
    bs = ""
    if is_new:
        bs += badge("NEW · 이번 세션 재렌더", "#ffd24d")
    if is_anchor:
        bs += badge("★ 그룹 ANCHOR — 건물 시각 정체성 기준", "#ff8c5a")
    if e.get("is_reuse"):
        bs += badge(f"재사용 alias ← {e.get('reused_from_bg_id')}", "#9aa")

    ins = ""
    for lab in labels:
        kind, _, rid = lab.partition(":")
        if kind == "fp":
            img = thumb(fp_by_variant.get(rid, ""), f"fp_{rid}", 300) \
                if fp_by_variant.get(rid) else None
            ins += input_cell(img, "자기 도면(fp)", "#b9c46f", rid)
        elif kind == "bg":
            t = bg_asset_by_bid.get(rid)
            img = thumb(t[1], f"bg_{rid}", 300) if t else None
            ins += input_cell(img, "이전 체인 plate", "#7fb8ff", rid)
        elif kind == "building_anchor":
            t = bg_asset_by_bid.get(rid)
            img = thumb(t[1], f"bg_{rid}", 300) if t else None
            ins += input_cell(img, "★ 건물 anchor(같은 건물)", "#ff8c5a", rid)
        elif kind == "same_loc_anchor":
            t = bg_asset_by_bid.get(rid)
            img = thumb(t[1], f"bg_{rid}", 300) if t else None
            ins += input_cell(img, "◎ 같은 장소 anchor(W-K)", "#5ad0a0", rid)
        elif kind == "building_fp":
            img = thumb(fp_by_variant.get(rid, ""), f"fp_{rid}", 300) \
                if fp_by_variant.get(rid) else None
            ins += input_cell(img, "실내 도면(같은 건물)", "#ffa8d5", rid)
        elif kind == "aerial":
            ap = CHAIN_DIR / f"aerial_{rid}.png"
            img = thumb(str(ap), f"aerial_{rid}", 300) if ap.exists() else None
            ins += input_cell(img, "✈ 항공뷰 배치도 SOT(W-L·1순위)", "#7ee0ff", rid)
    if not ins:
        ins = '<div class=noref>text-only (참조 이미지 없음)</div>'

    mt_s = mt.strftime("%m-%d %H:%M") if mt else "?"
    return (f'<div class="card {"newcard" if is_new else ""}">'
            f'<div class=hd><b>{bid}</b> <span class=loc>{e.get("location_name") or e.get("location_id")}</span>'
            f' <span class=meta>{mt_s} · {mode}</span> {bs}</div>'
            f'<div class=flow><div class=ins>{ins}</div>'
            f'<div class=arrow>→</div>'
            f'<div class=out><img src="{big}"></div></div></div>')


# ── 스틸 카드 (씬별, primary + 히스토리) ──
by_still = defaultdict(list)
for row in scene_rows:
    by_still[row[2]].append(row)

scene_html = defaultdict(list)
new_still_count = 0
for sid, rows in by_still.items():
    rows_sorted = sorted(rows, key=lambda r: r[7] or "")
    primary = next((r for r in rows_sorted if r[6] == 1), rows_sorted[-1])
    (aid, fp, _sid, scn, sh, desc, _isp, ct, model, meta_j, _disp,
     _status) = primary
    ct_dt = parse_ts(ct)
    is_new = ct_dt is not None and ct_dt >= NEW_CUTOFF
    if is_new:
        new_still_count += 1
    meta = json.loads(meta_j or "{}")
    refs = meta.get("actual_attached_refs") or []

    big = thumb(fp, f"st_{aid[:8]}", 900)
    bs = badge("NEW · 이번 세션 redo", "#ffd24d") if is_new else ""

    ins = ""
    bg_stale = False
    for r in refs:
        rid = r.get("asset_id")
        role = r.get("role") or ""
        info = asset_info.get(rid) or {}
        img = thumb(info.get("fp", ""), f"ref_{(rid or '')[:8]}", 300) \
            if info.get("fp") else None
        lab, color = role_ko(role)
        sub = ""
        if info.get("asset_type") == "chain_bg":
            sub = info.get("variant_type") or ""
            pm = bg_png_mtime(sub) if sub else None
            if pm and ct_dt and pm.astimezone(ct_dt.tzinfo) > ct_dt:
                bg_stale = True
                sub += " (이후 재렌더됨)"
                # 현재 plate 파일로 썸네일 갱신됨 — 라벨로 구분
        elif info.get("entity_id"):
            sub = canon_name.get(info["entity_id"], "")
        elif info.get("still_id"):
            sub = still_label.get(info["still_id"], "")
        ins += input_cell(img, lab, color, sub)
    if not ins:
        ins = '<div class=noref>첨부 기록 없음(t2i-only)</div>'
    if bg_stale and not is_new:
        bs += badge("⚠ 배경 plate 가 이후 재렌더됨 — 스틸은 이전 plate 기준", "#ff7a7a")

    hist = ""
    others = [r for r in rows_sorted if r[0] != aid]
    for h in others[-3:]:
        himg = thumb(h[1], f"st_{h[0][:8]}", 240)
        tag = h[10] or ("rejected" if h[11] == "rejected" else "이전 버전")
        if himg:
            hist += (f'<div class=hin><img src="{himg}">'
                     f'<div class=hlab>{tag} · {kst(parse_ts(h[7]))}</div></div>')
    hist_html = (f'<div class=histrow><span class=hcap>이전 버전</span>{hist}</div>'
                 if hist else "")

    card = (f'<div class="card {"newcard" if is_new else ""}">'
            f'<div class=hd><b>S{scn}sh{sh}</b>'
            f' <span class=meta>{kst(ct_dt)} · {(model or "").replace("gemini-3.1-flash-image-preview","nb2")}</span> {bs}</div>'
            f'<div class=flow><div class=ins>{ins}</div>'
            f'<div class=arrow>→</div>'
            f'<div class=out><img src="{big}"></div></div>'
            f'{hist_html}'
            f'<div class=dsc>{(desc or "")[:120]}</div></div>')
    scene_html[scn].append((sh, card))

# ── 섹션 조립 ──
parts = []

# 배경: 건물 그룹 섹션 먼저
covered = set()
bg_sections_html = ""
for gid, locs in building_sections:
    bids = [b for b, e in bgr_groups.items()
            if (e.get("location_id") in locs)]
    if not bids:
        continue
    covered.update(bids)
    anchor = anchor_of_group.get(gid, "")
    cards = "".join(bg_card(b) for b in sorted(bids))
    loc_names = {bgr_groups[b].get("location_id"): 1 for b in bids}
    bg_sections_html += (
        f'<div class=scn><h3>건물 그룹 <code>{gid}</code>'
        f' <span class=n>같은 건물 — locations {", ".join(sorted(loc_names))}'
        f' · anchor={anchor or "없음"}</span></h3>{cards}</div>')
rest = [b for b in sorted(bgr_groups) if b not in covered
        and (bgr_groups[b].get("status") == "ok")]
rest_by_loc = defaultdict(list)
for b in rest:
    rest_by_loc[bgr_groups[b].get("location_id") or "?"].append(b)
rest_html = ""
for loc in sorted(rest_by_loc):
    cards = "".join(bg_card(b) for b in rest_by_loc[loc])
    nm = bgr_groups[rest_by_loc[loc][0]].get("location_name") or ""
    rest_html += (f'<div class=scn><h3>{loc} <span class=n>{nm}</span></h3>'
                  f'{cards}</div>')

stills_html = ""
for scn in sorted(scene_html, key=lambda x: int(x) if x is not None else 999):
    cards = "".join(cd for _sh, cd in sorted(scene_html[scn]))
    stills_html += (f'<div class=scn><h3>Scene {scn}'
                    f' <span class=n>({len(scene_html[scn])} shots)</span></h3>'
                    f'{cards}</div>')

new_bg_count = sum(
    1 for b in bgr_groups
    if (lambda m: m is not None and m.astimezone(NEW_CUTOFF.tzinfo) >= NEW_CUTOFF)(bg_png_mtime(b)))

# ── W-L: 실사 항공뷰 establishing 스트립 ──
_bgr_data = json.loads((CP_DIR / "background_render" / "manifest.json")
                       .read_text())["data"]
_loc_aerials = _bgr_data.get("location_aerials") or {}
_loc_name = {}
for _b, _e in bgr_groups.items():
    _l = _e.get("location_id")
    if _l and _l not in _loc_name:
        _loc_name[_l] = _e.get("location_name") or ""
aerial_cards = []
for _gid in sorted(_loc_aerials):
    _ae = _loc_aerials[_gid]
    _ae_locs = _ae.get("locs") or []
    _loc_names = " · ".join(
        f"{_l} {_loc_name.get(_l, '')}".strip() for _l in _ae_locs)
    if _ae.get("status") != "ok":
        aerial_cards.append(
            f'<div class=card><div class=hd><b>{_gid}</b> '
            f'<span class=loc>{_loc_names}</span> '
            f'<span class=meta>aerial 생성 실패 — 기존 fp 경로 fail-safe'
            f'</span></div></div>')
        continue
    _ap = CHAIN_DIR / f"aerial_{_gid}.png"
    _big = thumb(str(_ap), f"aerial_{_gid}", 900) if _ap.exists() else ""
    _fpu = " · 실내 fp 정합(I2I)" if _ae.get("building_fp_used") else ""
    aerial_cards.append(
        f'<div class="card newcard"><div class=hd><b>✈ aerial_{_gid}</b> '
        f'<span class=loc>{_loc_names}</span> '
        f'<span class=meta>같은 장소 그룹 통합 배치도(top-down site plan) · '
        f'그룹 야외 bg 1순위 ref{_fpu}</span></div>'
        f'<div class=out><img src="{_big}"></div></div>')
aerial_html = "".join(aerial_cards)

html = f"""<html><head><meta charset=utf-8><title>금월도 E2E 갤러리 v2 — 생성 형태</title>
<style>
body{{background:#0d0d0d;color:#ddd;font-family:sans-serif;margin:14px}}
h1{{font-size:20px}} h2{{font-size:16px;color:#ffd24d;border-bottom:1px solid #333;padding-bottom:6px;margin-top:28px}}
h3{{margin:18px 0 6px;border-top:1px solid #2a2a2a;padding-top:10px;font-size:14px}}
.n{{color:#888;font-size:12px;font-weight:normal}} code{{color:#ffa}}
.card{{background:#161616;border:1px solid #2c2c2c;border-radius:8px;padding:10px;margin:10px 0}}
.newcard{{border-color:#ffd24d;box-shadow:0 0 0 1px #ffd24d55}}
.hd{{font-size:13px;margin-bottom:8px}} .loc{{color:#9ac}} .meta{{color:#777;font-size:11px}}
.badge{{border:1px solid;border-radius:10px;padding:1px 8px;font-size:10.5px;margin-left:6px}}
.flow{{display:flex;align-items:center;gap:10px;flex-wrap:wrap}}
.ins{{display:flex;gap:8px;flex-wrap:wrap;max-width:46%}}
.in{{width:130px}} .inbox img{{width:100%;border:1px solid #333;border-radius:4px}}
.inlab{{font-size:10.5px;margin-top:2px}} .sub{{font-size:10px;color:#888}}
.noimg{{width:100%;height:70px;border:1px dashed #444;border-radius:4px;color:#555;font-size:10px;display:flex;align-items:center;justify-content:center}}
.noref{{color:#666;font-size:11px}}
.arrow{{color:#ffd24d;font-size:22px}}
.out img{{width:520px;max-width:52vw;border:1px solid #444;border-radius:6px}}
.histrow{{display:flex;gap:8px;align-items:center;margin-top:8px;opacity:.55}}
.hcap{{font-size:10.5px;color:#999}} .hin{{width:110px}} .hin img{{width:100%;border:1px solid #333;border-radius:4px}}
.hlab{{font-size:9.5px;color:#999}}
.dsc{{font-size:11px;color:#999;margin-top:6px}}
.legend{{background:#161616;border:1px solid #2c2c2c;border-radius:8px;padding:10px 14px;font-size:12.5px;line-height:1.7}}
</style></head><body>
<h1>금월도 E2E 갤러리 v2 — 어떤 이미지가 새 것이고, 무엇으로 만들어졌나</h1>
<div class=legend>
<b style="color:#ffd24d">이번 세션 신규 (2026-07-03 21:29 이후 · 8차 정정)</b>: <b>W-L 야외 기준 재편(7차 정정)</b> — 야외 loc 5곳(L03/L08/L10/L13/L17)에 <b>같은 장소 그룹당 통합 배치도(실내 fp 와 같은 도면 언어, top-down site plan) 1장</b>을 생성해 위상 SOT 로 삼고, 야외 배경 plate 는 평면도(fp) ref 를 제외하고 <b>항공뷰를 1순위 ref</b> 로 받습니다(L10 의 fp 분열 무해화). 배경 plate <b>{new_bg_count}장 재렌더</b> + 스틸 <b>{new_still_count}장 redo</b> — 노란 테두리 + NEW 배지.<br>
각 카드는 <b>[실제 첨부된 입력 이미지들] → [출력]</b> 형태로 그 이미지가 <b>무엇을 참조해 생성됐는지</b>를 보여줍니다 (DB 실측 lineage, 추정 아님).<br>
<span style="color:#ff8c5a">★ 그룹 ANCHOR</span> = 건물 그룹의 시각 정체성 기준 plate — 같은 건물의 다른 plate 들이 이 렌더를 참조합니다.
<span style="color:#ff7a7a">⚠ 배지</span> = 이 스틸 생성 후 배경 plate 가 재렌더되어 현재 plate 와 어긋날 수 있음(스틸 자체는 보존).<br>
입력 라벨: <span style="color:#7ee0ff">✈ 항공뷰 배치도 SOT(W-L·1순위)</span> · <span style="color:#7fb8ff">배경 plate</span> · <span style="color:#ffc46b">구도 가이드</span> · <span style="color:#6cd5ff">포즈 가이드</span> · <span style="color:#8be28b">이전 샷 프레임</span> · <span style="color:#d9a7ff">얼굴 ref</span> · <span style="color:#c9c37a">소품 ref</span> · <span style="color:#ff9bb5">상태 변형</span> · <span style="color:#b9c46f">자기 도면(fp)</span> · <span style="color:#ffa8d5">실내 도면(같은 건물)</span> · <span style="color:#ff8c5a">건물 anchor</span> · <span style="color:#5ad0a0">같은 장소 anchor(W-K)</span>
</div>
<h2>⓪ W-L 항공뷰 배치도(야외 fp) — 야외 loc 위상 SOT (1장/loc)</h2>
{aerial_html or '<p class=n>항공뷰 없음</p>'}
<h2>① 배경 plate — 건물 그룹 (W-K 같은 장소 체이닝 대상)</h2>
{bg_sections_html or '<p class=n>mixed 건물 그룹 없음</p>'}
<h2>② 배경 plate — 기타 location</h2>
{rest_html}
<h2>③ 씬 스틸 (샷별 현재 이미지 + 입력 + 이전 버전)</h2>
{stills_html}
</body></html>"""
(OUT / "index.html").write_text(html)
print(f"gallery v2 -> {OUT}/index.html")
print(f"  배경 {len(bgr_groups)} (신규 {new_bg_count}) / 스틸 {len(by_still)} (redo {new_still_count})")
