#!/usr/bin/env python3
"""s35 — 프로젝트 전체 선택 샷(58) 풀 런 (2026-07-12, 실험 전용·커밋 금지).

v24s 로직을 전체 샷에 적용:
①LLM 장소 그룹핑(전 샷 → 장소 연속 그룹 + 그룹 장소 서술 EN)
②그룹별 동선 분석(EN, s34 계약 재사용)
③그룹 샷을 ≤6개 시트로 청킹 → i2 콘티(v24 계약) ④그리드 크롭
⑤그룹별 prev 시각 관련성 판정 ⑥샷별 콘티 필요 판정(쉬운 구도 제거)
⑦nb2 스틸 58샷 — 참조=선별 콘티 패널(참조 전용)+prev 스틸+VE 엔티티
  (캐릭터 composite+소품, 배경 제외)+REALIZE+LOCATION lock+시간 잠금
⑧stills_run_full.html — 샷별 참조 이미지 전부+프롬프트 전문 기록.

사용: backend/.venv/bin/python s35_fullrun.py --only
     <groups|movement|conti|crop|prev|conti_need|gen|html> [--groups-filter g1,g2]
"""
import argparse
import html as _html
import json
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 s34_stillrun as S  # noqa: E402 — 스틸 헬퍼 재사용

BASE = HERE
OUTC = BASE / "out" / "conti"
OUTP = BASE / "out" / "conti" / "panels35"
OUTS = BASE / "out" / "conti" / "stills35"
PAGE = BASE / "stills_run_full.html"
PLAN = "s35_full_v1"
DATA = json.load(open(BASE / "pipeline_doc" / "shot_loc_data.json"))
SHOT = {(s["scene"], s["shot"]): s for s in DATA["shots"]}
ALL_TAGS = [f"S{s['scene']}sh{s['shot']}" for s in DATA["shots"]]


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


# ---------- ① 장소 그룹핑 ----------

def stage_groups():
    plan = F.load_plan(PLAN) if (BASE / "plans" / f"{PLAN}.json").exists() else {}
    SYS = "\n".join([
        "당신은 영화 로케이션 스크립터다. 전체 샷 목록(씬 헤딩·샷 텍스트·",
        "배정 장소 데이터 포함)을 보고, 샷들을 '같은 물리적 장소' 그룹으로",
        "묶어라. 장소 연속성이 있는 샷들(같은 공간을 다른 씬에서 재방문",
        "포함)은 한 그룹. 규칙:",
        "1) 모든 샷은 정확히 한 그룹에 속한다(누락·중복 금지).",
        "2) 그룹 안에서 샷 순서는 입력 순서(스토리 순서)를 유지한다.",
        "3) 그룹마다: key(영문 스네이크 슬러그), name_ko(짧은 한국어 표시명),",
        "place_en(영어 1~3문장 장소 서술 — 고정 지형지물 포함, 이미지 생성",
        "프롬프트의 LOCATION lock 으로 쓸 수 있게 구체적으로).",
        "4) 실내/실외가 명확히 다른 공간이면 다른 그룹(예: 같은 건물의",
        "내부와 외부는 분리).",
        "5) 작품 고유명사는 place_en 에 쓰지 말 것(범용 서술).",
    ])
    SCHEMA = {"type": "object", "properties": {"groups": {"type": "array",
        "items": {"type": "object", "properties": {
            "key": {"type": "string"},
            "name_ko": {"type": "string"},
            "place_en": {"type": "string"},
            "shots": {"type": "array", "items": {"type": "string"}}},
            "required": ["key", "name_ko", "place_en", "shots"]}}},
        "required": ["groups"]}
    lines = []
    for s in DATA["shots"]:
        tag = f"S{s['scene']}sh{s['shot']}"
        lines.append(f"{tag} | 헤딩: {s['heading']} | 배정 장소: "
                     f"{', '.join(s.get('ve_locs') or []) or '(없음)'}\n"
                     f"  샷: {s['desc']}")
    res = F.llm("s35_groups", SYS, "전체 샷 목록(순서대로):\n"
                + "\n".join(lines), SCHEMA)
    groups = res["groups"]
    # 검증: 전수·중복 없음
    seen = [t for g in groups for t in g["shots"]]
    missing = [t for t in ALL_TAGS if t not in seen]
    dup = [t for t in set(seen) if seen.count(t) > 1]
    bad = [t for t in seen if t not in ALL_TAGS]
    if missing or dup or bad:
        raise SystemExit(f"그룹핑 검증 실패 missing={missing} dup={dup} bad={bad}")
    plan["groups"] = groups
    F.save_plan(PLAN, plan)
    print(f"[groups] {len(groups)}그룹 / 58샷:",
          {g["key"]: len(g["shots"]) for g in groups})


def _groups(plan):
    return {g["key"]: g for g in plan["groups"]}


# ---------- ② 동선 분석 (s34 계약 재사용) ----------

def stage_movement(groups_filter):
    plan = F.load_plan(PLAN)
    mv = plan.setdefault("movement", {})
    for g in plan["groups"]:
        gkey = g["key"]
        if groups_filter and gkey not in groups_filter:
            continue
        keys = [_key(t) for t in g["shots"]]
        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)
        res = F.llm(f"s35_move_{gkey}", C.MOVEMENT_SYS,
                    f"씬 원문 전문:\n{scene_txt}\n\n샷 목록(각각 동선 판정):\n"
                    f"{shots_txt}", C.MOVEMENT_SCHEMA)
        mv[gkey] = {m["shot"]: {"movement": m["movement_en"],
                                "figures": m["figures_en"]}
                    for m in res["movements"]}
        print(f"[movement] {gkey}: {len(mv[gkey])}건")
    F.save_plan(PLAN, plan)


# ---------- ③ 콘티 시트 (청킹 ≤6, i2) ----------

def _chunks(tags):
    out, cur = [], []
    for t in tags:
        cur.append(t)
        if len(cur) == 6:
            out.append(cur)
            cur = []
    if cur:
        out.append(cur)
    return out


def stage_conti(groups_filter):
    plan = F.load_plan(PLAN)
    sheets = plan.setdefault("sheets", {})
    for g in plan["groups"]:
        gkey = g["key"]
        if groups_filter and gkey not in groups_filter:
            continue
        mv = plan.get("movement", {}).get(gkey, {})
        for i, chunk in enumerate(_chunks(g["shots"])):
            keys = [_key(t) for t in chunk]
            slots, layout, size, ar = C._grid(len(keys))
            prompt = C._prompt(g["place_en"], keys, mv)
            fn = f"conti35_{gkey}_{i}_i2.png"
            sheets[f"{gkey}_{i}"] = {
                "gkey": gkey, "file": fn, "shots": chunk,
                "slots": slots, "prompt": prompt}
            F.img_gpt(f"s35_conti_{gkey}_{i}_i2", prompt,
                      refs=None, size=size, out_path=OUTC / fn)
            print(f"[conti] {gkey}_{i}: {len(chunk)}샷 → {fn}")
    F.save_plan(PLAN, plan)


# ---------- ④ 크롭 ----------

# 거터 오인 시트 — 등분 강제 (육안 검수 결과)
EQUAL_FORCE = {"dense_forest_incident_site_0"}


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)}
    for skey, sh in plan["sheets"].items():
        sheet = Image.open(OUTC / 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.05 < sum(x) / len(x) < total * 0.95]
            if len(centers) == n_expect:
                return centers
            return [total * (k + 1) // (n_expect + 1)
                    for k in range(n_expect)]

        if skey in EQUAL_FORCE:
            v_lines = [W * (k + 1) // cols for k in range(cols - 1)]
            h_lines = [H * (k + 1) // rows for k in range(rows - 1)]
        else:
            v_lines = _lines(dark.mean(axis=0), cols - 1, W) if cols > 1 else []
            h_lines = _lines(dark.mean(axis=1), rows - 1, H) if rows > 1 else []
        xs = [0] + v_lines + [W]
        ys = [0] + h_lines + [H]
        for i, tag in enumerate(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] {skey}: {len(sh['shots'])}패널 v={v_lines} h={h_lines}")


# ---------- ⑤ prev 판정 / ⑥ 콘티 필요 판정 ----------

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


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


# ---------- ⑦ 스틸 생성 ----------

def stage_gen(groups_filter):
    plan = F.load_plan(PLAN)
    prev_map = plan["prev"]
    need_map = plan["conti_need"]
    mv_all = plan.get("movement", {})
    meta = plan.setdefault("gen_meta", {})
    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)
    OUTS.mkdir(parents=True, exist_ok=True)
    for g in plan["groups"]:
        gkey = g["key"]
        if groups_filter and gkey not in groups_filter:
            continue
        for tag in g["shots"]:
            out = OUTS / f"{tag}_nb2.png"
            if out.exists():
                print(f"[gen] {tag} skip(exists)")
                continue
            s = SHOT[_key(tag)]
            m = mv_all.get(gkey, {}).get(tag, {})
            pj = prev_map.get(gkey, {}).get(tag, {})
            prev_tag = pj.get("prev")
            nd = need_map.get(gkey, {}).get(tag, {"need": True,
                                                  "reason": "판정 없음"})
            refs = []
            if nd["need"] and (OUTP / f"{tag}.png").exists():
                refs.append((
                    "STORYBOARD PANEL — a REFERENCE ONLY, never the"
                    " target: use it loosely for camera framing, figure"
                    " placement and depth order. Do NOT photorealize"
                    " this drawing as-is — the SHOT TEXT and the other"
                    " references are authoritative; re-stage the moment"
                    " naturally as a real photograph. Never copy the"
                    " sketch's line style, paper texture, borders,"
                    " simplified geometry or drawing errors.",
                    OUTP / f"{tag}.png"))
            prev_used = None
            if prev_tag and (OUTS / f"{prev_tag}_nb2.png").exists():
                prev_used = prev_tag
                refs.append((
                    "PREVIOUS SHOT STILL — a visually related earlier shot"
                    " of this same place: the location's look, materials,"
                    " fixed features, lighting mood and each person's"
                    " clothing are LOCKED to this photo; never copy its"
                    " camera framing.", OUTS / f"{prev_tag}_nb2.png"))
            ve_ids = ve.get(_key(tag), [])
            if not ve_ids:
                ve_ids = sorted(scene_union.get(_key(tag)[0], set()))
            char_names, prop_names = [], []
            for cid in ve_ids:
                if cid in chars:
                    name, p = chars[cid]
                    if p.exists():
                        char_names.append(name)
                        refs.append((
                            f"CHARACTER REFERENCE — {name}: the exact"
                            " person appearing in this shot; match face,"
                            " hair and build exactly.", p))
                elif cid in props:
                    name, p = props[cid]
                    if p.exists():
                        prop_names.append(name)
                        refs.append((
                            f"PROP REFERENCE — {name}: the exact object"
                            " appearing in this shot; match its look,"
                            " material and wear exactly.", p))
            parts = [
                "Create ONE FINAL photorealistic live-action film still of"
                " the moment below — contemporary South Korea, 2026; all"
                " people are Korean unless stated. TIME OF DAY (lock): "
                + S._time_of(tag) + ".",
                f"SHOT TEXT (authoritative, Korean): {S._soften(s['desc'])}",
                f"LOCATION (lock): {g['place_en']} The shot takes place"
                " here — pick the sub-area of this location that the shot"
                " text implies.",
            ]
            if refs and refs[0][0].startswith("STORYBOARD"):
                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)
            if m.get("movement"):
                parts.append("MOVEMENT (follow exactly): "
                             + S._soften(m["movement"]))
            if m.get("figures"):
                parts.append("FIGURES — size & depth (follow exactly): "
                             + S._soften(m["figures"]))
            if char_names:
                parts.append(
                    "PEOPLE: the SHOT TEXT alone decides whether any person"
                    " is visible in this shot. IF a person appears, they"
                    " must be one of the referenced people ("
                    + ", ".join(char_names)
                    + ") matched exactly to their reference photo — never"
                    " anyone else, and never add a person the shot text"
                    " does not show.")
            else:
                parts.append("No people appear unless the shot text itself"
                             " says so.")
            parts.append("No text, captions, watermarks or annotations"
                         " anywhere.")
            prompt_full = "\n\n".join(parts)
            F.img_nb2(f"s35run_{tag}_nb2", prompt_full, refs,
                      aspect_ratio="16:9", out_path=out)
            meta[tag] = {
                "gkey": gkey,
                "conti_used": bool(refs and refs[0][0].startswith("STORY")),
                "conti_reason": nd["reason"], "prev_used": prev_used,
                "prompt": prompt_full,
                "refs": [{"label": lab,
                          "path": (str(p.relative_to(BASE))
                                   if str(p).startswith(str(BASE))
                                   else str(p))}
                         for lab, p in refs],
            }
            F.save_plan(PLAN, plan)
            print(f"[gen] {tag} 완료 (conti={nd['need']}, prev={prev_used},"
                  f" chars={char_names}, props={prop_names})")


# ---------- ⑧ 갤러리 ----------

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

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

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

    def _ref_rel(path_s):
        if not path_s.startswith("/"):
            return path_s
        src = Path(path_s)
        dst = REFD / src.name
        if not dst.exists():
            shutil.copy(src, dst)
        return f"out/conti/refs35/{src.name}"

    n_conti = sum(1 for m in meta.values() if m.get("conti_used"))
    secs = []
    for g in plan["groups"]:
        gkey = g["key"]
        figs = ""
        for tag in g["shots"]:
            pj = prev_map.get(gkey, {}).get(tag, {})
            nd = need_map.get(gkey, {}).get(tag, {})
            mt = meta.get(tag, {})
            still_rel = f"out/conti/stills35/{tag}_nb2.png"
            badge = ("<span class=on>콘티 사용</span>" if mt.get("conti_used")
                     else "<span class=off>콘티 제거</span>")
            ref_items = ""
            for r in mt.get("refs", []):
                rel = _ref_rel(r["path"])
                ref_items += (
                    f"<figure class=ref><a href='{rel}' target=_blank>"
                    f"<img src='{rel}' loading=lazy></a><figcaption>"
                    f"{esc(_ref_kind(r['label']))}</figcaption></figure>")
            if not ref_items:
                ref_items = "<span class=note>참조 이미지 없음</span>"
            ref_details = "".join(
                f"<li><b>{esc(_ref_kind(r['label']))}</b> — "
                f"<span class=note>{esc(r['label'])}</span> "
                f"<code>{esc(r['path'])}</code></li>"
                for r in mt.get("refs", []))
            figs += (
                f"<div class=shot><h3>{tag} {badge}"
                f"<span class=note> — 콘티 판정: {esc(nd.get('reason', ''))}"
                f" · prev: {esc(str(pj.get('prev') or '없음'))}"
                + (f" ({esc(pj.get('reason', ''))})" if pj.get("prev") else "")
                + "</span></h3>"
                f"<div class=note style='margin:2px 0 8px'>"
                f"{esc(SHOT[_key(tag)]['desc'])}</div>"
                f"<div class=row><figure><a href='{still_rel}'"
                f" target=_blank><img src='{still_rel}' loading=lazy></a>"
                f"<figcaption>nb2 스틸</figcaption></figure>"
                f"<div class=refs><div class=note>참조 이미지"
                f" ({len(mt.get('refs', []))}장):</div>"
                f"<div class=refrow>{ref_items}</div></div></div>"
                f"<details><summary>참조 상세 + 프롬프트 전문</summary>"
                f"<ul>{ref_details}</ul>"
                f"<pre>{esc(mt.get('prompt', '(기록 없음)'))}</pre>"
                f"</details></div>")
        secs.append(
            f"<section><h2>{esc(g['name_ko'])} <span class=k>({gkey} · "
            f"샷 {len(g['shots'])})</span></h2>"
            f"<div class=note>장소 서술(LOCATION lock): "
            f"{esc(g['place_en'])}</div>{figs}</section>")
    PAGE.write_text(f"""<!DOCTYPE html>
<html lang=ko><head><meta charset=utf-8>
<meta name=viewport content="width=device-width, initial-scale=1">
<title>s35 풀 런 — 전체 58샷 (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:6px 0; }}
.k {{ color:#8a939e; font-size:13px; font-weight:400; }}
.row {{ display:flex; gap:14px; align-items:flex-start; margin:10px 0;
        flex-wrap:wrap; }}
figure {{ margin:0; }} figcaption {{ color:#c9d2dc; font-size:12.5px;
          max-width:640px; }}
img {{ max-width:640px; width:100%; border-radius:8px;
      border:1px solid #2a2f36; }}
.note {{ color:#8a939e; }}
.box {{ background:#161b22; border:1px solid #2a2f36; border-radius:8px;
       padding:12px 16px; margin:12px 0; }}
.shot {{ border-top:1px dashed #2a2f36; padding:12px 0; }}
.on {{ background:#1e3a2a; color:#7ee2a8; border-radius:5px;
      padding:1px 8px; font-size:12px; margin-left:6px; }}
.off {{ background:#3a2a1e; color:#e2b57e; border-radius:5px;
       padding:1px 8px; font-size:12px; margin-left:6px; }}
.refs {{ max-width:640px; }}
.refrow {{ display:flex; gap:8px; flex-wrap:wrap; }}
figure.ref img {{ max-width:150px; }}
figure.ref figcaption {{ font-size:11px; max-width:150px; }}
code {{ background:#161b22; padding:0 4px; border-radius:4px;
       font-size:11px; }}
details {{ margin:6px 0; }} summary {{ color:#9ecbff; cursor:pointer; }}
pre {{ background:#161b22; border:1px solid #2a2f36; padding:10px;
      white-space:pre-wrap; font-size:12px; }}
</style></head><body>
<h1>s35 풀 런 — 프로젝트 전체 선택 샷 {len(ALL_TAGS)}개
(콘티 사용 {n_conti} / 제거 {len(meta) - n_conti})</h1>
<div class=box>v24s 로직의 전체 샷 확장: ①LLM 장소 그룹핑(전 샷 →
장소 연속 그룹+장소 서술) → ②그룹별 동선 분석 → ③i2 콘티(v24
실사영화 계약, ≤6샷 시트 청킹) → ④크롭 → ⑤prev 시각 관련성 판정
⑥샷별 콘티 필요 LLM 판정(쉬운 구도는 콘티 제거) → ⑦nb2 스틸.
참조=선별 콘티 패널(참조 전용, 그대로 실사화 금지)+prev 스틸+VE 전
엔티티(캐릭터 composite+소품 reference_face, 배경 L 제외)+REALIZE 원칙
+<b>LOCATION lock</b>(그룹 장소 서술)+씬 헤딩 시간 잠금. 각 샷 카드에
사용된 참조 이미지 전부와 프롬프트 전문 기록. 비교:
<a href='stills_run24s.html'>v24s(6그룹 28샷)</a></div>
{''.join(secs)}
</body></html>""")
    print(f"[html] {PAGE} (콘티 {n_conti}/{len(meta)})")


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--only", required=True,
                    choices=["groups", "movement", "conti", "crop", "prev",
                             "conti_need", "gen", "html"])
    ap.add_argument("--groups-filter", default="")
    a = ap.parse_args()
    gf = [g for g in a.groups_filter.split(",") if g]
    {"groups": lambda: stage_groups(),
     "movement": lambda: stage_movement(gf),
     "conti": lambda: stage_conti(gf),
     "crop": lambda: stage_crop(),
     "prev": lambda: stage_prev(gf),
     "conti_need": lambda: stage_conti_need(gf),
     "gen": lambda: stage_gen(gf),
     "html": lambda: stage_html()}[a.only]()
