#!/usr/bin/env python3
"""s30 — 맵 기반 샷 적용 체인 (2026-07-09, 실험 전용·커밋 금지).

top2 최종 자산(경찰서 FP 스타일 맵 + VLM 선택·결함 수정 실사)을 구 실험
프로젝트의 옥탑 실외 샷 라인에 적용하는 체인:
  [1] select  — 실외 8샷 라인에서 옥탑방 외부/주변 관련 샷 LLM 선택
  [2] concept — 샷별 "배경을 어찌 그리면 좋을지" LLM 구상
  [3] ground  — 맵 이미지+마커 스펙+구상을 주고 "지도의 어디 주변에서
                어찌 찍을지" grounding (마커 인용 카메라 배치)
  [4] bg      — 샷별 새 배경 플레이트 생성: 최초 bg(룩 SOT)+맵(배치 SOT)
                참조 + "지도의 어느 지점인지" 명시 프롬프트, i2/nb2 비교
  [5] pick    — GPT/Gemini VLM 0-10 합산으로 플레이트 선택(동점=Gemini,
                s29 판정 지식 재사용)
  [6] compose — 선택 플레이트+캐릭터 passport 로 샷 스틸 합성(nb2)
판정 지식: nb2 프롬프트 ID-free(마커 코드→서술명 치환)·참조 관할 라벨
분리(맵=배치/실사=룩)·발명 경계(무근거 날씨·재질 금지)+철제 계단 정정.
사용: backend/.venv/bin/python s30_shot_apply.py [--only <stage>|all]
산출: out/shot_apply/*.png + plans/s30_shot_apply_v1.json + shot_apply.html
"""
import argparse
import html as _html
import json
import shutil
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
import forest_lib as F  # noqa: E402
import s27_forest_map as S27  # noqa: E402 (스펙 재사용)

OUTD = F.OUT / "shot_apply"
PAGE = F.EXP / "shot_apply.html"
PLAN = "s30_shot_apply_v1"

MAP_PNG = S27.OUT / "top2" / "top2_map_gpt.png"        # 배치 SOT
PHOTO_PNG = S27.OUT / "top2" / "top2_photo_fixed_nb2.png"  # 룩 SOT

# ── 공용 계약 블록 (시나리오 중립 — 구체 내용은 데이터 주입) ──

WORLD_FACTS = "\n".join([
    "WORLD FACTS (creator-confirmed / evidence-bound — always true):",
    "- The main building is an aged three-storey multi-family villa;",
    "  its roof deck sits roughly 8-10 m above the yard, with the small",
    "  rooftop-room unit as an extra volume on the deck.",
    "- The exterior stair is a later-added STEEL retrofit stair climbing",
    "  the full three storeys: open treads, thin steel plate stringers,",
    "  support posts, pipe railings. It is NOT a concrete stair.",
])

INVENTION_BOUNDARY = "\n".join([
    "INVENTION BOUNDARY: state world facts (weather, atmosphere, surface",
    "wetness, materials, light-source colour, numeric distances, time of",
    "day) ONLY when the shot text or its corrections state them; where",
    "they are silent, stay silent too — do not invent moods or weather.",
])

NO_ANNOTATION = S27.NO_ANNOTATION


def _load_shots():
    """collect(실외 8샷 라인) + recon staging + s24 정정 팩 조인."""
    collect = F.load_plan("outdoor_shots_collect")["shots"]
    recon = F.load_recon()["shots"]
    repair = F.load_plan("spatial_repair_v1")["shots"]
    shots = {}
    for sk, c in collect.items():
        r = recon.get(sk) or {}
        shots[sk] = {**c, "staging": r.get("staging"),
                     "characters": r.get("characters") or [],
                     "corrections": (repair.get(sk) or {}).get(
                         "staging_corrections") or []}
    return shots


def _shot_text(sk, s):
    """LLM 입력용 샷 블록 — 원문 전체 + 정정 override (자르기 금지)."""
    lines = [f"[{sk}] scene_heading={s.get('scene_heading')}",
             f"description: {s.get('description')}"]
    if s.get("characters"):
        lines.append("characters: " + ", ".join(s["characters"]))
    st = s.get("staging") or {}
    for k in ("framing_scale", "camera_direction", "perspective",
              "lighting_mood"):
        if st.get(k):
            lines.append(f"{k}: {st[k]}")
    if st.get("key_bg_elements"):
        lines.append("key_bg_elements: "
                     + json.dumps(st["key_bg_elements"], ensure_ascii=False))
    if st.get("frame_spatial_contract"):
        lines.append("frame_spatial_contract: "
                     + json.dumps(st["frame_spatial_contract"],
                                  ensure_ascii=False))
    corr = s.get("corrections") or []
    if corr:
        lines.append("STAGING CORRECTIONS (override — each replaces the"
                     " quoted expression above; corrections win):")
        for c in corr:
            lines.append(f'- "{c["before_quote"]}" -> {c["after_en"]}'
                         f' ({c["field_en"]})')
    return "\n".join(lines)


def _legend_with_codes(spec):
    d = spec["detail"]
    return "\n".join(f"- ({it['code']}) {it['name_en']} — {it['placement_en']}"
                     for it in d["items"])


def _code_to_item(spec):
    return {it["code"]: it for it in spec["detail"]["items"]}


# ── [1] select — 옥탑방 외부/주변 관련 샷 선택 ──

SELECT_SYSTEM = "\n".join([
    "You are a film production planner. You are given several shots from",
    "one production. Select the shots whose BACKGROUND is the exterior",
    "or the immediate surroundings of the rooftop-room property (roof",
    "deck, exterior stair, yard, gate, alley, views of the building from",
    "outside). Exclude shots whose background is an interior seen from",
    "inside. For every shot return include=true/false with a one-line",
    "Korean reason.",
])


def stage_select():
    shots = _load_shots()
    keys = sorted(shots.keys())
    schema = {
        "type": "object", "additionalProperties": False,
        "properties": {"shots": {
            "type": "array",
            "items": {
                "type": "object", "additionalProperties": False,
                "properties": {
                    "shot_key": {"type": "string", "enum": keys},
                    "include": {"type": "boolean"},
                    "reason_ko": {"type": "string"},
                },
                "required": ["shot_key", "include", "reason_ko"],
            },
        }},
        "required": ["shots"],
    }
    user = "SHOTS:\n\n" + "\n\n".join(_shot_text(k, shots[k]) for k in keys)
    res = F.llm("s30_select", SELECT_SYSTEM, user, schema)
    picked = [x["shot_key"] for x in res["shots"] if x["include"]]
    F.save_plan(PLAN, {"select": res, "selected_shots": picked})
    print(f"[select] {len(picked)}/{len(keys)} 선택: {picked}")


# ── [2] concept — 샷별 배경 구상 ──

CONCEPT_SYSTEM = "\n".join([
    "You are the production designer and cinematographer of a live-action",
    "film, planning the BACKGROUND PLATE (no people) behind one shot.",
    "Given the shot content below, describe how that background should be",
    "drawn: what the camera is, what physical elements fill the frame and",
    "where, and the time-of-day / light state.",
    "Ground every world fact in the shot text and its corrections;",
    "corrections always win over the original wording.",
    INVENTION_BOUNDARY,
])

CONCEPT_SCHEMA = {
    "type": "object", "additionalProperties": False,
    "properties": {
        "bg_concept_en": {"type": "string",
                          "description": "how to draw this background,"
                          " 2-4 sentences"},
        "key_elements_en": {"type": "array", "items": {"type": "string"}},
        "camera_en": {"type": "string",
                      "description": "camera height/angle/direction and"
                      " framing for the background plate"},
        "time_light_en": {"type": "string",
                          "description": "time of day and light, ONLY as"
                          " grounded by the shot text; if silent, say"
                          " 'unspecified — neutral'"},
        "notes_ko": {"type": "string"},
    },
    "required": ["bg_concept_en", "key_elements_en", "camera_en",
                 "time_light_en", "notes_ko"],
}


def stage_concept():
    shots = _load_shots()
    plan = F.load_plan(PLAN)
    out = {}
    for sk in plan["selected_shots"]:
        user = "\n\n".join([WORLD_FACTS, "SHOT:\n" + _shot_text(sk, shots[sk])])
        out[sk] = F.llm(f"s30_concept_{sk}", CONCEPT_SYSTEM, user,
                        CONCEPT_SCHEMA)
        print(f"[concept] {sk}: {out[sk]['camera_en'][:80]}")
    plan["concept"] = out
    F.save_plan(PLAN, plan)


# ── [3] ground — 맵 grounding (마커 인용 카메라 배치) ──

GROUND_SYSTEM = "\n".join([
    "You are the location scout of a live-action film. Attached is the",
    "SITE PLAN of the single filming property (with circled markers whose",
    "legend is given as text), plus one shot and its background concept.",
    "Decide WHERE on this site plan the shot takes place and how to shoot",
    "it there: pick the zone, cite the marker codes the camera stands at",
    "or looks toward, and describe the camera position and view strictly",
    "in terms of the mapped elements.",
    "Every spatial claim must be consistent with the site plan and the",
    "marker legend. If the shot looks outward past the property edge,",
    "say what lies beyond the frame edge on the plan side it faces.",
    INVENTION_BOUNDARY,
])


def _ground_schema(spec):
    codes = [it["code"] for it in spec["detail"]["items"]]
    zones = spec["detail"]["zone_labels_en"]
    return {
        "type": "object", "additionalProperties": False,
        "properties": {
            "map_zone": {"type": "string", "enum": zones},
            "anchor_markers": {"type": "array",
                               "items": {"type": "string", "enum": codes},
                               "description": "markers the camera stands"
                               " at / looks toward / frames"},
            "camera_position_en": {"type": "string",
                                   "description": "where the camera stands,"
                                   " described via mapped elements"},
            "look_direction_en": {"type": "string"},
            "in_frame_en": {"type": "string",
                            "description": "what of the mapped property"
                            " fills the frame, near to far"},
            "rationale_ko": {"type": "string"},
        },
        "required": ["map_zone", "anchor_markers", "camera_position_en",
                     "look_direction_en", "in_frame_en", "rationale_ko"],
    }


def stage_ground():
    spec = F.load_plan(S27.SPEC)
    shots = _load_shots()
    plan = F.load_plan(PLAN)
    schema = _ground_schema(spec)
    out = {}
    for sk in plan["selected_shots"]:
        parts = [
            {"type": "text", "text": "SITE PLAN of the filming property:"},
            F.png_data_url(MAP_PNG),
            {"type": "text", "text": "MARKER LEGEND:\n"
             + _legend_with_codes(spec)},
            {"type": "text", "text": "SHOT:\n" + _shot_text(sk, shots[sk])},
            {"type": "text", "text": "BACKGROUND CONCEPT:\n"
             + json.dumps(plan["concept"][sk], ensure_ascii=False)},
        ]
        out[sk] = F.llm(f"s30_ground_{sk}", GROUND_SYSTEM, parts, schema)
        print(f"[ground] {sk}: zone={out[sk]['map_zone']}"
              f" markers={out[sk]['anchor_markers']}")
    plan["ground"] = out
    F.save_plan(PLAN, plan)


# ── [4] bg — 샷별 새 배경 플레이트 (i2/nb2 비교) ──

LOOK_NOTE = "\n".join([
    "LOOK REFERENCE: the attached PHOTOGRAPH shows this same property.",
    "Match its look exactly — the building, its materials and aging, the",
    "roof-deck contents, colours, and the surrounding town. It is the",
    "single source of truth for how everything LOOKS.",
])

MAP_NOTE = "\n".join([
    "SITE PLAN REFERENCE: the attached flat drawing is the site plan of",
    "this same property. Use it ONLY as the source of truth for WHERE",
    "things are (spatial arrangement, adjacency, routes). NEVER draw the",
    "plan itself — none of its flat colours, circles, codes or labels may",
    "appear in the output.",
])


def _bg_prompt(spec, sk, plan):
    c2i = _code_to_item(spec)
    g = plan["ground"][sk]
    con = plan["concept"][sk]
    anchors = "\n".join(
        f"- {c2i[c]['name_en']}: {c2i[c]['placement_en']}"
        for c in g["anchor_markers"] if c in c2i)
    return "\n\n".join([
        "Create ONE photorealistic BACKGROUND PLATE for a live-action film"
        " still. No people, no animals, an empty set.",
        "CAMERA: " + g["camera_position_en"] + " Looking: "
        + g["look_direction_en"] + " " + con["camera_en"],
        "MAP POINT: this plate is taken in the \"" + g["map_zone"]
        + "\" area of the property on the attached site plan, anchored to"
        " these mapped elements:\n" + anchors,
        "IN FRAME (near to far): " + g["in_frame_en"],
        "BACKGROUND INTENT: " + con["bg_concept_en"]
        + " Key elements: " + "; ".join(con["key_elements_en"]) + ".",
        "TIME & LIGHT: " + con["time_light_en"] + " Do not add weather,"
        " atmosphere or colour moods beyond this.",
        WORLD_FACTS,
        LOOK_NOTE,
        MAP_NOTE,
        NO_ANNOTATION,
    ])


def stage_bg():
    spec = F.load_plan(S27.SPEC)
    plan = F.load_plan(PLAN)
    bg = plan.get("bg", {})
    for sk in plan["selected_shots"]:
        p = _bg_prompt(spec, sk, plan)
        bg[sk] = {"prompt": p,
                  "i2": f"bg_{sk}_i2.png", "nb2": f"bg_{sk}_nb2.png"}
        # i2: 참조 순서 선언은 프롬프트의 LOOK/MAP 노트가 담당(사진→도면 순)
        F.img_gpt(f"s30_bg_{sk}_i2", p, refs=[PHOTO_PNG, MAP_PNG],
                  size="1536x1024", out_path=OUTD / bg[sk]["i2"])
        F.img_nb2(f"s30_bg_{sk}_nb2", p,
                  [("LOOK REFERENCE — real photograph of the same property;"
                    " sole source of how everything looks.", PHOTO_PNG),
                   ("SITE PLAN — layout source only; never draw this"
                    " drawing or its markers.", MAP_PNG)],
                  aspect_ratio="16:9", out_path=OUTD / bg[sk]["nb2"])
        print(f"[bg] {sk}: i2+nb2 완료")
    plan["bg"] = bg
    F.save_plan(PLAN, plan)


# ── [5] pick — 플레이트 VLM 선택 (s29 판정 지식 재사용) ──

PICK_SYSTEM = "\n".join([
    "You are given ONE image-generation prompt and TWO candidate images,",
    "labelled A and B in the order attached, both generated from that",
    "exact prompt.",
    "Pick the ONE candidate that most faithfully realises the prompt:",
    "its camera (height, angle, direction, framing), its stated spatial",
    "arrangement and anchored elements, materials and state, time and",
    "light, and its exclusions (no people, no text, no map-style",
    "graphics). Ignore generic aesthetic appeal.",
    "Also give each candidate an integer score 0-10 for that fidelity.",
    "Output: winner, and per candidate a score plus a one-line Korean",
    "verdict citing the decisive prompt points.",
])

PICK_SCHEMA = {
    "type": "object", "additionalProperties": False,
    "properties": {
        "winner": {"type": "string", "enum": ["A", "B"]},
        "verdicts": {
            "type": "array",
            "items": {
                "type": "object", "additionalProperties": False,
                "properties": {
                    "label": {"type": "string", "enum": ["A", "B"]},
                    "score": {"type": "integer"},
                    "verdict_ko": {"type": "string"},
                },
                "required": ["label", "score", "verdict_ko"],
            },
        },
    },
    "required": ["winner", "verdicts"],
}

CAND = [("A", "i2"), ("B", "nb2")]


def stage_pick():
    plan = F.load_plan(PLAN)
    pick = {}
    for sk in plan["selected_shots"]:
        b = plan["bg"][sk]
        parts = [{"type": "text",
                  "text": "THE PROMPT (both candidates were generated from"
                          " this):\n" + b["prompt"]}]
        for lab, eng in CAND:
            parts.append({"type": "text", "text": f"Candidate {lab}:"})
            parts.append(F.png_data_url(OUTD / b[eng]))
        judge = {}
        for model, tag in (("gpt", f"s30_pick_{sk}_gpt"),
                           ("gemini-pro", f"s30_pick_{sk}_gemini")):
            judge[model] = F.llm(tag, PICK_SYSTEM, parts, PICK_SCHEMA,
                                 model=model)
        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 CAND}
        best = max(totals.values())
        tied = [lab for lab, t in totals.items() if t == best]
        sel = (tied[0] if len(tied) == 1
               else judge["gemini-pro"]["winner"] if
               judge["gemini-pro"]["winner"] in tied else tied[0])
        eng = dict(CAND)[sel]
        pick[sk] = {"judge": judge, "totals": totals,
                    "selected": sel, "engine": eng,
                    "file": plan["bg"][sk][eng]}
        print(f"[pick] {sk}: totals={totals} -> {sel}({eng})")
    plan["pick"] = pick
    F.save_plan(PLAN, plan)


# ── [6] compose — 선택 플레이트 + passport 합성 (nb2) ──

BG_LABEL = ("SETTING PLATE — the exact background of this still; keep its"
            " camera, layout, lighting and every visible element exactly.")


def _compose_prompt(sk, s, plan):
    g = plan["ground"][sk]
    st = s.get("staging") or {}
    chars = s.get("characters") or []
    lines = [
        "Create the final live-action film still by staging the moment"
        " below inside the attached SETTING PLATE. The plate is the"
        " camera and the set — do not change its viewpoint, layout,"
        " lighting or any visible element; only add what the moment"
        " requires.",
        "MOMENT (authoritative, Korean): " + (s.get("description") or ""),
    ]
    if chars:
        lines.append(
            "CHARACTERS: only the listed reference people appear — "
            + ", ".join(chars)
            + ". Match each reference person's identity exactly"
            " (face, hair, build); dress and pose them as the moment"
            " describes.")
    else:
        lines.append("No people appear unless the moment itself says so.")
    if st.get("framing_scale"):
        lines.append("FRAMING: " + str(st["framing_scale"]) + " shot; "
                     + g["look_direction_en"])
    lines.append("Photorealistic cinematic still, one single moment.")
    lines.append(NO_ANNOTATION)
    return "\n\n".join(lines)


def stage_compose():
    shots = _load_shots()
    plan = F.load_plan(PLAN)
    passports = F.query_passports()
    name_to_sid = F.load_recon()["character_name_to_sid"]
    comp = {}
    for sk in plan["selected_shots"]:
        s = shots[sk]
        bg_path = OUTD / plan["pick"][sk]["file"]
        refs = [(BG_LABEL, bg_path)]
        used, missing = [], []
        for name in s.get("characters") or []:
            sid = name_to_sid.get(name)
            if sid and passports.get(sid):
                refs.append((f"CHARACTER REFERENCE — {name}: the exact"
                             " person to place in the moment.",
                             passports[sid]))
                used.append(name)
            else:
                missing.append(name)
        p = _compose_prompt(sk, s, plan)
        fn = f"still_{sk}.png"
        comp[sk] = {"prompt": p, "file": fn, "chars_used": used,
                    "chars_missing_passport": missing,
                    "bg_engine": plan["pick"][sk]["engine"]}
        F.img_nb2(f"s30_still_{sk}", p, refs, aspect_ratio="16:9",
                  out_path=OUTD / fn)
        print(f"[compose] {sk}: chars={used} missing={missing}")
    plan["compose"] = comp
    F.save_plan(PLAN, plan)


# ── html ──

def _copy_baseline(shots, plan):
    base = OUTD / "baseline"
    base.mkdir(parents=True, exist_ok=True)
    out = {}
    for sk in plan["selected_shots"]:
        imgs = shots[sk].get("images") or []
        if not imgs:
            continue
        src = F.ROOT / imgs[0]["src"]
        if src.exists():
            dst = base / f"{sk}.png"
            if not dst.exists():
                shutil.copyfile(src, dst)
            out[sk] = f"baseline/{sk}.png"
    return out


def stage_html():
    shots = _load_shots()
    plan = F.load_plan(PLAN)
    baseline = _copy_baseline(shots, plan)

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

    def fig(rel, cap, width=23, sel=False):
        cls = " class='sel'" if sel else ""
        return (f"<figure style='width:{width}%'><a href='{rel}'>"
                f"<img src='{rel}' loading='lazy'{cls}></a>"
                f"<figcaption>{esc(cap)}</figcaption></figure>")

    sel_rows = "".join(
        f"<tr><td>{x['shot_key']}</td><td>{'O' if x['include'] else 'X'}"
        f"</td><td>{esc(x['reason_ko'])}</td></tr>"
        for x in plan.get("select", {}).get("shots", []))

    secs = ""
    for sk in plan["selected_shots"]:
        s = shots[sk]
        con = (plan.get("concept") or {}).get(sk) or {}
        g = (plan.get("ground") or {}).get(sk) or {}
        b = (plan.get("bg") or {}).get(sk) or {}
        pk = (plan.get("pick") or {}).get(sk) or {}
        cm = (plan.get("compose") or {}).get(sk) or {}
        prows = ""
        for m, j in (pk.get("judge") or {}).items():
            for v in j["verdicts"]:
                prows += (f"<tr><td>{m}</td><td>{v['label']}</td>"
                          f"<td>{v['score']}</td>"
                          f"<td>{esc(v['verdict_ko'])}</td></tr>")
        figs = ""
        if sk in baseline:
            figs += fig(f"out/shot_apply/{baseline[sk]}", "기존 production 스틸")
        for lab, eng in CAND:
            if b.get(eng):
                figs += fig(f"out/shot_apply/{b[eng]}",
                            f"새 배경 {lab}({eng})"
                            + (" ★선택" if pk.get("selected") == lab else ""),
                            sel=pk.get("selected") == lab)
        if cm.get("file"):
            figs += fig(f"out/shot_apply/{cm['file']}",
                        f"최종 스틸 (nb2 합성, bg={cm.get('bg_engine')})")
        secs += f"""
<h2>{esc(sk)} — {esc(s.get('scene_heading'))}</h2>
<p>{esc(s.get('description'))}</p>
<p><b>②구상</b> camera: {esc(con.get('camera_en', ''))}<br>
concept: {esc(con.get('bg_concept_en', ''))}<br>
time/light: {esc(con.get('time_light_en', ''))}</p>
<p><b>③grounding</b> zone=<b>{esc(g.get('map_zone', '?'))}</b>,
markers={esc(', '.join(g.get('anchor_markers', [])))}<br>
camera: {esc(g.get('camera_position_en', ''))}<br>
look: {esc(g.get('look_direction_en', ''))}<br>
in frame: {esc(g.get('in_frame_en', ''))}<br>
<i>{esc(g.get('rationale_ko', ''))}</i></p>
{figs}
<table><tr><th>VLM</th><th>후보</th><th>score</th><th>판정</th></tr>
{prows}</table>
<details><summary>④배경 프롬프트</summary><pre>{esc(b.get('prompt', ''))}</pre></details>
<details><summary>⑥합성 프롬프트 (chars={esc(cm.get('chars_used', []))},
passport 없음={esc(cm.get('chars_missing_passport', []))})</summary>
<pre>{esc(cm.get('prompt', ''))}</pre></details>
"""

    doc = f"""<!doctype html><html lang=ko><head><meta charset=utf-8>
<title>s30 — 맵 기반 샷 적용 체인</title><style>
body{{font-family:'Apple SD Gothic Neo',sans-serif;margin:24px;max-width:1500px}}
figure{{display:inline-block;margin:1%;vertical-align:top}}
img{{width:100%;border:1px solid #ccc}} img.sel{{outline:4px solid #2a7ae2}}
figcaption{{font-size:13px;text-align:center}}
pre{{font-size:11px;background:#f7f7f7;border:1px solid #ddd;padding:8px;
white-space:pre-wrap;max-height:320px;overflow:auto}}
table{{border-collapse:collapse;font-size:12px;width:100%}}
td,th{{border:1px solid #ccc;padding:4px 6px;text-align:left}}
h2{{border-bottom:2px solid #333;padding-bottom:4px;margin-top:36px}}</style>
</head><body>
<h1>s30 — 맵 기반 샷 적용 체인 (2026-07-09)</h1>
<p>자산: top2 맵(배치 SOT)+수정 실사(룩 SOT). 체인=①샷 선택→②배경 구상
→③맵 grounding(마커 인용)→④배경 플레이트 i2/nb2→⑤VLM 선택→⑥passport
합성(nb2). 발명 경계+철제 계단 정정+ID-free(nb2) 적용.</p>
{fig('out/forest_map/top2/top2_map_gpt.png', '배치 SOT — top2 맵', 31)}
{fig('out/forest_map/top2/top2_photo_fixed_nb2.png', '룩 SOT — 수정 실사', 31)}
<h2>① 샷 선택 (실외 8샷 라인)</h2>
<table><tr><th>shot</th><th>선택</th><th>이유</th></tr>{sel_rows}</table>
{secs}
</body></html>"""
    PAGE.write_text(doc, encoding="utf-8")
    print(f"[html] {PAGE}")


STAGES = ["select", "concept", "ground", "bg", "pick", "compose", "html"]

if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--only", default="all", choices=STAGES + ["all"])
    a = ap.parse_args()
    OUTD.mkdir(parents=True, exist_ok=True)
    for st in STAGES:
        if a.only in (st, "all"):
            globals()[f"stage_{st}"]()
