#!/usr/bin/env python3
"""s37 — 옥탑 관련 23샷 전면 재구축 (2026-07-12, 실험 전용·커밋 금지).

사용자 설계(하드코딩·하드 프롬프트 절대 금지 — 구조·크기·규모는 LLM 이
씬 원문 전문에서 스스로 추론):

실외(8샷 = 옥탑외부 5 + 철제계단 1 + 골목 2):
  ①ext_prompt  LLM 이 전체 관련 씬 원문으로 건물 전체 실사 마스터 t2i
               프롬프트 저작(근거 목록 포함) → ②ext_photos nb2 3롤
  ③ext_judge  GPT/Gemini VLM 0-10 합산 선택(동점=Gemini — s29 계약 재사용)
  ④ext_fix    양 VLM 결함 취합 → nb2 i2i 수정(0건=생략) = 기본 이미지
  ⑤ext_bg_prompts LLM 이 샷별 배경 플레이트 t2i 프롬프트 저작
  ⑥ext_bgs    nb2, 참조=[기본 이미지] — 샷별 배경 8장
  ⑦ext_sketch i2, 참조=[배경 8장 동시] → 8패널 분할 스케치 1장

실내(15샷):
  ⑧fp_spec    LLM 이 실내 씬 원문에서 공간 구성 추론 → FP 도면 프롬프트
  ⑨fp_img     i2 평면도(재투영 매체 계약)
  ⑩int_plan   LLM 이 그리는 순서 판단: 뿌리 플레이트부터 순서·커버 샷 배정
               (전체 실내 샷 정보 전달)
  ⑪int_plates nb2 체이닝 — 뿌리=[FP], 이후=[FP+뿌리+직전]
  ⑫int_sketch i2, 참조=[모든 플레이트] → ≤8패널 스케치 2장(8+7)
  ⑬crop / ⑭html 갤러리

사용: backend/.venv/bin/python s37_rooftop_rebuild.py --only <stage>
"""
import argparse
import html as _html
import sys
from pathlib import Path

HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
import forest_lib as F  # noqa: E402
import s34_conti_sheet as C  # noqa: E402 — 콘티 v24 계약 재사용
import s35_fullrun as R  # noqa: E402 — 샷/씬 데이터·그룹 재사용
import s29_top_v2 as T  # noqa: E402 — VLM 판정·수정 계약 재사용

BASE = HERE
OUT = BASE / "out" / "s37"
OUTP = OUT / "panels"
PAGE = BASE / "s37_rooftop_rebuild.html"
PLAN = "s37_rooftop_v1"
S35_PLAN = "s35_full_v1"

EXT_GROUPS = ["rooftop_room_exterior", "exterior_metal_staircase",
              "residential_alley_courtyard"]
INT_GROUP = "rooftop_room_interior"

MASTER_CAND = [("A", "ext_master_a.png"), ("B", "ext_master_b.png"),
               ("C", "ext_master_c.png")]
MASTER_FIXED = "ext_master_fixed.png"
FP_FILE = "int_fp.png"


def _s35():
    return F.load_plan(S35_PLAN)


def _shots_of(plan35, gkeys):
    gmap = {g["key"]: g for g in plan35["groups"]}
    out = []
    for gk in gkeys:
        out += gmap[gk]["shots"]
    return out


def _scene_text(si):
    sc = R.DATA["scenes"][str(si)]
    return f"[씬 {si}] {sc['heading']}\n{sc['text']}"


def _scenes_block(tags):
    sids = sorted({R._key(t)[0] for t in tags})
    return "\n\n".join(_scene_text(si) for si in sids)


def _shots_block(tags):
    return "\n".join(f"{t}: {R.SHOT[R._key(t)]['desc']}" for t in tags)


def _movement(plan35):
    mv = {}
    for gk in EXT_GROUPS + [INT_GROUP]:
        mv.update(plan35.get("movement", {}).get(gk, {}))
    return mv


def _grid_n(n):
    if n <= 6:
        return C._grid(n)
    return (8,
            "8 panels made by THREE vertical cuts at exactly 25%, 50% and"
            " 75% of the width and ONE horizontal cut at exactly 50% of the"
            " height — a 4-column x 2-row grid, all eight panels exactly"
            " equal", "1536x1024", "16:9")


# ── 실외 ──────────────────────────────────────────────

def stage_ext_prompt():
    plan35 = _s35()
    ext_tags = _shots_of(plan35, EXT_GROUPS)
    int_tags = _shots_of(plan35, [INT_GROUP])
    all_tags = ext_tags + int_tags
    SYS = "\n".join([
        "당신은 영화 미술감독 겸 로케이션 설계자다. 한 건물(주인공이 사는",
        "곳)이 나오는 모든 씬의 원문 전문을 읽고, 그 건물 '전체'가 한",
        "프레임에 담기는 실사 로케이션 마스터 스틸 1장을 만들 이미지 생성",
        "프롬프트(영어)를 저작하라. 이 마스터는 이후 모든 샷 배경의 기준",
        "이미지가 된다.",
        "규칙:",
        "1) 건물의 층수·크기·규모·공간 구성·고정 지형지물(출입 동선,",
        "   상하 이동 수단, 지붕 위 구조물, 지상부와의 관계 등)은 오직 씬",
        "   원문의 근거로만 추론하라. 인물의 이동·행동·시선, 공간 사용",
        "   방식이 크기·구조의 단서다. 실내 씬의 공간 사용도 건물 외형",
        "   규모 추론에 반영하라.",
        "2) 근거 없는 부분은 그 지역·유형의 전형으로 최소한만 보충하고,",
        "   원문과 모순되게 발명하지 마라.",
        "3) 카메라: 건물 전체와 그 주변 맥락(지상 접근부까지)이 다 보이는",
        "   하나의 시점을 스스로 정하라.",
        "4) 마스터는 중립 상태: 사람 없음, 읽히는 텍스트 없음, 특정",
        "   사건의 순간 상태물 없음. 시간대는 중립 주간.",
        "5) 배경 나라·시대는 원문을 따른다.",
        "출력: inferred_facts_ko(추론한 구조·규모 사실 각각에 근거 씬/문장",
        "명시), master_prompt_en(완결된 t2i 프롬프트 — 추론한 규모·구조가",
        "프롬프트 안에 스스로 서술되게), camera_ko(선택한 시점과 이유).",
    ])
    SCHEMA = {"type": "object", "additionalProperties": False, "properties": {
        "inferred_facts_ko": {"type": "array", "items": {"type": "string"}},
        "master_prompt_en": {"type": "string"},
        "camera_ko": {"type": "string"}},
        "required": ["inferred_facts_ko", "master_prompt_en", "camera_ko"]}
    user = ("건물 관련 씬 원문 전문:\n\n" + _scenes_block(all_tags)
            + "\n\n이 건물이 나오는 샷 목록(참고):\n" + _shots_block(all_tags))
    res = F.llm("s37_ext_prompt", SYS, user, SCHEMA)
    plan = F.load_plan(PLAN) if (BASE / "plans" / f"{PLAN}.json").exists() \
        else {}
    plan["ext_master"] = res
    plan["ext_tags"] = ext_tags
    plan["int_tags"] = int_tags
    F.save_plan(PLAN, plan)
    print("[ext_prompt] 근거", len(res["inferred_facts_ko"]), "건")
    for f_ in res["inferred_facts_ko"]:
        print("  -", f_)


def stage_ext_photos():
    plan = F.load_plan(PLAN)
    p = plan["ext_master"]["master_prompt_en"]
    OUT.mkdir(parents=True, exist_ok=True)
    for lab, fn in MASTER_CAND:
        F.img_nb2(f"s37_ext_master_{lab.lower()}", p, [],
                  aspect_ratio="16:9", out_path=OUT / fn)
    print("[ext_photos] nb2 3롤 완료")


def stage_ext_judge():
    plan = F.load_plan(PLAN)
    parts = [{"type": "text",
              "text": ("THE PROMPT (all three candidates were generated"
                       " from this):\n"
                       + plan["ext_master"]["master_prompt_en"])}]
    for label, fn in MASTER_CAND:
        f = OUT / fn
        assert f.exists(), f"후보 없음: {f}"
        parts.append({"type": "text", "text": f"Candidate {label}:"})
        parts.append(F.png_data_url(f))
    judge = {}
    for model, tag in (("gpt", "s37_ext_judge_gpt"),
                       ("gemini-pro", "s37_ext_judge_gemini")):
        judge[model] = F.llm(tag, T.JUDGE_SYSTEM, parts, T.JUDGE_SCHEMA,
                             model=model)
        print(f"judge[{model}]: winner={judge[model]['winner']}")
    totals = {lab: sum(
        next(v["score"] for v in judge[m]["verdicts"] if v["label"] == lab)
        for m in ("gpt", "gemini-pro")) for lab, _ in MASTER_CAND}
    best = max(totals.values())
    tied = [lab for lab, t in totals.items() if t == best]
    gem_rank = judge["gemini-pro"]["ranking"]
    selected = min(tied, key=lambda lab: gem_rank.index(lab)
                   if lab in gem_rank else 99)
    plan.update({"ext_judge": judge, "ext_totals": totals,
                 "ext_selected": selected,
                 "ext_selected_file": dict(MASTER_CAND)[selected]})
    F.save_plan(PLAN, plan)
    print(f"[ext_judge] totals={totals} selected={selected}")


def stage_ext_fix():
    plan = F.load_plan(PLAN)
    src = OUT / plan["ext_selected_file"]
    parts = [{"type": "text",
              "text": ("THE PROMPT (the photograph was generated from"
                       " this):\n"
                       + plan["ext_master"]["master_prompt_en"])},
             {"type": "text", "text": "Photograph to examine:"},
             F.png_data_url(src)]
    critique = {}
    for model, tag in (("gpt", "s37_ext_critique_gpt"),
                       ("gemini-pro", "s37_ext_critique_gemini")):
        critique[model] = F.llm(tag, T.CRITIQUE_SYSTEM, parts,
                                T.CRITIQUE_SCHEMA, model=model)
        print(f"critique[{model}]: issues={len(critique[model]['issues'])}")
    plan["ext_critique"] = critique
    fix_lines = [f"- ({title}) {i['fix_en']}"
                 for model, title in (("gpt", "GPT VLM"),
                                      ("gemini-pro", "Gemini VLM"))
                 for i in critique[model]["issues"]]
    if not fix_lines:
        plan["ext_fixed_file"] = plan["ext_selected_file"]
        plan["ext_fix_skipped"] = True
        F.save_plan(PLAN, plan)
        print("[ext_fix] 결함 0 — 수정 생략")
        return
    p = "\n\n".join([T.FIX_HEAD,
                     "ISSUES TO FIX:\n" + "\n".join(fix_lines),
                     T.PRESERVE_TAIL])
    plan["ext_fix_prompt"] = p
    plan["ext_fixed_file"] = MASTER_FIXED
    F.save_plan(PLAN, plan)
    F.img_nb2("s37_ext_master_fixed", p, [(T.FIX_LABEL, src)],
              aspect_ratio="16:9", out_path=OUT / MASTER_FIXED)
    print("[ext_fix] 수정본 완료")


def stage_ext_bg_prompts():
    plan = F.load_plan(PLAN)
    ext_tags = plan["ext_tags"]
    SYS = "\n".join([
        "당신은 영화 배경(플레이트) 설계자다. 기준이 되는 건물 마스터",
        "이미지의 생성 프롬프트와, 씬 원문 전문, 그리고 각 샷의 텍스트를",
        "읽고 — 샷마다 그 샷의 카메라에 필요한 '배경 플레이트' 이미지",
        "생성 프롬프트(영어)를 저작하라.",
        "규칙:",
        "1) 배경 플레이트 = 그 샷의 카메라 위치·방향에서 본 장소만. 사람",
        "   없음, 읽히는 텍스트 없음, 사건 순간 상태물 없음(빈 세트).",
        "2) 마스터 이미지가 참조로 함께 첨부된다 — 프롬프트는 '첨부된",
        "   마스터 사진의 그 건물/그 장소'임을 명시하고, 어느 부분을",
        "   어느 방향에서 보는지 마스터 기준으로 서술하라.",
        "3) 샷 텍스트가 요구하는 시간대가 있으면 그 시간대(씬 헤딩 참조),",
        "   없으면 씬 헤딩 시간대.",
        "4) 건물 구조·지형지물은 씬 원문 근거를 벗어나 발명하지 마라.",
        "각 샷마다: shot(태그), bg_prompt_en, view_ko(어느 부분을 어느",
        "방향에서·한 구절).",
    ])
    SCHEMA = {"type": "object", "additionalProperties": False, "properties": {
        "items": {"type": "array", "items": {
            "type": "object", "additionalProperties": False, "properties": {
                "shot": {"type": "string"},
                "bg_prompt_en": {"type": "string"},
                "view_ko": {"type": "string"}},
            "required": ["shot", "bg_prompt_en", "view_ko"]}}},
        "required": ["items"]}
    user = ("마스터 이미지 생성 프롬프트:\n"
            + plan["ext_master"]["master_prompt_en"]
            + "\n\n관련 씬 원문 전문:\n\n" + _scenes_block(ext_tags)
            + "\n\n샷 목록(각각 배경 플레이트 프롬프트 저작):\n"
            + _shots_block(ext_tags))
    res = F.llm("s37_ext_bg_prompts", SYS, user, SCHEMA)
    got = {it["shot"] for it in res["items"]}
    missing = [t for t in ext_tags if t not in got]
    if missing:
        raise SystemExit(f"배경 프롬프트 누락: {missing}")
    plan["ext_bg"] = {it["shot"]: it for it in res["items"]}
    F.save_plan(PLAN, plan)
    print(f"[ext_bg_prompts] {len(res['items'])}건")


MASTER_REF_LABEL = ("LOCATION MASTER PHOTOGRAPH — the exact building and"
                    " grounds where this background takes place: its"
                    " architecture, massing, materials and fixed features"
                    " are spatial truth; render the requested view OF THIS"
                    " PLACE. Never copy this photo's camera or crop.")


def stage_ext_bgs():
    plan = F.load_plan(PLAN)
    master = OUT / plan["ext_fixed_file"]
    for tag in plan["ext_tags"]:
        it = plan["ext_bg"][tag]
        F.img_nb2(f"s37_ext_bg_{tag}", it["bg_prompt_en"],
                  [(MASTER_REF_LABEL, master)], aspect_ratio="16:9",
                  out_path=OUT / f"bg_{tag}.png")
        print(f"[ext_bgs] {tag}")


SKETCH_REF_NOTE = "\n".join([
    "BACKGROUND PHOTOGRAPHS (attached in panel order): the N-th attached",
    "photograph is the REAL location background of PANEL N. Draw each",
    "panel's environment faithfully FROM its own photograph — the same",
    "architecture, fixed features and spatial layout, translated into",
    "monochrome pencil storyboard linework — then stage the figures per",
    "that panel's shot text and movement. If a panel's shot text implies",
    "a slightly different framing than its photograph, keep the place",
    "identical and adjust only the framing.",
])


def stage_ext_sketch():
    plan = F.load_plan(PLAN)
    plan35 = _s35()
    mv = _movement(plan35)
    tags = plan["ext_tags"]
    keys = [R._key(t) for t in tags]
    slots, layout, size, _ar = _grid_n(len(tags))
    lines = []
    for i, (si, shi) in enumerate(keys, 1):
        s = R.SHOT[(si, shi)]
        line = f"PANEL {i}: {s['desc']}"
        m = mv.get(f"S{si}sh{shi}")
        if m:
            line += f"\n  MOVEMENT (follow exactly): {m['movement']}"
            if m.get("figures"):
                line += ("\n  FIGURES — apparent size & depth order"
                         f" (follow exactly): {m['figures']}")
        line += f"\n  BACKGROUND: attached photograph #{i}."
        lines.append(line)
    parts = [
        C.HEAD.format(layout=layout),
        "THE LOCATION (same building in all panels): the place shown in"
        " the attached photographs.",
        C.PURPOSE, C.REALIZE, C.GEOMETRY, C.CONSISTENCY, SKETCH_REF_NOTE,
        "PANELS (one shot per panel, Korean shot text):",
        "\n".join(lines),
    ]
    if slots > len(tags):
        parts.append(C.BLANK.format(n_shots=len(tags), slots=slots))
    parts.append(C.SEAL.format(
        numbers=", ".join(str(i) for i in range(1, len(tags) + 1))))
    p = "\n\n".join(parts)
    plan["ext_sketch"] = {"file": "sketch_ext.png", "shots": tags,
                          "slots": slots, "prompt": p}
    F.save_plan(PLAN, plan)
    refs = [OUT / f"bg_{t}.png" for t in tags]
    F.img_gpt("s37_ext_sketch", p, refs=refs, size=size,
              out_path=OUT / "sketch_ext.png")
    print(f"[ext_sketch] {len(tags)}패널 완료 (refs {len(refs)}장)")


# ── 실내 ──────────────────────────────────────────────

FP_MEDIUM = "\n".join([
    "OUTPUT MEDIUM: a flat 2D architectural FLOOR PLAN DRAWING on plain",
    "drawing paper — NOT a photograph, NOT a 3D rendering. TRUE",
    "ORTHOGRAPHIC PLAN seen from DIRECTLY ABOVE (90 degrees). Absolutely",
    "NO 3D: no perspective, no axonometric tilt, no wall surfaces, no",
    "furniture volumes — walls as clean double lines, openings as",
    "standard plan symbols, fixtures and furniture as flat plan symbols,",
    "uniform flat fills and clean drafting linework. No readable text",
    "labels anywhere.",
])


def stage_fp_spec():
    plan = F.load_plan(PLAN)
    int_tags = plan["int_tags"]
    SYS = "\n".join([
        "당신은 영화 미술팀 도면 작성자다. 한 주거 공간의 실내 씬 원문",
        "전문을 읽고, 그 내부의 공간 구성을 추론해 평면도(floor plan)",
        "도면 생성 프롬프트(영어)를 저작하라.",
        "규칙:",
        "1) 공간의 개수·종류·연결 관계·고정 설비(문·창·수납·위생 설비·",
        "   가구 등)와 대략의 크기는 오직 원문 근거로만 추론하라. 인물이",
        "   공간 사이를 어떻게 이동하는지, 무엇이 어디에 있는지가 단서다.",
        "2) 근거 없는 부분은 그 유형 주거의 전형으로 최소 보충, 원문과",
        "   모순 금지.",
        "3) rooms: 추론한 공간 각각 — name_en, evidence_ko(근거 씬/문장),",
        "   fixtures_en(그 공간의 고정 요소).",
        "4) size_estimate_ko: 전체 크기·규모 추정과 그 근거.",
        "5) fp_prompt_en: 완결된 평면도 t2i 프롬프트 — 공간 배치 서사",
        "   (layout narration)를 담되 텍스트 라벨은 도면에 넣지 않게.",
    ])
    SCHEMA = {"type": "object", "additionalProperties": False, "properties": {
        "rooms": {"type": "array", "items": {
            "type": "object", "additionalProperties": False, "properties": {
                "name_en": {"type": "string"},
                "evidence_ko": {"type": "string"},
                "fixtures_en": {"type": "string"}},
            "required": ["name_en", "evidence_ko", "fixtures_en"]}},
        "size_estimate_ko": {"type": "string"},
        "fp_prompt_en": {"type": "string"}},
        "required": ["rooms", "size_estimate_ko", "fp_prompt_en"]}
    user = ("실내 씬 원문 전문:\n\n" + _scenes_block(int_tags)
            + "\n\n실내 샷 목록(참고):\n" + _shots_block(int_tags))
    res = F.llm("s37_fp_spec", SYS, user, SCHEMA)
    plan["fp_spec"] = res
    F.save_plan(PLAN, plan)
    print(f"[fp_spec] rooms={[r['name_en'] for r in res['rooms']]}")
    print(" ", res["size_estimate_ko"])


def stage_fp_img():
    plan = F.load_plan(PLAN)
    p = plan["fp_spec"]["fp_prompt_en"] + "\n\n" + FP_MEDIUM
    plan["fp_prompt_final"] = p
    F.save_plan(PLAN, plan)
    F.img_gpt("s37_fp_img", p, refs=None, size="1024x1024",
              out_path=OUT / FP_FILE)
    print("[fp_img] 완료")


def stage_int_plan():
    plan = F.load_plan(PLAN)
    int_tags = plan["int_tags"]
    SYS = "\n".join([
        "당신은 영화 배경(플레이트) 설계자다. 한 주거 실내의 평면도",
        "스펙과, 실내 씬 원문 전문, 그리고 그 실내에서 찍는 전체 샷",
        "목록을 받는다. 이 실내의 배경 플레이트(사람 없는 실사 배경)",
        "들을 어떤 것부터 어떤 순서로 그릴지 스스로 판단하라.",
        "규칙:",
        "1) 첫 번째 플레이트가 '뿌리(root)': 이후 모든 플레이트의 기준이",
        "   되므로, 공간의 정체성과 고정 요소가 가장 많이 담기는 뷰를",
        "   골라라.",
        "2) 이후 순서는 체이닝을 고려하라: 각 플레이트는 뿌리와 직전",
        "   플레이트를 참조 이미지로 받아 생성된다 — 앞 플레이트와",
        "   공간·요소가 겹치는 뷰가 이어지도록 배열하라.",
        "3) 플레이트 수는 스스로 정하라(전체 샷의 카메라를 커버하는 최소",
        "   집합, 서로 다른 뷰·서브공간 단위).",
        "4) covers: 각 플레이트가 배경을 담당할 샷 태그들 — 전체 샷이",
        "   빠짐없이 정확히 한 플레이트에 배정돼야 한다.",
        "5) prompt_en: 그 플레이트의 t2i 프롬프트(영어) — 사람 없음,",
        "   읽히는 텍스트 없음, 사건 순간 상태물 없음(빈 세트), 첨부된",
        "   평면도(와 뿌리·직전 플레이트)의 그 공간임을 명시, 카메라",
        "   위치·방향을 평면도 기준으로 서술. 시간대는 씬 헤딩.",
        "6) 공간 구성·설비는 평면도 스펙과 원문 근거를 벗어나지 마라.",
        "출력: plates 배열(순서=생성 순서, 첫 항목=뿌리) — key(스네이크",
        "슬러그), title_ko, why_ko(이 순서·뷰인 이유), covers, prompt_en.",
    ])
    SCHEMA = {"type": "object", "additionalProperties": False, "properties": {
        "plates": {"type": "array", "items": {
            "type": "object", "additionalProperties": False, "properties": {
                "key": {"type": "string"},
                "title_ko": {"type": "string"},
                "why_ko": {"type": "string"},
                "covers": {"type": "array", "items": {"type": "string"}},
                "prompt_en": {"type": "string"}},
            "required": ["key", "title_ko", "why_ko", "covers",
                         "prompt_en"]}}},
        "required": ["plates"]}
    spec = plan["fp_spec"]
    rooms_txt = "\n".join(
        f"- {r['name_en']}: {r['fixtures_en']} (근거: {r['evidence_ko']})"
        for r in spec["rooms"])
    user = ("평면도 스펙(추론 완료):\n" + rooms_txt
            + "\n크기 추정: " + spec["size_estimate_ko"]
            + "\n\n실내 씬 원문 전문:\n\n" + _scenes_block(int_tags)
            + "\n\n전체 실내 샷 목록(빠짐없이 커버 배정):\n"
            + _shots_block(int_tags))
    res = F.llm("s37_int_plan", SYS, user, SCHEMA)
    covered = [t for p_ in res["plates"] for t in p_["covers"]]
    missing = [t for t in int_tags if t not in covered]
    dup = [t for t in set(covered) if covered.count(t) > 1]
    if missing or dup:
        raise SystemExit(f"플레이트 커버 검증 실패 missing={missing}"
                         f" dup={dup}")
    plan["int_plates"] = res["plates"]
    F.save_plan(PLAN, plan)
    print(f"[int_plan] {len(res['plates'])}플레이트:")
    for p_ in res["plates"]:
        print(f"  {p_['key']} ({p_['title_ko']}) covers={p_['covers']}")
        print(f"    이유: {p_['why_ko']}")


FP_REF_LABEL = ("FLOOR PLAN — the spatial truth of this interior: room"
                " layout, openings and fixture positions; never draw the"
                " plan itself, render the real interior it describes.")
ROOT_REF_LABEL = ("ROOT INTERIOR PHOTOGRAPH — the same interior: its"
                  " materials, finishes, furniture, lighting and wear are"
                  " LOCKED to this photo.")
PREV_REF_LABEL = ("PREVIOUS ADJACENT VIEW — the immediately preceding"
                  " plate of this same interior: keep every shared fixed"
                  " feature identical where the views overlap.")


def stage_int_plates():
    plan = F.load_plan(PLAN)
    fp = OUT / FP_FILE
    plates = plan["int_plates"]
    prev_path = None
    root_path = None
    for i, p_ in enumerate(plates):
        out = OUT / f"plate_{p_['key']}.png"
        refs = [(FP_REF_LABEL, fp)]
        if i > 0:
            refs.append((ROOT_REF_LABEL, root_path))
            if prev_path != root_path:
                refs.append((PREV_REF_LABEL, prev_path))
        F.img_nb2(f"s37_int_plate_{p_['key']}", p_["prompt_en"], refs,
                  aspect_ratio="16:9", out_path=out)
        if i == 0:
            root_path = out
        prev_path = out
        print(f"[int_plates] {i}: {p_['key']}")


def stage_int_cont():
    plan = F.load_plan(PLAN)
    int_tags = plan["int_tags"]
    SYS = "\n".join([
        "당신은 콘티 연속성 편집자다. 같은 실내에서 찍는 샷 목록(시간순)",
        "과 씬 원문 전문을 읽고, 각 샷마다 '이어받는 상태(carried",
        "state)' — 그 샷을 그릴 때 앞뒤 샷과 반드시 일치시켜야 하는",
        "지속 시각 상태 — 를 영어 1~2문장으로 저작하라.",
        "규칙:",
        "1) 대상 = 샷 사이에 지속되는 시각 상태: 움직일 수 없는 인물",
        "   (사망·의식불명)의 자세·위치와 그가 쥐거나 걸친 물건, 공간에",
        "   남는 상태물(표식·흔적·물건의 위치), 인물이 계속 지니는",
        "   소지물.",
        "2) ★양방향 전파: 어떤 샷에서 확정되는 상태가 물리적으로 그",
        "   이전부터 존재했어야 하면 — 예: 움직일 수 없는 인물이 쥐고",
        "   있는 물건은 나중에 잡을 수 없으므로 그 인물이 등장하는 이전",
        "   샷부터 이미 쥐고 있어야 한다 — 이전 샷들의 carried 에도",
        "   소급해 명시하라. 이후 샷에는 원문에 바뀔 근거가 나올 때까지",
        "   유지하라.",
        "3) 오직 씬 원문 근거로만. 원문에 없는 상태 발명 금지. 시간이",
        "   지나 상태가 정리·소거되는 씬 경계(원문 근거)에서는 전파를",
        "   끊어라.",
        "4) 그 샷에 이어받는 상태가 없으면 carried_en 은 빈 문자열.",
        "각 샷마다: shot(태그), carried_en, basis_ko(근거 한 구절).",
    ])
    SCHEMA = {"type": "object", "additionalProperties": False, "properties": {
        "items": {"type": "array", "items": {
            "type": "object", "additionalProperties": False, "properties": {
                "shot": {"type": "string"},
                "carried_en": {"type": "string"},
                "basis_ko": {"type": "string"}},
            "required": ["shot", "carried_en", "basis_ko"]}}},
        "required": ["items"]}
    user = ("실내 씬 원문 전문:\n\n" + _scenes_block(int_tags)
            + "\n\n샷 목록(시간순 — 각각 carried state 판정):\n"
            + _shots_block(int_tags))
    res = F.llm("s37_int_cont", SYS, user, SCHEMA)
    got = {it["shot"]: it for it in res["items"]}
    missing = [t for t in int_tags if t not in got]
    if missing:
        raise SystemExit(f"carried state 누락: {missing}")
    plan["int_cont"] = got
    F.save_plan(PLAN, plan)
    for t in int_tags:
        c = got[t]["carried_en"]
        print(f"[int_cont] {t}: {c[:80] if c else '(없음)'}")


ACTION_CONT = "\n".join([
    "PANELS ARE CHRONOLOGICAL — CONTINUE THE ACTION: compose every",
    "panel as the direct continuation of the panel before it. Before",
    "drawing a panel, read the previous panel's END STATE — where each",
    "person is, their pose and facing, what they hold, and the state of",
    "the room — and start this panel's action FROM that state,",
    "advancing only what this panel's shot text says. Consecutive",
    "panels must cut together as one continuous scene.",
    "IMMOBILE CHARACTERS NEVER MOVE: a character who is dead,",
    "unconscious or otherwise unable to move must appear in every later",
    "panel EXACTLY as they were in the previous panel where they",
    "appeared — the same collapsed pose, same spot, same clothing and",
    "state, unmoved and unposed. Never re-pose, revive, tidy, seat",
    "upright or relocate an immobile character between panels; only the",
    "camera changes around them.",
])


def stage_int_sketch():
    plan = F.load_plan(PLAN)
    plan35 = _s35()
    mv = _movement(plan35)
    plates = plan["int_plates"]
    tag2plate = {}
    for idx, p_ in enumerate(plates, 1):
        for t in p_["covers"]:
            tag2plate[t] = idx
    tags = plan["int_tags"]
    chunks = [tags[:8], tags[8:]] if len(tags) > 8 else [tags]
    sketches = plan.setdefault("int_sketch", {})
    for ci, chunk in enumerate(c for c in chunks if c):
        keys = [R._key(t) for t in chunk]
        slots, layout, size, _ar = _grid_n(len(chunk))
        lines = []
        for i, (si, shi) in enumerate(keys, 1):
            s = R.SHOT[(si, shi)]
            tag = f"S{si}sh{shi}"
            line = f"PANEL {i}: {s['desc']}"
            m = mv.get(tag)
            if m:
                line += f"\n  MOVEMENT (follow exactly): {m['movement']}"
                if m.get("figures"):
                    line += ("\n  FIGURES — apparent size & depth order"
                             f" (follow exactly): {m['figures']}")
            carried = (plan.get("int_cont", {}).get(tag, {})
                       .get("carried_en", ""))
            if carried:
                line += ("\n  CARRIED STATE (persist exactly — must match"
                         f" the panels before and after): {carried}")
            line += (f"\n  BACKGROUND: attached photograph"
                     f" #{tag2plate[tag]}.")
            lines.append(line)
        note = "\n".join([
            "BACKGROUND PHOTOGRAPHS (attached): each PANEL states which",
            "attached photograph is its REAL location background. Draw",
            "that panel's environment faithfully FROM its stated",
            "photograph — the same room, fixed features and spatial",
            "layout, translated into monochrome pencil storyboard",
            "linework — then stage the figures per the panel's shot text",
            "and movement. All photographs are the SAME single interior.",
        ])
        parts = [
            C.HEAD.format(layout=layout),
            "THE LOCATION (same interior in all panels): the place shown"
            " in the attached photographs.",
            C.PURPOSE, C.REALIZE, C.GEOMETRY, C.CONSISTENCY, ACTION_CONT,
            note,
            "PANELS (one shot per panel, Korean shot text):",
            "\n".join(lines),
        ]
        if slots > len(chunk):
            parts.append(C.BLANK.format(n_shots=len(chunk), slots=slots))
        parts.append(C.SEAL.format(
            numbers=", ".join(str(i) for i in range(1, len(chunk) + 1))))
        p = "\n\n".join(parts)
        fn = f"sketch_int_{ci}.png"
        sketches[str(ci)] = {"file": fn, "shots": chunk, "slots": slots,
                             "prompt": p}
        F.save_plan(PLAN, plan)
        refs = [OUT / f"plate_{p_['key']}.png" for p_ in plates]
        F.img_gpt(f"s37_int_sketch_{ci}", p, refs=refs, size=size,
                  out_path=OUT / fn)
        print(f"[int_sketch] {ci}: {len(chunk)}패널 (refs {len(refs)}장)")


# ── 자세 정본 (움직일 수 없는 인물 강제 교정) ─────────

def stage_pose_canon():
    plan = F.load_plan(PLAN)
    all_tags = plan["ext_tags"] + plan["int_tags"]
    SYS = "\n".join([
        "당신은 콘티 연속성 편집자다. 씬 원문 전문과 샷 목록을 읽고,",
        "움직일 수 없는 상태(사망·의식불명 등)로 화면에 등장하는 인물",
        "각각에 대해 '자세 정본(canonical pose)'을 저작하라.",
        "규칙:",
        "1) pose_en = 영어 1~2문장, 그 인물의 몸 자세를 시각적으로 완전",
        "   서술: 앉음/누움/기댐 여부, 상체·머리·팔다리의 정확한 상태,",
        "   무엇에 기대거나 닿아 있는지, 몸의 방향. 이 문장은 그 인물이",
        "   등장하는 모든 샷에 글자 그대로 재사용된다 — 중의적 단어",
        "   (예: 위치 의미의 lie)는 피하고 자세 동사를 명확히 써라.",
        "2) 자세는 오직 원문 근거로만. 원문이 특정한 순간부터 자세가",
        "   바뀔 근거(이동·수습 등)가 있으면 그 구간별로 별도 항목.",
        "3) shots = 그 정본이 적용되는 샷 태그들(그 인물이 그 자세로",
        "   화면에 보이거나 부분적으로라도 걸리는 샷 전부).",
        "4) 해당 인물이 없으면 빈 배열을 반환하라.",
        "각 항목: character_ko, pose_en, shots, basis_ko(근거 구절).",
    ])
    SCHEMA = {"type": "object", "additionalProperties": False, "properties": {
        "immobile": {"type": "array", "items": {
            "type": "object", "additionalProperties": False, "properties": {
                "character_ko": {"type": "string"},
                "pose_en": {"type": "string"},
                "shots": {"type": "array", "items": {"type": "string"}},
                "basis_ko": {"type": "string"}},
            "required": ["character_ko", "pose_en", "shots", "basis_ko"]}}},
        "required": ["immobile"]}
    user = ("씬 원문 전문:\n\n" + _scenes_block(all_tags)
            + "\n\n샷 목록(시간순):\n" + _shots_block(all_tags))
    res = F.llm("s37_pose_canon", SYS, user, SCHEMA)
    bad = [t for it in res["immobile"] for t in it["shots"]
           if t not in all_tags]
    if bad:
        raise SystemExit(f"pose_canon 미지의 샷: {bad}")
    plan["pose_canon"] = res["immobile"]
    F.save_plan(PLAN, plan)
    for it in res["immobile"]:
        print(f"[pose_canon] {it['character_ko']} shots={it['shots']}")
        print(f"  pose: {it['pose_en']}")
        print(f"  근거: {it['basis_ko']}")


def stage_pose_fix():
    """C안 — 정본과 모순되는 샷별 동선/크기/carried 문장 LLM 교정."""
    plan = F.load_plan(PLAN)
    plan35 = _s35()
    mv = _movement(plan35)
    fixes = plan.setdefault("pose_fix", {})
    SYS = "\n".join([
        "당신은 프롬프트 교정자다. 한 샷의 이미지 생성 프롬프트 문장들",
        "(movement/figures/carried)과, 그 샷에 등장하는 '움직일 수 없는",
        "인물'의 자세 정본(canonical pose)을 받는다.",
        "각 문장을 정본과 대조해, 자세와 모순되거나 다르게 읽힐 수 있는",
        "표현(예: 위치 의미로 쓴 lie 가 '누워있다'로 읽히는 중의성,",
        "정본과 다른 자세 동사)만 최소 수정하라.",
        "규칙: 모순 없는 부분은 글자 그대로 유지(불필요한 재작성 금지),",
        "자세 서술은 정본의 표현을 그대로 차용, 카메라·크기·깊이 정보는",
        "보존. 수정이 전혀 불필요하면 원문 그대로 반환.",
        "출력: movement_en, figures_en, carried_en (각각 교정본 전문).",
    ])
    SCHEMA = {"type": "object", "additionalProperties": False, "properties": {
        "movement_en": {"type": "string"},
        "figures_en": {"type": "string"},
        "carried_en": {"type": "string"}},
        "required": ["movement_en", "figures_en", "carried_en"]}
    for it in plan.get("pose_canon", []):
        for tag in it["shots"]:
            m = mv.get(tag, {})
            carried = (plan.get("int_cont", {}).get(tag, {})
                       .get("carried_en", ""))
            user = (f"자세 정본({it['character_ko']}): {it['pose_en']}\n\n"
                    f"샷 텍스트: {R.SHOT[R._key(tag)]['desc']}\n\n"
                    f"movement: {m.get('movement', '')}\n\n"
                    f"figures: {m.get('figures', '')}\n\n"
                    f"carried: {carried}")
            res = F.llm(f"s37_pose_fix_{tag}", SYS, user, SCHEMA)
            fixes[tag] = res
            print(f"[pose_fix] {tag} 교정 완료")
    F.save_plan(PLAN, plan)


# ── 스틸 (스케치 패널 참조 실사) ──────────────────────

OUTS = OUT / "stills"

PANEL_REF_LABEL = (
    "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.")
PLATE_STILL_LABEL = (
    "LOCATION PHOTOGRAPH — the exact place of this shot: its"
    " architecture, materials, fixed features and lighting mood are"
    " spatial truth; stage the moment inside this place. Never copy its"
    " camera framing.")
PREV_STILL_LABEL = (
    "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. If this photo shows a character who"
    " cannot move (dead or unconscious), that character's exact pose,"
    " position and orientation are ALSO LOCKED — treat their body as a"
    " fixed prop of the set that only the camera moves around.")


def stage_stills():
    import s34_stillrun as S
    plan = F.load_plan(PLAN)
    plan35 = _s35()
    mv = _movement(plan35)
    prev_all = {}
    for gk in EXT_GROUPS + [INT_GROUP]:
        prev_all.update(plan35.get("prev", {}).get(gk, {}))
    chars = S._char_refs()
    props = S._prop_refs()
    ve = S._ve_ids()
    scene_union = {}
    for (si, shi), ids in ve.items():
        scene_union.setdefault(si, set()).update(ids)
    gmap = {g["key"]: g for g in plan35["groups"]}
    tag2plate = {}
    for p_ in plan["int_plates"]:
        for t in p_["covers"]:
            tag2plate[t] = OUT / f"plate_{p_['key']}.png"
    meta = plan.setdefault("still_meta", {})
    OUTS.mkdir(parents=True, exist_ok=True)
    jobs = [(gk, t) for gk in EXT_GROUPS for t in gmap[gk]["shots"]] \
        + [(INT_GROUP, t) for t in plan["int_tags"]]
    for gkey, tag in jobs:
        out = OUTS / f"{tag}_nb2.png"
        if out.exists():
            print(f"[stills] {tag} skip(exists)")
            continue
        s = R.SHOT[R._key(tag)]
        m = mv.get(tag, {})
        plate = (OUT / f"bg_{tag}.png") if gkey != INT_GROUP \
            else tag2plate[tag]
        prev_tag = (prev_all.get(tag) or {}).get("prev")
        prev_used = None
        panel_used = False
        refs = []
        if prev_tag and (OUTS / f"{prev_tag}_nb2.png").exists():
            # prev 스틸이 장소 룩·연속성을 잠그므로 콘티 패널과 배경
            # 플레이트는 첨부하지 않음 (참조 = prev + 엔티티만)
            prev_used = prev_tag
            refs.append((PREV_STILL_LABEL, OUTS / f"{prev_tag}_nb2.png"))
        else:
            panel_used = True
            refs.append((PANEL_REF_LABEL, OUTP / f"{tag}.png"))
            refs.append((PLATE_STILL_LABEL, plate))
        ve_ids = ve.get(R._key(tag), [])
        if not ve_ids:
            ve_ids = sorted(scene_union.get(R._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): "
            + S._time_of(tag) + ".",
            f"SHOT TEXT (authoritative, Korean): {S._soften(s['desc'])}",
            f"LOCATION (lock): {gmap[gkey]['place_en']} The shot takes"
            " place here — the attached "
            + ("PREVIOUS SHOT STILL shows this exact place."
               if prev_used else "LOCATION PHOTOGRAPH shows the exact"
               " spot."),
        ]
        if panel_used:
            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(S.REALIZE_STILL)
        fix = plan.get("pose_fix", {}).get(tag)
        movement = (fix or {}).get("movement_en") or m.get("movement")
        figures = (fix or {}).get("figures_en") or m.get("figures")
        if movement:
            parts.append("MOVEMENT (follow exactly): "
                         + S._soften(movement))
        if figures:
            parts.append("FIGURES — size & depth (follow exactly): "
                         + S._soften(figures))
        carried = (plan.get("int_cont", {}).get(tag, {})
                   .get("carried_en", "")) if gkey == INT_GROUP else ""
        carried = (fix or {}).get("carried_en") or carried
        if carried:
            parts.append("CARRIED STATE (persist exactly — must match"
                         " the neighbouring shots of this scene): "
                         + S._soften(carried))
        for it in plan.get("pose_canon", []):
            if tag in it["shots"]:
                parts.append(
                    "IMMOBILE CHARACTER POSE — CANONICAL (this exact"
                    " wording recurs in every shot where this character"
                    " appears; if MOVEMENT, FIGURES or CARRIED STATE"
                    " conflicts with it, THIS POSE WINS): "
                    + S._soften(it["pose_en"]))
        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"s37_still_{tag}", prompt_full, refs,
                  aspect_ratio="16:9", out_path=out)
        meta[tag] = {
            "gkey": gkey, "prev_used": prev_used,
            "panel_used": panel_used,
            "plate": ("(미첨부 — prev 잠금)" if prev_used
                      else str(plate.name)),
            "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, plan)
        print(f"[stills] {tag} 완료 (plate={plate.name}, prev={prev_used},"
              f" chars={char_names}, props={prop_names})")


# ── 크롭 / 갤러리 ─────────────────────────────────────

def stage_crop():
    from PIL import Image
    import numpy as np
    plan = F.load_plan(PLAN)
    OUTP.mkdir(parents=True, exist_ok=True)
    GRIDMAP = {2: (2, 1), 4: (2, 2), 6: (3, 2), 8: (4, 2)}
    jobs = [plan["ext_sketch"]] + [v for _, v in
                                   sorted(plan.get("int_sketch", {}).items())]
    for sh in jobs:
        sheet = Image.open(OUT / sh["file"]).convert("RGB")
        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
        cols, rows = GRIDMAP[sh["slots"]]
        arr = np.asarray(sheet.convert("L"))
        dark = arr < 120

        def _lines(profile, n_expect, total):
            cand = [i for i, v in enumerate(profile) if v > 0.55]
            gs = []
            for i in cand:
                if gs and i - gs[-1][-1] <= 6:
                    gs[-1].append(i)
                else:
                    gs.append([i])
            centers = [sum(x) // len(x) for x in gs
                       if total * 0.04 < sum(x) / len(x) < total * 0.96]
            if len(centers) == n_expect:
                return centers
            return [total * (k + 1) // (n_expect + 1)
                    for k in range(n_expect)]

        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(sh["shots"]):
            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] {sh['file']}: {len(sh['shots'])}패널"
              f" v={v_lines} h={h_lines}")


def stage_html():
    plan = F.load_plan(PLAN)

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

    def fig(fn, cap, cls=""):
        rel = f"out/s37/{fn}"
        return (f"<figure class='{cls}'><a href='{rel}' target=_blank>"
                f"<img src='{rel}' loading=lazy></a>"
                f"<figcaption>{esc(cap)}</figcaption></figure>")

    em = plan["ext_master"]
    facts = "".join(f"<li>{esc(f_)}</li>" for f_ in em["inferred_facts_ko"])
    jrows = ""
    for m in ("gpt", "gemini-pro"):
        for v in plan.get("ext_judge", {}).get(m, {}).get("verdicts", []):
            jrows += (f"<tr><td>{m}</td><td>{v['label']}</td>"
                      f"<td>{v['score']}</td><td>{esc(v['verdict_ko'])}"
                      f"</td></tr>")
    crows = ""
    for m, c in (plan.get("ext_critique") or {}).items():
        for i in c["issues"]:
            crows += (f"<tr><td>{m}</td><td>{esc(i['issue_ko'])}</td>"
                      f"<td>{esc(i['fix_en'])}</td></tr>")
    sel = plan.get("ext_selected", "?")
    cands = "".join(
        fig(fn, f"마스터 후보 {lab}"
            + (f" — 선택(합산 {plan['ext_totals'][lab]}점)"
               if lab == sel else f" ({plan.get('ext_totals', {}).get(lab, '?')}점)"),
            cls=("sel" if lab == sel else ""))
        for lab, fn in MASTER_CAND)
    fixed = plan.get("ext_fixed_file")
    fixed_fig = fig(fixed, "기본 이미지(수정 반영본)" if not
                    plan.get("ext_fix_skipped") else
                    "기본 이미지(결함 0 — 선택본 그대로)") if fixed else ""

    bg_figs = ""
    for tag in plan["ext_tags"]:
        it = plan["ext_bg"][tag]
        bg_figs += (
            f"<div class=shot><h3>{tag}</h3>"
            f"<div class=note>{esc(R.SHOT[R._key(tag)]['desc'])}<br>"
            f"뷰: {esc(it['view_ko'])}</div><div class=row>"
            + fig(f"bg_{tag}.png", "배경 플레이트 (참조=기본 이미지)")
            + fig(f"panels/{tag}.png", "스케치 패널")
            + "</div><details><summary>플레이트 프롬프트</summary>"
            f"<pre>{esc(it['bg_prompt_en'])}</pre></details></div>")

    es = plan.get("ext_sketch", {})
    ext_sketch_fig = fig(es.get("file", ""), f"실외 분할 스케치"
                         f" ({len(es.get('shots', []))}패널 — 배경"
                         f" {len(plan['ext_tags'])}장 동시 참조)")

    spec = plan["fp_spec"]
    rooms = "".join(
        f"<li><b>{esc(r['name_en'])}</b> — {esc(r['fixtures_en'])}"
        f"<br><span class=note>근거: {esc(r['evidence_ko'])}</span></li>"
        for r in spec["rooms"])
    plate_secs = ""
    for i, p_ in enumerate(plan["int_plates"]):
        role = "뿌리(root)" if i == 0 else f"체인 {i}"
        refs_desc = ("FP" if i == 0 else "FP + 뿌리 + 직전")
        plate_secs += (
            f"<div class=shot><h3>{i + 1}. {esc(p_['title_ko'])}"
            f" <span class=k>({p_['key']} · {role} · 참조={refs_desc})"
            f"</span></h3>"
            f"<div class=note>순서 이유: {esc(p_['why_ko'])}<br>"
            f"커버 샷: {esc(', '.join(p_['covers']))}</div>"
            + fig(f"plate_{p_['key']}.png", "플레이트")
            + f"<details><summary>프롬프트</summary>"
              f"<pre>{esc(p_['prompt_en'])}</pre></details></div>")

    still_secs = ""
    sm = plan.get("still_meta", {})
    if sm:
        gmap35 = {g["key"]: g for g in _s35()["groups"]}
        for gkey in EXT_GROUPS + [INT_GROUP]:
            rows = ""
            for tag in ([t for t in plan["ext_tags"]
                         if sm.get(t, {}).get("gkey") == gkey]
                        if gkey != INT_GROUP else plan["int_tags"]):
                mt = sm.get(tag)
                if not mt:
                    continue
                ref_items = "".join(
                    f"<figure class=pn><a href='{r['path']}'"
                    f" target=_blank>"
                    f"<img src='{r['path']}' loading=lazy></a>"
                    f"<figcaption>{esc(r['label'].split('—')[0].strip())}"
                    f"</figcaption></figure>"
                    for r in mt["refs"] if r["path"].startswith("out"))
                ext_refs = [r for r in mt["refs"]
                            if not r["path"].startswith("out")]
                rows += (
                    f"<div class=shot><h3>{tag}"
                    f"<span class=k> — plate={esc(mt['plate'])}"
                    f" · prev={esc(str(mt['prev_used'] or '없음'))}"
                    f" · 외부 참조(캐릭터/소품) {len(ext_refs)}장</span></h3>"
                    f"<div class=note>{esc(R.SHOT[R._key(tag)]['desc'])}"
                    f"</div><div class=row>"
                    f"<figure><a href='out/s37/stills/{tag}_nb2.png'"
                    f" target=_blank><img src='out/s37/stills/"
                    f"{tag}_nb2.png' loading=lazy></a>"
                    f"<figcaption>nb2 스틸</figcaption></figure>"
                    f"<div>{ref_items}</div></div>"
                    f"<details><summary>프롬프트 전문</summary>"
                    f"<pre>{esc(mt['prompt'])}</pre></details></div>")
            still_secs += (f"<h3 style='margin-top:22px'>"
                           f"{esc(gmap35[gkey]['name_ko'])}"
                           f" <span class=k>({gkey})</span></h3>{rows}")
        still_secs = (
            "<section><h2>⑨ 실사 스틸 (스케치 패널+플레이트+prev+엔티티"
            " 참조, nb2)</h2>" + still_secs + "</section>")
        prev_tags = [t for t in (plan["ext_tags"] + plan["int_tags"])
                     if sm.get(t, {}).get("prev_used")]
        comp_rows = ""
        for tag in prev_tags:
            cells = ""
            for ver, cap in (("stills_v1", "v1 패널+플레이트+prev"),
                             ("stills_v2", "v2 패널+prev"),
                             ("stills", "v3 prev만 (현행)")):
                rel = f"out/s37/{ver}/{tag}_nb2.png"
                cells += (f"<figure class=cmp><a href='{rel}'"
                          f" target=_blank><img src='{rel}'"
                          f" loading=lazy></a><figcaption>{cap}"
                          f"</figcaption></figure>")
            comp_rows += (f"<div class=shot><h3>{tag}"
                          f"<span class=k> — prev="
                          f"{esc(sm[tag]['prev_used'])}</span></h3>"
                          f"<div class=note>"
                          f"{esc(R.SHOT[R._key(tag)]['desc'])}</div>"
                          f"<div class=row>{cells}</div></div>")
        if comp_rows:
            still_secs += (
                "<section><h2>⑩ prev 13샷 — 참조 조합 3판 비교</h2>"
                "<div class=note>v1=스케치 패널+배경 플레이트+prev /"
                " v2=스케치 패널+prev(플레이트 제외) / v3=prev만"
                "(콘티·플레이트 제외, 현행). 캐릭터·소품 참조는 3판"
                " 공통.</div>" + comp_rows + "</section>")

    int_sketch_figs = ""
    int_panel_figs = ""
    for ci, sh in sorted(plan.get("int_sketch", {}).items()):
        int_sketch_figs += fig(sh["file"],
                               f"실내 스케치 시트 {int(ci) + 1}"
                               f" ({len(sh['shots'])}패널)")
        for tag in sh["shots"]:
            int_panel_figs += (
                f"<figure class=pn><a href='out/s37/panels/{tag}.png'"
                f" target=_blank><img src='out/s37/panels/{tag}.png'"
                f" loading=lazy></a><figcaption>{tag} — "
                f"{esc(R.SHOT[R._key(tag)]['desc'])[:60]}</figcaption>"
                f"</figure>")

    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>s37 — 옥탑 23샷 전면 재구축 (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; }}
h3 {{ font-size:15px; margin:12px 0 4px; }}
.k {{ color:#8a939e; font-size:13px; font-weight:400; }}
.row {{ display:flex; gap:14px; align-items:flex-start; margin:8px 0;
        flex-wrap:wrap; }}
figure {{ margin:6px 8px 6px 0; display:inline-block;
          vertical-align:top; max-width:560px; }}
figcaption {{ color:#c9d2dc; font-size:12.5px; }}
img {{ max-width:560px; width:100%; border-radius:8px;
      border:1px solid #2a2f36; }}
figure.sel img {{ outline:3px solid #2a7ae2; }}
figure.pn {{ max-width:300px; }} figure.pn img {{ max-width:300px; }}
figure.cmp {{ max-width:420px; }} figure.cmp img {{ max-width:420px; }}
figure.pn figcaption {{ font-size:11px; }}
.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:10px 0; }}
table {{ border-collapse:collapse; font-size:12.5px; margin:8px 0; }}
td,th {{ border:1px solid #2a2f36; padding:4px 8px; }}
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>s37 — 옥탑 관련 23샷 전면 재구축</h1>
<div class=box>하드코딩 0: 건물 구조·크기·규모 전부 LLM 이 씬 원문
전문에서 자가 추론. <b>실외(8샷)</b>: LLM 마스터 프롬프트 저작 → nb2
3롤 → GPT/Gemini VLM 합산 판정 선택 → 결함 취합 i2i 수정 = 기본 이미지
→ 샷별 배경 플레이트 8장(기본 이미지 참조) → 배경 8장 동시 참조 8패널
분할 스케치. <b>실내(15샷)</b>: LLM 평면도 스펙 추론 → i2 FP → LLM 이
그리는 순서 스스로 판단(뿌리 → 체인, 커버 샷 배정) → nb2 플레이트
체이닝(FP+뿌리+직전 참조) → 전 플레이트 동시 참조 스케치 2시트(8+7).
</div>

<section><h2>① 실외 — LLM 자가 추론 (구조·규모 근거)</h2>
<ul>{facts}</ul>
<div class=note>카메라 선택: {esc(em['camera_ko'])}</div>
<details><summary>마스터 t2i 프롬프트 전문 (LLM 저작)</summary>
<pre>{esc(em['master_prompt_en'])}</pre></details></section>

<section><h2>② 실외 — 마스터 3롤 + VLM 합산 판정</h2>
<div class=row>{cands}</div>
<table><tr><th>VLM</th><th>후보</th><th>점수</th><th>판정</th></tr>
{jrows}</table></section>

<section><h2>③ 실외 — VLM 결함 취합 수정 → 기본 이미지</h2>
<table><tr><th>VLM</th><th>결함</th><th>수정 지시</th></tr>{crows}</table>
{fixed_fig}</section>

<section><h2>④ 실외 — 샷별 배경 플레이트 + 스케치 패널 (8샷)</h2>
{bg_figs}</section>

<section><h2>⑤ 실외 — 분할 스케치 시트</h2>
{ext_sketch_fig}
<details><summary>스케치 프롬프트 전문</summary>
<pre>{esc(es.get('prompt', ''))}</pre></details></section>

<section><h2>⑥ 실내 — LLM 평면도 추론 + FP</h2>
<ul>{rooms}</ul>
<div class=note>크기 추정: {esc(spec['size_estimate_ko'])}</div>
{fig(FP_FILE, "평면도 (i2, LLM 저작 프롬프트)")}
<details><summary>FP 프롬프트 전문</summary>
<pre>{esc(plan.get('fp_prompt_final', ''))}</pre></details></section>

<section><h2>⑦ 실내 — 플레이트 체인 (LLM 이 정한 순서)</h2>
{plate_secs}</section>

<section><h2>⑧ 실내 — 분할 스케치 시트 (전 플레이트 동시 참조)</h2>
<div class=row>{int_sketch_figs}</div>
<div class=row>{int_panel_figs}</div>
<details><summary>스케치 프롬프트 전문 (시트별)</summary>
{''.join(f"<pre>{esc(sh['prompt'])}</pre>" for _, sh in
         sorted(plan.get('int_sketch', {}).items()))}
</details></section>
{still_secs}
</body></html>""")
    print(f"[html] {PAGE}")


STAGES = ["ext_prompt", "ext_photos", "ext_judge", "ext_fix",
          "ext_bg_prompts", "ext_bgs", "ext_sketch",
          "fp_spec", "fp_img", "int_plan", "int_plates", "int_cont",
          "int_sketch", "crop", "pose_canon", "pose_fix", "stills",
          "html"]

if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--only", required=True,
                    choices=STAGES + ["ext_all", "int_all"])
    a = ap.parse_args()
    fns = {s: globals()[f"stage_{s}"] for s in STAGES}
    if a.only == "ext_all":
        for s in STAGES[:7]:
            fns[s]()
    elif a.only == "int_all":
        for s in STAGES[7:13]:
            fns[s]()
    else:
        fns[a.only]()
