#!/usr/bin/env python3
"""s34 전체 샷 스틸 런 (2026-07-12, 실험 전용·커밋 금지).

v24i 체인: v24 i2 콘티(실사영화 스토리보드+REALIZE 원칙) 6장
 → 그리드 크롭(백색 패널 제외, 28패널, panels24i/)
 → LLM이 샷별 '시각적 관련 앞쪽 샷' 판정(기존 판정 재사용, prev 참조)
 → nb2 스틸 순차 생성 (참조=콘티 패널[참조일 뿐, 그대로 실사화 금지
   강조]+prev 스틸+VE 엔티티: 캐릭터 composite+소품 reference_face —
   맵·배경 참조 없음, REALIZE 원칙 주입, stills24i/)
 → stills_run24i.html 새 갤러리.
이전 런(nb2 콘티 기반 panels24/stills24/stills_run24.html)은 보존.

사용: backend/.venv/bin/python s34_stillrun.py --only <judge|crop|gen|html>
     [--groups ...]
"""
import argparse
import html as _html
import json
import subprocess
import sys
from pathlib import Path

HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
import forest_lib as F  # noqa: E402

BASE = HERE
CONTI_VER = "conti24"  # 크롭 소스 콘티 시트 버전
CONTI_ENG = "i2"       # 콘티 엔진 (i2 콘티 → nb2 실사)
OUTP = BASE / "out" / "conti" / "panels24i"
# v24s: LLM이 샷별 콘티 참조 필요 여부 판정 → 쉬운 구도는 콘티 제거.
# 갤러리에 참조 구성·프롬프트 전문 기록. (v24i 산출물은 보존)
OUTS = BASE / "out" / "conti" / "stills24s"
PAGE = BASE / "stills_run24s.html"
PLAN_CONTI = "s34_conti_v2"
PLAN_RUN = "s34_stillrun_v1"
P5 = "8ad94c03-5b33-4d51-83d0-a6e4106e9c02"
DATA = json.load(open(BASE / "pipeline_doc" / "shot_loc_data.json"))
SHOT = {(s["scene"], s["shot"]): s for s in DATA["shots"]}

GROUPS = json.loads(json.dumps({  # (표시명, 샷 목록) — s34 콘티와 동일
    "forest": ["S1sh1", "S1sh4", "S3sh3"],
    "busstop": ["S15sh2", "S15sh6"],
    "rooftop_ext": ["S10sh6", "S10sh7", "S11sh4", "S11sh5", "S13sh4", "S13sh6"],
    "rooftop_int": ["S12sh11", "S12sh21", "S14sh5", "S18sh11", "S25sh1", "S25sh5"],
    "mart": ["S8sh3", "S8sh5", "S19sh5", "S19sh6", "S20sh1", "S20sh3"],
    "police": ["S16sh3", "S21sh7", "S21sh10", "S21sh12", "S22sh1"],
}))
GRID = {"forest": (2, 2), "busstop": (2, 1), "rooftop_ext": (3, 2),
        "rooftop_int": (3, 2), "mart": (3, 2), "police": (3, 2)}

# 그룹 장소 서술(콘티 v24와 동일) — 콘티 제거 샷의 장소 앵커.
# 스틸 프롬프트에 장소 텍스트가 없으면 콘티 패널이 유일한 장소 단서라,
# 콘티 제거 시 장소 이탈 실측(S3sh3 숲→도시 거리) → LOCATION lock 상시 주입.
PLACE = {
    "forest": "a dense Korean mountain forest with thick undergrowth",
    "busstop": "a small seaside bus stop with a bench facing the sea at"
               " night on a quiet Korean coastal road",
    "rooftop_ext": "the exterior of an old Korean multi-family villa:"
                   " narrow alley and small yard at ground level, an added"
                   " exterior steel staircase climbing the wall, and a"
                   " rooftop with a small rooftop room (steel front door,"
                   " one lit window), a water tank, a clothesline and a"
                   " low parapet",
    "rooftop_int": "the interior of a small Korean rooftop home: living"
                   " room with a small window, kitchenette and dining"
                   " table; a bedroom with a bed against one wall and a"
                   " curtained window; a tiny tiled bathroom with a mirror",
    "mart": "a mid-size Korean supermarket: shelved sales floor inside;"
            " the entrance area outside with a bicycle rack and one"
            " wall-mounted CCTV camera above; a small back security"
            " office with CCTV monitors",
    "police": "a modest Korean provincial police station: a detectives'"
              " office with desks, monitors and a doorway; and the street"
              " right in front of the building",
}


def _key(tag):
    si, shi = tag[1:].split("sh")
    return (int(si), int(shi))


def _time_of(tag):
    h = SHOT[_key(tag)]["heading"] or ""
    if "/ N" in h or "밤" in h:
        return "night"
    if "해질" in h or "해 질" in h:
        return "dusk, just after sunset"
    if "동틀" in h:
        return "dawn"
    if "소나기" in h:
        return "heavy rain shower, daytime"
    if "실내" in h:
        return "interior ambient light"
    return "daytime"


def stage_judge(groups_filter):
    plan = F.load_plan(PLAN_RUN) if (BASE / "plans" / f"{PLAN_RUN}.json").exists() else {}
    prev_map = plan.setdefault("prev", {})
    SYS = "\n".join([
        "당신은 콘티 연속성 판정가다. 같은 장소 그룹의 샷 목록(순서대로)과",
        "씬 원문을 보고, 각 샷마다 '시각적으로 관련된 앞쪽 샷'이 있는지",
        "판정하라. 시각적 관련 = 같은 공간·연속된 액션·같은 인물 구도가",
        "이어져 앞 샷의 생성 이미지를 참조로 붙이면 일관성에 도움이 되는",
        "경우. 다른 시간대·다른 서브 공간·연결 단서가 없으면 null.",
        "앞쪽 샷은 반드시 목록에서 자기보다 앞에 있는 샷이어야 한다.",
        "각 샷마다 prev(태그 또는 null)와 reason_ko(한 구절)를 반환.",
    ])
    SCHEMA = {"type": "object", "properties": {"items": {"type": "array",
        "items": {"type": "object", "properties": {
            "shot": {"type": "string"},
            "prev": {"type": ["string", "null"]},
            "reason_ko": {"type": "string"}},
            "required": ["shot", "prev", "reason_ko"]}}},
        "required": ["items"]}
    for gkey, tags in GROUPS.items():
        if groups_filter and gkey not in groups_filter:
            continue
        scene_ids = sorted({_key(t)[0] for t in tags})
        scenes_txt = "\n\n".join(
            f"[씬 {si}] {DATA['scenes'][str(si)]['heading']}\n"
            + DATA['scenes'][str(si)]['text'] for si in scene_ids)
        shots_txt = "\n".join(
            f"{t}: {SHOT[_key(t)]['desc']}" for t in tags)
        res = F.llm(f"s34run_prev_{gkey}", SYS,
                    f"씬 원문:\n{scenes_txt}\n\n샷 목록(순서):\n{shots_txt}",
                    SCHEMA)
        prev_map[gkey] = {it["shot"]: {"prev": it["prev"],
                                       "reason": it["reason_ko"]}
                         for it in res["items"]}
        print(f"[judge] {gkey}:",
              {k: v["prev"] for k, v in prev_map[gkey].items()})
    F.save_plan(PLAN_RUN, plan)


def stage_conti_need(groups_filter):
    """LLM 판정: 샷별로 콘티(구도) 참조가 필요한지 — 쉬운 구도는 제거."""
    plan = F.load_plan(PLAN_RUN)
    need_map = plan.setdefault("conti_need", {})
    mv_all = F.load_plan(PLAN_CONTI)["movement"]
    SYS = "\n".join([
        "당신은 촬영 현장의 콘티 운용 판정가다. 각 샷의 텍스트와 동선",
        "분석을 보고, 이미지 생성 시 스토리보드(콘티) 패널을 구도",
        "참조로 첨부할 필요가 있는지 판정하라.",
        "콘티 필요(true) = 구도를 글만으로 오해하기 쉬운 경우: 여러",
        "인물의 동선·위치 관계가 얽힘, 특수한 카메라 각도/깊이 관계,",
        "연속 액션의 방향 유지가 중요, 프레임 내 배치가 스토리텔링에",
        "결정적.",
        "콘티 불필요(false) = 글만으로 충분히 명확한 쉬운 구도: 단일",
        "인물 클로즈업/상반신, 단순 인서트(손·사물), 정적인 단순 배치,",
        "표준적인 대화 구도 등.",
        "각 샷마다 need_conti(bool)와 reason_ko(한 구절)를 반환하라.",
    ])
    SCHEMA = {"type": "object", "properties": {"items": {"type": "array",
        "items": {"type": "object", "properties": {
            "shot": {"type": "string"},
            "need_conti": {"type": "boolean"},
            "reason_ko": {"type": "string"}},
            "required": ["shot", "need_conti", "reason_ko"]}}},
        "required": ["items"]}
    for gkey, tags in GROUPS.items():
        if groups_filter and gkey not in groups_filter:
            continue
        lines = []
        for t in tags:
            m = mv_all.get(gkey, {}).get(t, {})
            if isinstance(m, str):
                m = {"movement": m}
            lines.append(f"{t}: {SHOT[_key(t)]['desc']}\n  동선: "
                         + m.get("movement", "(없음)"))
        res = F.llm(f"s34run24s_contineed_{gkey}", SYS,
                    "샷 목록:\n" + "\n".join(lines), SCHEMA)
        need_map[gkey] = {it["shot"]: {"need": it["need_conti"],
                                       "reason": it["reason_ko"]}
                          for it in res["items"]}
        print(f"[conti_need] {gkey}:",
              {k: v["need"] for k, v in need_map[gkey].items()})
    F.save_plan(PLAN_RUN, plan)


# 시트 이상 레이아웃 수동 오버라이드 (육안 매핑)
# conti24 i2: 6장 전부 등분 준수 — 단 forest는 수풀 질감을 거터로
# 오인(h=289)해 등분 강제(equal)
SHEET_OVERRIDES = {
    "forest": {"grid": (2, 2), "equal": True, "cells": {
        "S1sh1": (0, 0), "S1sh4": (1, 0), "S3sh3": (0, 1)}},
}


def stage_crop():
    from PIL import Image
    OUTP.mkdir(parents=True, exist_ok=True)
    for gkey, tags in GROUPS.items():
        sheet = Image.open(
            BASE / "out" / "conti"
            / f"{CONTI_VER}_{gkey}_{CONTI_ENG}.png").convert("RGB")
        # nb2 여백 자동 트림 (거의-흰색 마진 제거 후 그리드 크롭 — 대충)
        mask = sheet.convert("L").point(lambda v: 255 if v < 242 else 0)
        bbox = mask.getbbox()
        if bbox:
            pad = 4
            bbox = (max(0, bbox[0] - pad), max(0, bbox[1] - pad),
                    min(sheet.size[0], bbox[2] + pad),
                    min(sheet.size[1], bbox[3] + pad))
            sheet = sheet.crop(bbox)
        W, H = sheet.size
        ov = SHEET_OVERRIDES.get(gkey)
        cols, rows = ov["grid"] if ov else GRID[gkey]

        # 거터 라인 감지: 시트를 가로/세로로 관통하는 어두운 직선 탐색.
        # 실패 시 등분 fallback (대충 크롭 허용).
        import numpy as np
        arr = np.asarray(sheet.convert("L"))
        dark = arr < 120

        def _lines(profile, n_expect, total):
            # profile: 축별 어두운 픽셀 비율. 0.55+ 구간을 라인 후보로.
            cand = [i for i, v in enumerate(profile) if v > 0.55]
            groups = []
            for i in cand:
                if groups and i - groups[-1][-1] <= 6:
                    groups[-1].append(i)
                else:
                    groups.append([i])
            centers = [sum(g) // len(g) for g in groups
                       if total * 0.05 < sum(g) / len(g) < total * 0.95]
            if len(centers) == n_expect:
                return centers
            return [total * (k + 1) // (n_expect + 1)
                    for k in range(n_expect)]  # 등분 fallback

        if ov and ov.get("equal"):
            v_lines = [W * (k + 1) // cols for k in range(cols - 1)]
            h_lines = [H * (k + 1) // rows for k in range(rows - 1)]
        else:
            v_lines = _lines(dark.mean(axis=0), cols - 1, W) if cols > 1 else []
            h_lines = _lines(dark.mean(axis=1), rows - 1, H) if rows > 1 else []
        xs = [0] + v_lines + [W]
        ys = [0] + h_lines + [H]
        for i, tag in enumerate(tags):
            if ov:
                c, r = ov["cells"][tag]
            else:
                c, r = i % cols, i // cols
            box = (xs[c] + 3, ys[r] + 3, xs[c + 1] - 3, ys[r + 1] - 3)
            sheet.crop(box).save(OUTP / f"{tag}.png")
        print(f"[crop] {gkey}: {len(tags)}패널 v={v_lines} h={h_lines}"
              + (" (override)" if ov else ""))


def _psql(sql):
    return subprocess.run(
        ["psql", "-h", "localhost", "-U", "theroad", "-d", "theroad",
         "-tAc", sql],
        capture_output=True, text=True, check=True,
        env={"PGPASSWORD": "theroad_dev_2026", "PATH": "/usr/bin:/bin:/opt/homebrew/bin:/usr/local/bin"}).stdout


def _char_refs():
    """5회차 VE 캐릭터 → composite 경로 (DB read-only)."""
    sql = ("SELECT DISTINCT ON (ec.short_id) ec.short_id, ec.name,"
           " ia.file_path FROM image_asset ia "
           "JOIN entity_canon ec ON ec.id::text=ia.entity_id::text "
           f"WHERE ia.project_id='{P5}' AND ec.entity_type='character' "
           "AND ia.pipeline_role='reference_composite' "
           "ORDER BY ec.short_id, ia.created_at DESC")
    m = {}
    for line in _psql(sql).strip().split("\n"):
        if not line:
            continue
        sid, name, path = line.split("|")
        m[sid] = (name, Path("/Users/manta/Documents/Projects/TheRoad-I1") / path)
    return m


def _prop_refs():
    """5회차 VE 소품 → reference_face 경로 (DB read-only)."""
    sql = ("SELECT DISTINCT ON (ec.short_id) ec.short_id, ec.name,"
           " ia.file_path FROM image_asset ia "
           "JOIN entity_canon ec ON ec.id::text=ia.entity_id::text "
           f"WHERE ia.project_id='{P5}' AND ec.entity_type='prop' "
           "AND ia.pipeline_role='reference_face' "
           "ORDER BY ec.short_id, ia.created_at DESC")
    m = {}
    for line in _psql(sql).strip().split("\n"):
        if not line:
            continue
        sid, name, path = line.split("|")
        m[sid] = (name, Path("/Users/manta/Documents/Projects/TheRoad-I1") / path)
    return m


def _ve_ids():
    """샷별 VE — 배경(L) 제외 전 엔티티(C/P) short_id 목록."""
    sql = ("SELECT scene_index, shot_index, visible_entities_json FROM"
           f" scene_still WHERE project_id='{P5}' AND is_selected")
    m = {}
    for line in _psql(sql).strip().split("\n"):
        if not line:
            continue
        si, shi, ve = line.split("|", 2)
        try:
            ids = [e["short_id"] for e in json.loads(ve)
                   if str(e.get("short_id", ""))[:1] in ("C", "P")]
        except Exception:
            ids = []
        m[(int(si), int(shi))] = ids
    return m


# Gemini IMAGE_SAFETY 우회 — 명시 고어 어휘만 완곡화 (시각 의도 유지)
_SOFTEN = [("피투성이가 된", "힘없이 무너져 미동이 없는"),
           ("핏자국", "어두운 얼룩"), ("피 묻은", "얼룩진"),
           ("피로 그린 듯한", "짙은 붉은"),
           ("blood-soaked", "dark-stained"), ("bloodied", "dark-stained"),
           ("bloodstained", "stained"), ("bloody", "dark-stained"),
           ("blood", "dark stains"),
           ("lifeless", "utterly motionless, collapsed"),
           ("corpse", "motionless figure")]


def _soften(t):
    for a, b in _SOFTEN:
        t = t.replace(a, b)
    return t


REALIZE_STILL = "\n".join([
    "REALIZE FIGURATIVE LANGUAGE AS A LIVE-ACTION SHOT: the Korean shot",
    "text may describe characters metaphorically, figuratively or with",
    "exaggeration. Photograph what a real movie camera would actually",
    "record on a physical set — exaggerated or figurative impressions",
    "become realistic staging choices (lighting, distance, angle,",
    "wardrobe), not literal fantasy imagery.",
    "EVERY CHARACTER IS A HUMAN BEING: unless the story explicitly",
    "features non-human or virtual beings (as in science fiction or",
    "fantasy), every character — however indirectly, vaguely or",
    "figuratively the text describes them — IS a real human. When the",
    "text gives no direct visual description of a person, IMAGINE one",
    "and still show them as a concrete, fully-formed human being:",
    "always render the human form to the maximum extent — build,",
    "posture, face, hands, clothing — never reduce a person to a shape,",
    "blob, solid silhouette or abstract mass.",
])


def stage_gen(groups_filter):
    plan = F.load_plan(PLAN_RUN)
    prev_map = plan["prev"]
    need_map = plan.get("conti_need", {})
    meta = plan.setdefault("gen_meta", {})
    mv_all = F.load_plan(PLAN_CONTI)["movement"]
    chars = _char_refs()
    props = _prop_refs()
    ve = _ve_ids()
    # VE 공백 fallback: 같은 씬 선택 샷들의 VE 합집합
    scene_union = {}
    for (si, shi), ids in ve.items():
        scene_union.setdefault(si, set()).update(ids)
    OUTS.mkdir(parents=True, exist_ok=True)
    for gkey, tags in GROUPS.items():
        if groups_filter and gkey not in groups_filter:
            continue
        for tag in tags:
            out = OUTS / f"{tag}_nb2.png"
            if out.exists():
                print(f"[gen] {tag} skip(exists)")
                continue
            s = SHOT[_key(tag)]
            m = mv_all.get(gkey, {}).get(tag, {})
            if isinstance(m, str):
                m = {"movement": m, "figures": ""}
            pj = prev_map.get(gkey, {}).get(tag, {})
            prev_tag = pj.get("prev")
            nd = need_map.get(gkey, {}).get(tag, {"need": True,
                                                  "reason": "판정 없음"})
            refs = []
            if nd["need"]:
                refs.append((
                    "STORYBOARD PANEL — a REFERENCE ONLY, never the"
                    " target: use it loosely for camera framing, figure"
                    " placement and depth order. Do NOT photorealize"
                    " this drawing as-is — the SHOT TEXT and the other"
                    " references are authoritative; re-stage the moment"
                    " naturally as a real photograph. Never copy the"
                    " sketch's line style, paper texture, borders,"
                    " simplified geometry or drawing errors.",
                    OUTP / f"{tag}.png"))
            prev_used = None
            if prev_tag and (OUTS / f"{prev_tag}_nb2.png").exists():
                prev_used = prev_tag
                refs.append((
                    "PREVIOUS SHOT STILL — a visually related earlier shot"
                    " of this same place: the location's look, materials,"
                    " fixed features, lighting mood and each person's"
                    " clothing are LOCKED to this photo; never copy its"
                    " camera framing.", OUTS / f"{prev_tag}_nb2.png"))
            ve_ids = ve.get(_key(tag), [])
            if not ve_ids:
                ve_ids = sorted(scene_union.get(_key(tag)[0], set()))
            char_names, prop_names = [], []
            for cid in ve_ids:
                if cid in chars:
                    name, p = chars[cid]
                    if p.exists():
                        char_names.append(name)
                        refs.append((
                            f"CHARACTER REFERENCE — {name}: the exact"
                            " person appearing in this shot; match face,"
                            " hair and build exactly.", p))
                elif cid in props:
                    name, p = props[cid]
                    if p.exists():
                        prop_names.append(name)
                        refs.append((
                            f"PROP REFERENCE — {name}: the exact object"
                            " appearing in this shot; match its look,"
                            " material and wear exactly.", p))
            parts = [
                "Create ONE FINAL photorealistic live-action film still of"
                " the moment below — contemporary South Korea, 2026; all"
                " people are Korean unless stated. TIME OF DAY (lock): "
                + _time_of(tag) + ".",
                f"SHOT TEXT (authoritative, Korean): {_soften(s['desc'])}",
                f"LOCATION (lock): {PLACE[gkey]}. The shot takes place"
                " here — pick the sub-area of this location that the shot"
                " text implies.",
            ]
            if nd["need"]:
                parts.append("\n".join([
                    "THE STORYBOARD PANEL IS ONLY A REFERENCE: it is a",
                    "rough pre-production sketch, not the image to",
                    "reproduce. Take from it only the shot's rough",
                    "composition — framing, where figures sit, near/far",
                    "order. Everything else (real-world detail, materials,",
                    "light, anatomy, environment richness) must come from",
                    "the shot text and the photographic references,",
                    "re-staged as if actually filmed on a real set. If the",
                    "sketch conflicts with the shot text or looks",
                    "simplified/wrong, FOLLOW THE TEXT, not the sketch.",
                ]))
            parts.append(REALIZE_STILL)
            if m.get("movement"):
                parts.append("MOVEMENT (follow exactly): "
                             + _soften(m["movement"]))
            if m.get("figures"):
                parts.append("FIGURES — size & depth (follow exactly): "
                             + _soften(m["figures"]))
            if char_names:
                parts.append(
                    "PEOPLE: the SHOT TEXT alone decides whether any person"
                    " is visible in this shot. IF a person appears, they"
                    " must be one of the referenced people ("
                    + ", ".join(char_names)
                    + ") matched exactly to their reference photo — never"
                    " anyone else, and never add a person the shot text"
                    " does not show.")
            else:
                parts.append("No people appear unless the shot text itself"
                             " says so.")
            parts.append("No text, captions, watermarks or annotations"
                         " anywhere.")
            prompt_full = "\n\n".join(parts)
            F.img_nb2(f"s34run24s_{tag}_nb2", prompt_full, refs,
                      aspect_ratio="16:9", out_path=out)
            meta[tag] = {
                "conti_used": nd["need"], "conti_reason": nd["reason"],
                "prev_used": prev_used, "prompt": prompt_full,
                "refs": [{"label": lab,
                          "path": (str(p.relative_to(BASE))
                                   if str(p).startswith(str(BASE))
                                   else str(p))}
                         for lab, p in refs],
            }
            F.save_plan(PLAN_RUN, plan)
            print(f"[gen] {tag} 완료 (conti={nd['need']},"
                  f" prev={prev_used}, chars={char_names},"
                  f" props={prop_names})")


def stage_html():
    import shutil
    plan = F.load_plan(PLAN_RUN)
    prev_map = plan.get("prev", {})
    need_map = plan.get("conti_need", {})
    meta = plan.get("gen_meta", {})
    REFD = BASE / "out" / "conti" / "refs24s"
    REFD.mkdir(parents=True, exist_ok=True)

    def esc(t):
        return _html.escape(t or "")

    def _ref_kind(label):
        if label.startswith("STORYBOARD"):
            return "콘티 패널 (구도 참조 전용)"
        if label.startswith("PREVIOUS"):
            return "PREV 스틸 (장소 룩·의상 잠금)"
        if label.startswith("CHARACTER"):
            return "캐릭터 " + label.split("—", 1)[1].split(":", 1)[0].strip()
        if label.startswith("PROP"):
            return "소품 " + label.split("—", 1)[1].split(":", 1)[0].strip()
        return label[:30]

    def _ref_rel(path_s):
        if not path_s.startswith("/"):
            return path_s  # 이미 BASE 상대
        src = Path(path_s)
        dst = REFD / src.name
        if not dst.exists():
            shutil.copy(src, dst)
        return f"out/conti/refs24s/{src.name}"

    secs = []
    NAME = {"forest": "숲속", "busstop": "해안가 버스정류장",
            "rooftop_ext": "옥탑 단지 외부", "rooftop_int": "옥탑방 내부",
            "mart": "대형마트", "police": "경찰서"}
    for gkey, tags in GROUPS.items():
        figs = ""
        for tag in tags:
            pj = prev_map.get(gkey, {}).get(tag, {})
            nd = need_map.get(gkey, {}).get(tag, {})
            mt = meta.get(tag, {})
            still_rel = f"out/conti/stills24s/{tag}_nb2.png"
            conti_used = mt.get("conti_used", nd.get("need"))
            badge = ("<span class=on>콘티 사용</span>" if conti_used
                     else "<span class=off>콘티 제거</span>")
            ref_items = ""
            for r in mt.get("refs", []):
                rel = _ref_rel(r["path"])
                ref_items += (
                    f"<figure class=ref><a href='{rel}' target=_blank>"
                    f"<img src='{rel}' loading=lazy></a><figcaption>"
                    f"{esc(_ref_kind(r['label']))}</figcaption></figure>")
            if not ref_items:
                ref_items = "<span class=note>참조 이미지 없음</span>"
            ref_details = "".join(
                f"<li><b>{esc(_ref_kind(r['label']))}</b> — "
                f"<span class=note>{esc(r['label'])}</span> "
                f"<code>{esc(r['path'])}</code></li>"
                for r in mt.get("refs", []))
            figs += (
                f"<div class=shot><h3>{tag} {badge}"
                f"<span class=note> — 콘티 판정: {esc(nd.get('reason', ''))}"
                f" · prev: {esc(str(pj.get('prev') or '없음'))}"
                + (f" ({esc(pj.get('reason', ''))})" if pj.get("prev") else "")
                + "</span></h3>"
                f"<div class=note style='margin:2px 0 8px'>"
                f"{esc(SHOT[_key(tag)]['desc'])}</div>"
                f"<div class=row><figure><a href='{still_rel}'"
                f" target=_blank><img src='{still_rel}' loading=lazy></a>"
                f"<figcaption>nb2 스틸</figcaption></figure>"
                f"<div class=refs><div class=note>참조 이미지"
                f" ({len(mt.get('refs', []))}장):</div>"
                f"<div class=refrow>{ref_items}</div></div></div>"
                f"<details><summary>참조 상세 + 프롬프트 전문</summary>"
                f"<ul>{ref_details}</ul>"
                f"<pre>{esc(mt.get('prompt', '(기록 없음)'))}</pre>"
                f"</details></div>")
        secs.append(f"<section><h2>{esc(NAME[gkey])} <span class=k>"
                    f"({gkey})</span></h2>{figs}</section>")
    PAGE.write_text(f"""<!DOCTYPE html>
<html lang=ko><head><meta charset=utf-8>
<meta name=viewport content="width=device-width, initial-scale=1">
<title>s34 v24s 스틸 런 — LLM 콘티 선별+참조·프롬프트 전문 기록</title>
<style>
body {{ margin:0; padding:24px; background:#0f1216; color:#e6e6e6;
       font:14px/1.6 -apple-system,'Apple SD Gothic Neo',sans-serif; }}
h1 {{ font-size:20px; }} h2 {{ font-size:17px; margin:34px 0 8px;
     border-bottom:1px solid #333; padding-bottom:5px; }}
.k {{ color:#8a939e; font-size:13px; font-weight:400; }}
.row {{ display:flex; gap:14px; align-items:flex-start; margin:14px 0;
        flex-wrap:wrap; }}
figure {{ margin:0; }} figcaption {{ color:#c9d2dc; font-size:12.5px;
          max-width:640px; }}
img {{ max-width:640px; width:100%; border-radius:8px;
      border:1px solid #2a2f36; }}
img.sm {{ max-width:300px; }}
.note {{ color:#8a939e; }}
.box {{ background:#161b22; border:1px solid #2a2f36; border-radius:8px;
       padding:12px 16px; margin:12px 0; }}
.shot {{ border-top:1px dashed #2a2f36; padding:12px 0; }}
h3 {{ font-size:15px; margin:6px 0; }}
.on {{ background:#1e3a2a; color:#7ee2a8; border-radius:5px;
      padding:1px 8px; font-size:12px; margin-left:6px; }}
.off {{ background:#3a2a1e; color:#e2b57e; border-radius:5px;
       padding:1px 8px; font-size:12px; margin-left:6px; }}
.refs {{ max-width:640px; }}
.refrow {{ display:flex; gap:8px; flex-wrap:wrap; }}
figure.ref img {{ max-width:150px; }}
figure.ref figcaption {{ font-size:11px; max-width:150px; }}
code {{ background:#161b22; padding:0 4px; border-radius:4px;
       font-size:11px; }}
details {{ margin:6px 0; }} summary {{ color:#9ecbff; cursor:pointer; }}
pre {{ background:#161b22; border:1px solid #2a2f36; padding:10px;
      white-space:pre-wrap; font-size:12px; }}
</style></head><body>
<h1>s34 v24s 스틸 런 — 28샷 (LLM 콘티 선별: 쉬운 구도는 콘티 참조
제거 → nb2 스틸)</h1>
<div class=box><b>콘티 선별</b>: 샷별로 LLM(GPT-5.6 Sol)이 "구도를
글만으로 오해하기 쉬운가"를 판정 — 다인물 동선·특수 앵글·깊이 관계는
<span class=on>콘티 사용</span>, 단일 인물 클로즈업·단순 인서트·정적
배치는 <span class=off>콘티 제거</span>. 나머지 참조는 동일: prev 스틸
(시각 관련 앞쪽 샷, 장소 룩·의상 잠금) + VE 전 엔티티(캐릭터 composite
+ 소품 reference_face, 배경 L 제외) + REALIZE 원칙 + 씬 헤딩 시간 잠금.
각 샷 카드에 <b>사용된 참조 이미지 전부와 프롬프트 전문</b>을 기록.
비교: <a href='stills_run24i.html'>v24i(전샷 콘티)</a> ·
<a href='stills_run24.html'>v24(nb2 콘티)</a>.</div>
{''.join(secs)}
</body></html>""")
    print(f"[html] {PAGE}")


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--only", required=True,
                    choices=["judge", "conti_need", "crop", "gen", "html"])
    ap.add_argument("--groups", default="")
    a = ap.parse_args()
    gf = [g for g in a.groups.split(",") if g]
    if a.only == "judge":
        stage_judge(gf)
    elif a.only == "conti_need":
        stage_conti_need(gf)
    elif a.only == "crop":
        stage_crop()
    elif a.only == "gen":
        stage_gen(gf)
    else:
        stage_html()
