#!/usr/bin/env python3
"""s34 — 연속성 콘티 시트 실험 (실험 전용·커밋 금지).

v22(2026-07-12): 영어 계약 원복(한국어 전문은 타 그룹 품질 저하로 반려)
+ 비유·은유·과장 캐릭터 표현의 실촬영 현실화 조항(REALIZE) 추가.
v21(반려): 프롬프트 전문 한국어 원어 — 숲 인영은 해결했으나 그 외 악화.
v20: 실사영화 프리프로덕션 스토리보드 스타일(만화체 금지, 영어 계약).
사용자 정정(v2): 콘티 = 손으로 그린 간략 러프 스케치 (실사 금지).
목적 = 구도·장면 연속성(인물 이동 방향/카메라 움직임/카메라 구도)만.
글자 절대 금지(샷 번호만 허용). 분할은 2/4/6 균등 그리드만.
패널 수 = 샷 수 그대로 — 홀수면 남는 패널을 순백으로 비움.
균등 분할이라 코드가 나중에 패널을 잘라 분해할 수 있어야 함.

사용: backend/.venv/bin/python s34_conti_sheet.py --only <prompts|gen|html>
     [--engines i2,nb2] [--groups forest,...]
산출: out/conti/conti2_*.png + plans/s34_conti_v2.json + conti_sheet.html
"""
import argparse
import html as _html
import json
import sys
from pathlib import Path

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

OUTD = F.OUT / "conti"
PAGE = F.EXP / "conti_sheet.html"
# v22: 영어 계약 원복 — 플랜 v2로 복귀(영어 동선 28건 보존분 재사용), v3=한국어판 보존
PLAN = "s34_conti_v2"
PLAN_V1 = "s34_conti_v1"
DATA = json.load(open(Path(__file__).parent / "pipeline_doc" / "shot_loc_data.json"))
SHOT = {(s["scene"], s["shot"]): s for s in DATA["shots"]}
ENGINES = ["i2", "nb2"]

# (키, 표시명, 장소 서술 EN, [(scene, shot)...])
GROUPS = [
    ("forest", "숲속", "a dense Korean mountain forest with thick undergrowth",
     [(1, 1), (1, 4), (3, 3)]),
    ("busstop", "해안가 버스정류장", "a small seaside bus stop with a bench facing the sea at night on a quiet Korean coastal road",
     [(15, 2), (15, 6)]),
    ("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",
     [(10, 6), (10, 7), (11, 4), (11, 5), (13, 4), (13, 6)]),
    ("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",
     [(12, 11), (12, 21), (14, 5), (18, 11), (25, 1), (25, 5)]),
    ("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",
     [(8, 3), (8, 5), (19, 5), (19, 6), (20, 1), (20, 3)]),
    ("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",
     [(16, 3), (21, 7), (21, 10), (21, 12), (22, 1)]),
]

# 그리드: 샷 수 → (패널 슬롯 수, 등분법 명시 EN, i2 size, nb2 aspect)
def _grid(n_shots):
    if n_shots <= 2:
        return (2,
                "2 panels made by ONE vertical cut at exactly 50% of the image"
                " width — each panel is exactly half the width and the FULL"
                " height (1 row x 2 columns)",
                "1536x1024", "16:9")
    if n_shots <= 4:
        return (4,
                "4 panels made by ONE vertical cut at exactly 50% of the width"
                " and ONE horizontal cut at exactly 50% of the height — a 2x2"
                " grid, each panel exactly one quarter of the image",
                "1024x1024", "1:1")
    return (6,
            "6 panels made by TWO vertical cuts at exactly 1/3 and 2/3 of the"
            " width and ONE horizontal cut at exactly 50% of the height — a"
            " 3-column x 2-row grid, all six panels exactly equal",
            "1536x1024", "16:9")


HEAD = "\n".join([
    "Draw ONE storyboard (conti) sheet: {layout}.",
    "STYLE: hand-drawn PRE-PRODUCTION STORYBOARD FOR A LIVE-ACTION FILM",
    "SHOOT — the working drawings a professional film storyboard artist",
    "makes for the director and cinematographer of a real movie:",
    "REALISTIC adult human proportions and faces (ordinary Korean",
    "adults, film-actor realism), true cinematic lens framing, and",
    "naturalistic light-and-shadow blocking rendered in monochrome",
    "graphite pencil tone on white paper.",
    "ABSOLUTELY NOT manga, NOT anime, NOT cartoon, NOT webtoon, NOT",
    "comic-book style: no stylized or enlarged eyes, no cute or",
    "youthful idealized faces, no clean ink outlines — every panel",
    "must read like a frame of a live-action film sketched in pencil.",
    "No color anywhere.",
])

PURPOSE = "\n".join([
    "PURPOSE: each panel captures only the CAMERA COMPOSITION of one",
    "shot — framing (close-up / medium / wide), where the subject sits in",
    "the frame, and continuity between panels. Keep every panel",
    "extremely simple: only what matters for composition.",
    "MOVEMENT IS SHOWN BY THE FIGURE ITSELF, NEVER BY MARKINGS: convey",
    "direction of travel purely through the body — stride mid-motion,",
    "body lean into the path, back view vs front view, and the stated",
    "size/depth cues. Do NOT draw arrows, motion lines, path lines,",
    "speed lines or any diagram markings of any kind.",
])

REALIZE = "\n".join([
    "REALIZE FIGURATIVE LANGUAGE AS A LIVE-ACTION SHOT: the Korean shot",
    "text may describe characters metaphorically, figuratively or with",
    "exaggeration. Draw 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 drawings.",
    "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 draw 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.",
])

CONSISTENCY = "\n".join([
    "CONTINUITY: all drawn panels are the SAME single location — fixed",
    "features (doors, stairs, windows, poles, desks) keep the same",
    "position and left/right relationships across panels so the panels",
    "cut together as one continuous scene.",
])

GEOMETRY = "\n".join([
    "GEOMETRY — ANALYSE BEFORE DRAWING: from each panel's Korean shot",
    "text, first work out the spatial facts and draw EXACTLY those, never",
    "re-stage them: (a) each figure's DISTANCE from camera (tight insert /",
    "close-up / medium / full / wide — a shot about a hand or an object",
    "is a tight insert, not a two-shot); (b) each figure's FACING and",
    "MOVEMENT DIRECTION relative to camera — '뒷모습' means seen from",
    "BEHIND, moving AWAY from camera into the depth of the frame, not",
    "sideways; a profile means lateral movement; (c) relative positions",
    "and distances BETWEEN figures. Keep every established screen",
    "direction consistent across panels (180-degree rule): a person who",
    "leaves in one panel keeps the same on-screen direction wherever the",
    "movement appears again.",
    "SIZE & DEPTH ARE CONTRACTUAL: each panel states every figure's",
    "apparent size in the frame and the explicit near/far depth order.",
    "Draw exactly those proportions — a farther figure MUST be drawn",
    "clearly smaller than a nearer one; never equalize figure sizes,",
    "especially when several figures share a panel or their paths",
    "relate to each other.",
    "DEPARTING FIGURE — a figure running/walking AWAY must be",
    "unmistakably departing, never approaching: seen exactly from behind",
    "(back of the head only, no face, no chin, no cheek line), body",
    "leaning into the path, mid-stride with one shoe sole toward the",
    "camera. THE STATED SIZE/DEPTH WINS: draw the departing figure at",
    "exactly the apparent size and depth the panel's FIGURES line states",
    "(if stated FARTHER/small, it stays small in the distance — never",
    "enlarge it into the foreground for drama).",
])

SEAL = "\n".join([
    "ABSOLUTELY NO TEXT anywhere: no words, letters, captions, labels,",
    "sound effects or watermarks — the ONLY allowed marking is a small",
    "shot number digit ({numbers}) in a corner INSIDE each drawn panel.",
    "THE PANEL GRID FILLS THE ENTIRE IMAGE EDGE-TO-EDGE: no outer paper",
    "margin, no border area, nothing drawn outside the panels — the",
    "image must be mechanically cuttable along the stated cut lines.",
    "Panels are separated only by thin straight black gutter lines at",
    "the exact stated positions; ALL PANELS EXACTLY THE SAME SIZE.",
])

BLANK = "\n".join([
    "There are {n_shots} shots but {slots} panel slots: draw the shots in",
    "panels 1..{n_shots} and leave the remaining panel(s) COMPLETELY",
    "BLANK PURE WHITE — empty white paper, not even a frame line inside.",
])


MOVEMENT_SYS = "\n".join([
    "당신은 콘티 작가를 돕는 동선 분석가다. 씬 원문(한국어 전문)과 샷",
    "목록을 읽고, 각 샷에서 인물이 공간을 어떻게 통과하는지(동선)를",
    "판정하라. 원문에 근거한 것만 쓰고 발명 금지. 마커나 ID 코드 금지.",
    "샷마다 두 필드를 채워라:",
    "1) movement_en — 영어 한 문장: 누가/무엇이 움직이는지, 이동 경로와",
    "방향(오르는지 내려오는지, 들어가는지 나오는지, 카메라 쪽인지",
    "반대인지), 몸의 진행 자세, 어디에서 와서 어디로 가는 중인지.",
    "정지 샷이면 'static:'으로 시작해 시선·몸 방향만.",
    "★인물의 신체·의식 상태가 원문에 있으면 자세 서술에 반드시 반영:",
    "죽었거나 의식이 없으면 'lifeless: collapsed, asymmetric, limp",
    "limbs, weight fallen' 처럼 무너진 자세로 명시(단정하게 앉거나",
    "웅크린 자세로 쓰지 말 것), 부상·긴장·공포도 자세에 드러나게.",
    "어둠 속 실루엣·그림자로 보이는 존재도 실제로는 사람이면 사람의",
    "신체 비례임을 유지해 서술하라(예: 'a real person appearing as a",
    "dark silhouette, normal human build').",
    "2) figures_en — 영어 1~2문장: 프레임에 보이는 인물/핵심 물체",
    "각각에 대해 (a) 프레임 안 겉보기 크기(화면 높이의 몇 분의 몇",
    "수준인지: e.g. fills ~2/3 of frame height / small ~1/5), (b) 카메라",
    "기준 원근 서열(which is NEARER, which is FARTHER — 명시적 비교),",
    "(c) 인물이 여럿이거나 동선이 얽히면 서로의 경로·거리 관계까지.",
    "인물 1명 단순 샷이어도 크기와 근원(near/far)은 반드시 기록.",
])

MOVEMENT_SCHEMA = {
    "type": "object",
    "properties": {"movements": {"type": "array", "items": {
        "type": "object",
        "properties": {"shot": {"type": "string"},
                       "movement_en": {"type": "string"},
                       "figures_en": {"type": "string"}},
        "required": ["shot", "movement_en", "figures_en"]}}},
    "required": ["movements"],
}


def stage_movement(groups_filter):
    plan = F.load_plan(PLAN)
    mv = plan.setdefault("movement", {})
    for gkey, name, _place, keys in GROUPS:
        if groups_filter and gkey not in groups_filter:
            continue
        scene_ids = sorted({si for si, _ in keys})
        scene_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"S{si}sh{shi}: {SHOT[(si, shi)]['desc']}" for si, shi in keys)
        user = (f"씬 원문 전문:\n{scene_txt}\n\n샷 목록(각각 동선 판정):\n"
                f"{shots_txt}")
        res = F.llm(f"s34_move_{gkey}", MOVEMENT_SYS, user, MOVEMENT_SCHEMA)
        mv[gkey] = {m["shot"]: {"movement": m["movement_en"],
                                "figures": m["figures_en"]}
                    for m in res["movements"]}
        print(f"[movement] {gkey}({name}): {len(mv[gkey])}건")
    F.save_plan(PLAN, plan)


def _prompt(place_en, keys, movement=None):
    n = len(keys)
    slots, layout, _size, _ar = _grid(n)
    lines = []
    for i, (si, shi) in enumerate(keys, 1):
        s = SHOT[(si, shi)]
        line = f"PANEL {i}: {s['desc']}"
        mv = (movement or {}).get(f"S{si}sh{shi}")
        if mv:
            if isinstance(mv, str):
                mv = {"movement": mv, "figures": ""}
            line += f"\n  MOVEMENT (follow exactly): {mv['movement']}"
            if mv.get("figures"):
                line += ("\n  FIGURES — apparent size & depth order"
                         f" (follow exactly): {mv['figures']}")
        lines.append(line)
    parts = [
        HEAD.format(layout=layout),
        f"THE LOCATION (same in all drawn panels): {place_en}.",
        PURPOSE,
        REALIZE,
        GEOMETRY,
        CONSISTENCY,
        "PANELS (one shot per panel, Korean shot text):",
        "\n".join(lines),
    ]
    if slots > n:
        parts.append(BLANK.format(n_shots=n, slots=slots))
    parts.append(SEAL.format(numbers=", ".join(str(i) for i in range(1, n + 1))))
    return "\n\n".join(parts)


def stage_prompts():
    try:
        old = F.load_plan(PLAN)
        movement = old.get("movement", {})
    except Exception:
        movement = {}
    plan = {"groups": {}, "movement": movement}
    for gkey, name, place, keys in GROUPS:
        slots, layout, size, ar = _grid(len(keys))
        plan["groups"][gkey] = {
            "name": name, "n": len(keys), "slots": slots,
            "layout": layout, "i2_size": size, "nb2_ar": ar,
            "shots": [f"S{si}sh{shi}" for si, shi in keys],
            "prompt": _prompt(place, keys, movement.get(gkey)),
            **{eng: f"conti24_{gkey}_{eng}.png" for eng in ENGINES},
        }
    F.save_plan(PLAN, plan)
    n_mv = sum(len(v) for v in movement.values())
    print(f"[prompts] {len(GROUPS)}그룹 저장 (동선 주입 {n_mv}건)")


def stage_gen(engines, groups_filter):
    plan = F.load_plan(PLAN)
    for gkey, _name, _place, _keys in GROUPS:
        if groups_filter and gkey not in groups_filter:
            continue
        e = plan["groups"][gkey]
        for eng in [x for x in ENGINES if x in engines]:
            out = OUTD / e[eng]
            if eng == "i2":
                F.img_gpt(f"s34v2_conti_{gkey}_i2", e["prompt"],
                          refs=None, size=e["i2_size"], out_path=out)
            else:
                F.img_nb2(f"s34v2_conti_{gkey}_nb2", e["prompt"], [],
                          aspect_ratio=e["nb2_ar"], out_path=out)
        print(f"[gen] {gkey} 완료")


def stage_html():
    plan = F.load_plan(PLAN)
    try:
        v1 = F.load_plan(PLAN_V1)
    except Exception:
        v1 = None

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

    rows = []
    for gkey, name, _place, keys in GROUPS:
        e = plan["groups"][gkey]
        shots_html = "".join(
            f"<li><b>P{i}</b> S{si}sh{shi} — {esc(SHOT[(si, shi)]['desc'])}</li>"
            for i, (si, shi) in enumerate(keys, 1))
        blank = (f" · 백색 패널 {e['slots'] - e['n']}개" if e['slots'] > e['n'] else "")
        def _img_file(eng, e=e, gkey=gkey):
            if (OUTD / e[eng]).exists():
                return e[eng], "v24(인물=사람 형상 원칙)"
            for ver, fn in (("v22(EN+비유 현실화)", f"conti22_{gkey}_{eng}.png"),
                            ("v20(실사영화·EN)", f"conti20_{gkey}_{eng}.png"),
                            ("v9", f"conti9_{gkey}_{eng}.png"),
                            ("v8", f"conti8_{gkey}_{eng}.png"),
                            ("v7", f"conti7_{gkey}_{eng}.png"),
                            ("v6", f"conti6_{gkey}_{eng}.png"),
                            ("v5", f"conti5_{gkey}_{eng}.png"),
                            ("v4", f"conti4_{gkey}_{eng}.png"),
                            ("v3", f"conti3_{gkey}_{eng}.png"),
                            ("v2", f"conti2_{gkey}_{eng}.png")):
                if (OUTD / fn).exists():
                    return fn, ver
            return e[eng], "생성 전"
        imgs = ""
        for eng in ENGINES:
            fn, ver = _img_file(eng)
            imgs += (f"<figure><a href='out/conti/{fn}' target=_blank>"
                     f"<img src='out/conti/{fn}' loading=lazy></a>"
                     f"<figcaption>{eng} · {ver}</figcaption></figure>")
            for prev, plab in ((f"conti23_{gkey}_{eng}.png",
                                "비교 — v23(무광 검정 금지 하드룰, 반려)"),
                               (f"conti22_{gkey}_{eng}.png",
                                "비교 — v22(EN+비유 현실화)"),
                               (f"conti21_{gkey}_{eng}.png",
                                "비교 — v21(한국어 원어, 반려)"),
                               (f"conti20_{gkey}_{eng}.png",
                                "비교 — v20(실사영화·영어)")):
                if fn != prev and (OUTD / prev).exists():
                    imgs += (f"<figure><a href='out/conti/{prev}'"
                             f" target=_blank><img src='out/conti/{prev}'"
                             f" loading=lazy></a><figcaption>{eng} · {plab}"
                             "</figcaption></figure>")
        if gkey == "busstop":
            imgs += ("<figure><a href='out/conti/ref_busstop_map_v5_overlay"
                     ".png' target=_blank><img src='out/conti/"
                     "ref_busstop_map_v5_overlay.png' loading=lazy></a>"
                     "<figcaption>★참조 이미지 (현행 v5, i2=gpt-image-2 생성) — 완전 수직 2D"
                     " 평면도(원근 0, 셸터=평면 심볼) + LLM이 샷 원문을"
                     " 재분석한 카메라 배치(액션 뒤 down-road에서 up-road"
                     " 방향, H 가까이·S 멀리 깊이 순서) — 오버레이도 t2i"
                     "</figcaption></figure>"
                     "<figure><a href='out/conti/conti16_busstop_i2.png'"
                     " target=_blank><img src='out/conti/conti16_busstop_i2"
                     ".png' loading=lazy></a><figcaption>i2 · v16 (현행 최종)"
                     " — 재판정 카메라: 혜수 등 뒤 시점, H 전신 가까이/S"
                     " 멀리 뒷모습, 찻길 명확</figcaption></figure>"
                     "<figure><a href='out/conti/ref_busstop_map_v5_flat_nb2"
                     ".png' target=_blank><img src='out/conti/"
                     "ref_busstop_map_v5_flat_nb2.png' loading=lazy></a>"
                     "<figcaption>비교 — nb2 평면도 재시도: 1차보다 개선"
                     "됐으나 ★원근 잔존(도로 사다리꼴 수렴·호안 벽 측면"
                     " 노출)+바다·보도 실사 질감 혼입 — 완전 평면 실패"
                     "</figcaption></figure>"
                     "<figure><a href='out/conti/ref_busstop_map_v5_overlay"
                     "_nb2.png' target=_blank><img src='out/conti/"
                     "ref_busstop_map_v5_overlay_nb2.png' loading=lazy></a>"
                     "<figcaption>비교 — nb2 오버레이(재판정 카메라):"
                     " ★배치는 이번엔 정확(카메라 down-road·콘이 셸터+"
                     "H+S 포함·S 화살표 교차로 방향·단일성 준수) — 단"
                     " 바탕 맵의 원근·실사 혼입 상속</figcaption></figure>"
                     "<figure><a href='out/conti/conti17_busstop_i2.png'"
                     " target=_blank><img src='out/conti/conti17_busstop_i2"
                     ".png' loading=lazy></a><figcaption>i2 · v17 — nb2"
                     " 오버레이 맵 기반: 재판정 카메라·깊이 순서·찻길 전부"
                     " 정확 (바탕 맵의 원근·실사 혼입이 출력에 전염되지"
                     " 않음)</figcaption></figure>"
                     "<figure><a href='out/conti/conti17_busstop_nb2.png'"
                     " target=_blank><img src='out/conti/conti17_busstop_nb2"
                     ".png' loading=lazy></a><figcaption>nb2 · v17 — 같은"
                     " 맵 기반: 배치·찻길 정확+텍스트 무재발, 단 등분 위반"
                     " 지속(상단 몰림·하단 여백)</figcaption></figure>"
                     "<figure><a href='out/conti/conti18_busstop_i2.png'"
                     " target=_blank><img src='out/conti/conti18_busstop_i2"
                     ".png' loading=lazy></a><figcaption>i2 · v18 (재현 1)"
                     " — 동일 구도 유지</figcaption></figure>"
                     "<figure><a href='out/conti/conti18_busstop_nb2.png'"
                     " target=_blank><img src='out/conti/conti18_busstop_nb2"
                     ".png' loading=lazy></a><figcaption>nb2 · v18 (재현 1)"
                     " — ★등분 첫 준수·배치 정확 (단 밤이 낮처럼 밝음)"
                     "</figcaption></figure>"
                     "<figure><a href='out/conti/conti19_busstop_i2.png'"
                     " target=_blank><img src='out/conti/conti19_busstop_i2"
                     ".png' loading=lazy></a><figcaption>i2 · v19 (재현 2)"
                     " — 동일 구도 유지</figcaption></figure>"
                     "<figure><a href='out/conti/conti19_busstop_nb2.png'"
                     " target=_blank><img src='out/conti/conti19_busstop_nb2"
                     ".png' loading=lazy></a><figcaption>nb2 · v19 (재현 2)"
                     " — 배치 정확·등분 재위반</figcaption></figure>"
                     "<figure><a href='out/conti/ref_busstop_map_v4_t2i.png'"
                     " target=_blank><img src='out/conti/"
                     "ref_busstop_map_v4_t2i.png' loading=lazy></a>"
                     "<figcaption>이력 — v4 참조(구 카메라 판정·준평면)"
                     "</figcaption></figure>"
                     "<figure><a href='out/conti/ref_busstop_map.png'"
                     " target=_blank><img src='out/conti/ref_busstop_map.png'"
                     " loading=lazy></a><figcaption>구판 맵 (4회차 캐논 —"
                     " 도로 소실 결함 실측, 비교용)</figcaption></figure>"
                     "<figure><a href='out/conti/conti15_busstop_i2.png'"
                     " target=_blank><img src='out/conti/conti15_busstop_i2"
                     ".png' loading=lazy></a><figcaption>i2 · v15 (현행 최종)"
                     " — t2i 주석판 맵 참조: 찻길+보도+셸터+호안 층 구조"
                     " 정확</figcaption></figure>"
                     "<figure><a href='out/conti/ref_busstop_map_nb2.png'"
                     " target=_blank><img src='out/conti/ref_busstop_map_nb2"
                     ".png' loading=lazy></a><figcaption>비교: nb2 맵 재투영"
                     " — 탑다운 미준수(아이소메트릭)+하단 실사 질감 혼입"
                     "+B2 마커 누락</figcaption></figure>"
                     "<figure><a href='out/conti/ref_busstop_map_nb2_overlay"
                     ".png' target=_blank><img src='out/conti/"
                     "ref_busstop_map_nb2_overlay.png' loading=lazy></a>"
                     "<figcaption>비교: nb2 오버레이 — 단일성(1H/1S/1카메라)"
                     "은 준수했으나 ★시야 콘이 지시 반대 방향으로 열림"
                     "(S가 콘 밖)+S가 차도 위</figcaption></figure>")
            for eng2 in ENGINES:
                for fn_x, lab in (
                        (f"conti12_busstop_{eng2}.png",
                         "맵+실사+카메라 grounding 참조 (화살표 無)"),
                        (f"conti13_busstop_{eng2}.png",
                         "맵+카메라 grounding만 — 실사 제외 ablation")):
                    if (OUTD / fn_x).exists():
                        imgs += (f"<figure><a href='out/conti/{fn_x}'"
                                 f" target=_blank><img src='out/conti/{fn_x}'"
                                 f" loading=lazy></a><figcaption>{eng2} · "
                                 f"{lab} — P2 전용</figcaption></figure>")
        v1imgs = ""
        if v1 and gkey in v1.get("groups", {}):
            v1e = v1["groups"][gkey]
            v1imgs = ("<details><summary>v1 (실사판 — 비교용)</summary><div class=pair>"
                      + "".join(
                          f"<figure><a href='out/conti/{v1e[eng]}' target=_blank>"
                          f"<img src='out/conti/{v1e[eng]}' loading=lazy></a>"
                          f"<figcaption>v1 {eng}</figcaption></figure>"
                          for eng in ENGINES if eng in v1e)
                      + "</div></details>")
        rows.append(
            f"<section><h2>{esc(name)} <span class=k>({gkey} · 샷 {e['n']} → "
            f"{e['slots']}분할{blank})</span></h2><div class=pair>{imgs}</div>"
            f"<details><summary>패널 구성 (샷 원문)</summary><ul>{shots_html}"
            f"</ul><pre>{esc(e['prompt'])}</pre></details>{v1imgs}</section>")
    still_section = ""
    if (OUTD / "still_S15sh6_i2.png").exists():
        figs = ""
        for fn, cap in (
            ("panel_S15sh2_nb2.png", "크롭 패널 P1 (nb2 v18 콘티 좌반)"),
            ("panel_S15sh6_nb2.png", "크롭 패널 P2 (우반)"),
            ("still_S15sh2_i2.png", "스틸 S15sh2 · i2"),
            ("still_S15sh2_nb2.png", "스틸 S15sh2 · nb2"),
            ("still_S15sh6_i2.png", "스틸 S15sh6 · i2 (prev 없음)"),
            ("still_S15sh6_nb2.png", "스틸 S15sh6 · nb2 (prev 없음)"),
            ("still2_S15sh6_i2.png",
             "스틸 S15sh6 · i2 + prev 동반 — 장소 연속 개선, ★인물 역할"
             " 스왑(남은 이가 수리영 의상)"),
            ("still2_S15sh6_nb2.png",
             "스틸 S15sh6 · nb2 + prev 동반 — ★의상·장소 연속 정확"
             "(라벨드 참조가 역할 고정)"),
            ("still3_S15sh6_i2.png",
             "스틸 S15sh6 · i2 + prev + 역할 바인딩 명시 — 그래도 스왑"
             " 잔재(2연속): i2 다중 인물 참조 정체성 관리 약함"),
        ):
            if (OUTD / fn).exists():
                figs += (f"<figure><a href='out/conti/{fn}' target=_blank>"
                         f"<img src='out/conti/{fn}' loading=lazy></a>"
                         f"<figcaption>{fn.split('_')[0]} — {cap}"
                         "</figcaption></figure>")
        still_section = (
            "<section><h2>정류장 — 콘티 패널 → 실사 스틸 체인 "
            "<span class=k>(nb2 v18 콘티를 코드로 반분 → 패널=구도 참조 + "
            "캐릭터 합성 참조 2장만(배경 참조 제외) → i2/nb2 스틸)</span>"
            f"</h2><div class=pair>{figs}</div></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 콘티 시트 v2 — 러프 스케치 · 연속성 6그룹 × i2/nb2</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; }}
.pair {{ display:flex; gap:14px; flex-wrap:wrap; }}
figure {{ margin:0; }} figcaption {{ color:#8a939e; font-size:12px; text-align:center; }}
img {{ max-width:640px; width:100%; border-radius:8px; border:1px solid #2a2f36; background:#fff; }}
details {{ margin:8px 0; }} summary {{ color:#9ecbff; cursor:pointer; }}
pre {{ background:#161b22; border:1px solid #2a2f36; padding:10px;
      white-space:pre-wrap; font-size:12px; }}
ul {{ margin:6px 0; }}
.box {{ background:#161b22; border:1px solid #2a2f36; border-radius:8px;
       padding:12px 16px; margin:12px 0; }}
</style></head><body>
<h1>s34 v2 — 손그림 러프 스케치 콘티 (구도·연속성 전용, 균등 분할)</h1>
<div class=box>계약: 손그림 연필 러프 스케치(실사 금지)·글자 절대 금지(샷 번호만)·
2/4/6 균등 분할·<b>패널 수=샷 수, 홀수면 잔여 패널 순백</b>(숲 3샷→2×2+백색 1,
경찰서 5샷→3×2+백색 1)·모션 화살표로 인물/카메라 이동 표기·전 패널 같은 장소
연속성. 균등 그리드라 코드가 패널을 잘라 분해 가능. i2/nb2 둘 다.</div>
{still_section}
{''.join(rows)}
</body></html>""")
    print(f"[html] {PAGE}")


CANON_DIR = Path("/Users/manta/Documents/Projects/TheRoad-I1/projects/"
                 "8207aadc-7975-48f5-af17-ccc145a6660d/images/background_chain")
BUS_PHOTO = CANON_DIR / "canon_bg_coastal_bus_stop_master.png"
BUS_MAP = CANON_DIR / "canon_bg_coastal_bus_stop_map.png"

BUS_P2_GROUND = "\n".join([
    "PANEL 2 CAMERA & PLACEMENT (follow exactly, from the site plan):",
    "camera stands on the water-side portion of the bus stop, just",
    "beyond the bench, looking along the length of the coastal road —",
    "in frame: the departing runner in full-body stride at center,",
    "the bench with the other woman beside it in the left background,",
    "the coastal land edge to the right and the open sea beyond it.",
])

BUS_REF_NOTE = "\n".join([
    "REFERENCES (for grounding, not for style): the attached LOCATION",
    "PHOTOGRAPH shows how this exact bus stop looks (shelter, bench,",
    "road, sea side); the attached flat SITE PLAN shows the layout from",
    "above — never draw the plan itself or its markers. APPLY THEM TO",
    "PANEL 2 ONLY: panel 2's camera position, shelter/bench placement",
    "and figure positions must match the plan and the photograph's",
    "geography. PANEL 1 is an earlier, tighter moment from a different",
    "angle — take only the stop's general look (bench, shelter) from",
    "the references, not their viewpoint. The OUTPUT stays a rough",
    "hand-drawn pencil storyboard sketch — do not copy the photograph's",
    "rendering, only its spatial truth.",
])


BUS_MAPONLY_NOTE = "\n".join([
    "REFERENCE (for grounding, not for style): the attached flat SITE",
    "PLAN shows this bus stop's layout from above — where the shelter,",
    "bench, road, land edge and sea are relative to each other. Never",
    "draw the plan itself or its markers. APPLY IT TO PANEL 2 ONLY:",
    "panel 2's camera position, shelter/bench placement and figure",
    "positions must match this plan. PANEL 1 is an earlier, tighter",
    "moment from a different angle — do not force the plan's viewpoint",
    "onto it. The OUTPUT stays a rough hand-drawn pencil storyboard",
    "sketch.",
])


def stage_busstop_maponly(engines):
    plan = F.load_plan(PLAN)
    e = plan["groups"]["busstop"]
    prompt = e["prompt"] + "\n\n" + BUS_P2_GROUND + "\n\n" + BUS_MAPONLY_NOTE
    if "i2" in engines:
        out = OUTD / "conti13_busstop_i2.png"
        F.img_gpt("s34v13_busstop_maponly_i2", prompt,
                  refs=[BUS_MAP], size="1536x1024", out_path=out)
        print(f"[busstop_maponly] {out}")
    if "nb2" in engines:
        out = OUTD / "conti13_busstop_nb2.png"
        F.img_nb2("s34v13_busstop_maponly_nb2", prompt,
                  [("SITE PLAN — top-down layout source only; never draw"
                    " this plan or its markers.", BUS_MAP)],
                  aspect_ratio="16:9", out_path=out)
        print(f"[busstop_maponly] {out}")


def stage_busstop_refs(engines):
    plan = F.load_plan(PLAN)
    e = plan["groups"]["busstop"]
    prompt = e["prompt"] + "\n\n" + BUS_P2_GROUND + "\n\n" + BUS_REF_NOTE
    if "i2" in engines:
        out = OUTD / "conti12_busstop_i2.png"
        F.img_gpt("s34v12_busstop_refs_i2", prompt,
                  refs=[BUS_PHOTO, BUS_MAP], size="1536x1024", out_path=out)
        print(f"[busstop_refs] {out}")
    if "nb2" in engines:
        out = OUTD / "conti12_busstop_nb2.png"
        refs = [("LOCATION PHOTOGRAPH — this exact bus stop; spatial truth"
                 " only, output stays a pencil sketch.", BUS_PHOTO),
                ("SITE PLAN — top-down layout source only; never draw this"
                 " plan or its markers.", BUS_MAP)]
        F.img_nb2("s34v12_busstop_refs_nb2", prompt, refs,
                  aspect_ratio="16:9", out_path=out)
        print(f"[busstop_refs] {out}")


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--only", required=True,
                    choices=["prompts", "movement", "gen", "html",
                             "busstop_refs", "busstop_maponly"])
    ap.add_argument("--engines", default="i2,nb2")
    ap.add_argument("--groups", default="")
    a = ap.parse_args()
    if a.only == "prompts":
        stage_prompts()
    elif a.only == "busstop_refs":
        stage_busstop_refs(a.engines.split(","))
    elif a.only == "busstop_maponly":
        stage_busstop_maponly(a.engines.split(","))
    elif a.only == "movement":
        stage_movement([g for g in a.groups.split(",") if g])
    elif a.only == "gen":
        stage_gen(a.engines.split(","),
                  [g for g in a.groups.split(",") if g])
    else:
        stage_html()
