#!/usr/bin/env python3
"""s38 — 옥탑 23샷 전체: 경량 레이아웃 콘티 + prev 단독 참조 스틸 (2026-07-13).

x_quad v1~v3 + 체이닝 실험 판정 지식 종합(사용자 확정 규칙):
- 콘티 = 초경량 레이아웃 선화(LIGHT_STYLE — 질감 오염 방지)로 전 샷 시트.
- 스틸 참조 규칙:
  * prev(시각 관련 이전샷, s35 판정) 있으면 → 참조 = 이전샷 실사 '오롯이
    하나만' (콘티·플레이트·캐릭터·소품 전부 제외 — 자세·장소·의상·소품을
    prev 사진이 잠금, x_still_chain 실증)
  * prev 없으면 → 경량 콘티 패널 + 배경 플레이트 + 캐릭터/소품 참조.
- 자세 정본·carried state·pose_fix 교정본(텍스트)은 전 샷 안전망 유지.

사용: backend/.venv/bin/python s38_lightconti_full.py --only
     <conti|crop|stills|html>
"""
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 s35_fullrun as R  # noqa: E402
import s37_rooftop_rebuild as W  # noqa: E402
import s34_stillrun as S  # noqa: E402

OUT = W.OUT.parent / "s38"
OUTP = OUT / "panels"
OUTS = OUT / "stills"
PAGE = HERE / "s38_lightconti_full.html"
PLAN = "s38_light_v1"

LIGHT_STYLE = "\n".join([
    "STYLE — LAYOUT-ONLY THUMBNAIL SKETCH: the lightest possible",
    "director's layout drawing. THIN, CLEAN, UNIFORM PENCIL CONTOUR",
    "LINES ONLY on pure white paper. Absolutely NO shading, NO tone, NO",
    "hatching, NO texture, NO grayscale fill, NO light-and-shadow, NO",
    "facial features beyond a bare head outline, NO clothing detail, NO",
    "material rendering. Each panel contains ONLY: the camera framing,",
    "each figure as a simple correct-proportion human contour in its",
    "exact pose and screen position/size, and the few major set masses",
    "as plain outline shapes. It must read as a quick layout guide,",
    "never as a finished drawing.",
])

# 범용 연기 실현 계약 — 감정·비유 서술의 해부학 문자화 방지 (시나리오 무관)
EXPRESSION_REALISM = "\n".join([
    "EXPRESSIONS ARE ACTED, NEVER ANATOMICAL: when the text describes a",
    "person's eyes, face or presence emotionally or figuratively (vacant,",
    "hollow, dazed, burning, lifeless gaze and the like), it describes an",
    "ACTOR'S PERFORMANCE captured by a real camera — realize it ONLY",
    "through gaze direction, focus, eyelids, facial muscles, stillness",
    "and posture. Every living person's eyes remain anatomically normal",
    "human eyes with a natural iris and pupil, natural sclera and normal",
    "proportions; NEVER whiten, blank out, cloud over, glow, enlarge or",
    "otherwise alter eyeballs, skin or anatomy — unless the story",
    "explicitly declares that being non-human or supernatural in form.",
])

LIGHT_PANEL_LABEL = (
    "LAYOUT SKETCH — a bare thin-line layout guide, a REFERENCE ONLY:"
    " take from it ONLY the camera framing, figure placement, pose and"
    " size/depth order. It carries ZERO visual style — every texture,"
    " material, light and all realism come from the text and the"
    " photographic reference. Never let any line-drawing quality leak"
    " into the output.")


def _plan37():
    return F.load_plan(W.PLAN)


def _sheet_jobs(p37):
    plan35 = W._s35()
    gmap = {g["key"]: g for g in plan35["groups"]}
    jobs = []
    for gk in W.EXT_GROUPS:
        shots = gmap[gk]["shots"]
        for i, chunk in enumerate(R._chunks(shots)):
            jobs.append((gk, i, chunk, gmap[gk]["place_en"]))
    int_tags = p37["int_tags"]
    chunks = [int_tags[:8], int_tags[8:]]
    for i, chunk in enumerate(c for c in chunks if c):
        jobs.append((W.INT_GROUP, i, chunk,
                     gmap[W.INT_GROUP]["place_en"]))
    return jobs


def _panel_lines(p37, mv, chunk, include_time=False):
    lines = []
    for i, tag in enumerate(chunk, 1):
        s = R.SHOT[R._key(tag)]
        m = mv.get(tag, {})
        fix = p37.get("pose_fix", {}).get(tag) or {}
        movement = fix.get("movement_en") or m.get("movement", "")
        figures = fix.get("figures_en") or m.get("figures", "")
        carried = fix.get("carried_en") or (p37.get("int_cont", {})
                                            .get(tag, {})
                                            .get("carried_en", ""))
        line = f"PANEL {i} ({tag})"
        if include_time:
            line += f" — TIME OF DAY (lock): {S._time_of(tag)}."
        line += (f"\n  SHOT TEXT (authoritative, Korean):"
                 f" {S._soften(s['desc'])}")
        if movement:
            line += f"\n  MOVEMENT (follow exactly): {S._soften(movement)}"
        if figures:
            line += ("\n  FIGURES — apparent size & depth (follow"
                     f" exactly): {S._soften(figures)}")
        if carried:
            line += ("\n  CARRIED STATE (persist exactly across panels): "
                     + S._soften(carried))
        lines.append(line)
    return lines


def _pose_clauses(p37, chunk):
    out = []
    for it in p37.get("pose_canon", []):
        if any(t in it["shots"] for t in chunk):
            out.append(
                "IMMOBILE CHARACTER POSE — CANONICAL (identical wherever"
                " this character appears in ANY panel; on any conflict"
                " THIS POSE WINS): " + S._soften(it["pose_en"]))
    return out


def stage_conti():
    p37 = _plan37()
    plan35 = W._s35()
    mv = W._movement(plan35)
    plan = F.load_plan(PLAN) if (HERE / "plans" / f"{PLAN}.json").exists() \
        else {}
    sheets = plan.setdefault("sheets", {})
    OUT.mkdir(parents=True, exist_ok=True)
    for gk, i, chunk, place_en in _sheet_jobs(p37):
        slots, layout, size, _ar = W._grid_n(len(chunk))
        parts = [
            f"Draw ONE storyboard layout sheet: {layout}.",
            LIGHT_STYLE,
            f"THE LOCATION (same in all drawn panels): {place_en}",
        ]
        pc = _pose_clauses(p37, chunk)
        if pc:
            parts.append("\n".join(pc))
        parts += [
            "PANELS (one shot per panel, Korean shot text):",
            "\n".join(_panel_lines(p37, mv, chunk)),
        ]
        if slots > len(chunk):
            parts.append(W.C.BLANK.format(n_shots=len(chunk), slots=slots))
        parts.append(
            "ABSOLUTELY NO TEXT anywhere: the ONLY allowed marking is a"
            " small shot number digit ("
            + ", ".join(str(n) for n in range(1, len(chunk) + 1))
            + ") in a corner INSIDE each drawn panel. The grid fills the"
            " entire image edge-to-edge; panels separated only by thin"
            " straight black gutter lines at the exact stated positions;"
            " ALL PANELS EXACTLY THE SAME SIZE.")
        prompt = "\n\n".join(parts)
        fn = f"conti38_{gk}_{i}.png"
        sheets[f"{gk}_{i}"] = {"gkey": gk, "file": fn, "shots": chunk,
                               "slots": slots, "prompt": prompt}
        F.img_gpt(f"s38_conti_{gk}_{i}", prompt, refs=None, size=size,
                  out_path=OUT / fn)
        print(f"[conti] {gk}_{i}: {len(chunk)}샷 → {fn}")
    F.save_plan(PLAN, plan)


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)}
    for skey, sh in plan["sheets"].items():
        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)
        WIDTH, HEIGHT = 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, WIDTH) \
            if cols > 1 else []
        h_lines = _lines(dark.mean(axis=1), rows - 1, HEIGHT) \
            if rows > 1 else []
        xs = [0] + v_lines + [WIDTH]
        ys = [0] + h_lines + [HEIGHT]
        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}"
              f" h={h_lines}")


def _all_tags_story_order(p37):
    plan35 = W._s35()
    gmap = {g["key"]: g for g in plan35["groups"]}
    tags = [t for gk in W.EXT_GROUPS for t in gmap[gk]["shots"]] \
        + list(p37["int_tags"])
    return sorted(set(tags), key=R._key)


def stage_prev2():
    """prev 판정 v3 — ★유일 기준=배경(장소) 동일성 (2026-07-13 사용자 정정).

    v2 결함: 스토리·인물 연속만으로 prev 를 지정해, 이동 후 다른 장소인
    샷(S13sh4 골목)이 앞 장소(S11sh4 계단)의 배경을 물려받아 장소 이탈.
    v3: 배경이 사실상 같은 지점일 때만 prev, 아니면 null(→플레이트 참조).
    """
    p37 = _plan37()
    tags = _all_tags_story_order(p37)
    SYS = "\n".join([
        "당신은 촬영 연속성 판정가다. 샷 목록(시간순, 샷마다 LOCATION",
        "명시)과 씬 원문 전문을 받는다. 각 샷마다, 앞쪽 샷의 생성 이미지",
        "한 장을 '배경(장소) 앵커' 참조 사진으로 붙일지 판정하라.",
        "(a) prev: ★유일한 기준 = 배경 동일성. 두 샷이 사실상 같은 지점,",
        "같은 카메라 공간(같은 방의 같은 구역, 같은 계단의 같은 위치 등)을",
        "비추고, 앞 샷 이미지에 그 배경이 실제로 보일 때만 지정하라.",
        "다음은 전부 null 이다:",
        "  · 인물이 이동해 다른 지점이 된 경우 — 같은 건물이라도",
        "    계단→옥상, 마당→골목, 실외→실내, 방→거실은 다른 배경이다.",
        "  · 두 샷의 LOCATION 서술이 서로 다른 경우 (무조건 null).",
        "  · 앞 샷이 얼굴 클로즈업·손 인서트 등이라 프레임에 배경이 거의",
        "    안 담겨 장소 앵커 구실을 못 하는 경우 — 이때는 그보다 앞의,",
        "    같은 배경이 넓게 보이는 샷으로 거슬러 지정하는 것은 허용.",
        "  · 같은 배경이라도 시간대(낮/밤 등)가 다른 경우.",
        "★스토리가 이어진다, 같은 사건이다, 같은 인물이 나온다는 것은",
        "근거가 아니다 — 인물의 의상·자세 일관성은 별도 장치가 담당하므로",
        "고려하지 마라. 배경이 거의 똑같은 곳이 아니면 무조건 null.",
        "반드시 목록에서 자기보다 앞의 샷만 지정할 수 있다.",
        "(b) usage_en: prev 가 있으면, 그 참조 사진을 '어떻게' 쓸지",
        "명령문 영어 2~4문장으로 저작하라 — 반드시 두 축을 다 쓸 것:",
        "  · TAKE: 이어받을 것 — 장소의 룩·재질·조명, 지속되는 상태물,",
        "    움직일 수 없는 인물의 자세·위치, 인물의 의상 등 원문상 이",
        "    샷에도 그대로 존재해야 하는 것.",
        "  · EXCLUDE: 배제할 것 — 이 샷의 공간·프레임에 없어야 하는",
        "    가구·인물·소품, 앞 샷 전용의 순간 요소, 다른 서브공간의",
        "    요소. '배경(장소)만 쓰고 인물은 쓰지 말 것', '특정 물체는",
        "    지우고 쓸 것'처럼 구체적으로.",
        "원문 근거로만 판단하고, 작품 고유명사 대신 일반 서술을 써라.",
        "각 샷: shot, prev(태그|null), reason_ko(한 구절 — 배경 동일/",
        "상이 근거), usage_en(prev 없으면 빈 문자열).",
    ])
    SCHEMA = {"type": "object", "additionalProperties": False, "properties": {
        "items": {"type": "array", "items": {
            "type": "object", "additionalProperties": False, "properties": {
                "shot": {"type": "string"},
                "prev": {"type": ["string", "null"]},
                "reason_ko": {"type": "string"},
                "usage_en": {"type": "string"}},
            "required": ["shot", "prev", "reason_ko", "usage_en"]}}},
        "required": ["items"]}
    scene_ids = sorted({R._key(t)[0] for t in tags})
    scenes_txt = "\n\n".join(
        f"[씬 {si}] {R.DATA['scenes'][str(si)]['heading']}\n"
        + R.DATA['scenes'][str(si)]['text'] for si in scene_ids)
    plan35 = W._s35()
    gmap = {g["key"]: g for g in plan35["groups"]}
    tag2g = {t: gk for gk in W.EXT_GROUPS for t in gmap[gk]["shots"]}
    tag2g.update({t: W.INT_GROUP for t in p37["int_tags"]})
    tag2room = {}
    for p_ in p37["int_plates"]:
        for t in p_["covers"]:
            tag2room[t] = p_["key"]
    shots_txt = "\n".join(
        f"{t} [LOCATION: {gmap[tag2g[t]]['place_en']}"
        + (f" | sub-space: {tag2room[t]}" if t in tag2room else "")
        + f"]\n  {R.SHOT[R._key(t)]['desc']}" for t in tags)
    res = F.llm("s38_prev2", SYS,
                f"씬 원문 전문:\n{scenes_txt}\n\n샷 목록(시간순):\n"
                f"{shots_txt}", SCHEMA)
    got = {it["shot"]: it for it in res["items"]}
    missing = [t for t in tags if t not in got]
    if missing:
        raise SystemExit(f"prev2 누락: {missing}")
    for t in tags:
        pv = got[t]["prev"]
        if pv and (pv not in tags or R._key(pv) >= R._key(t)):
            raise SystemExit(f"prev2 위반: {t} → {pv}")
    plan = F.load_plan(PLAN)
    if plan.get("prev2") and not plan.get("prev2_v2_bak"):
        plan["prev2_v2_bak"] = plan["prev2"]  # v2(장면 연속 기준) 보존
    plan["prev2"] = got
    F.save_plan(PLAN, plan)
    for t in tags:
        it = got[t]
        print(f"[prev2] {t} → {it['prev']} | {it['reason_ko'][:40]}")
        if it["usage_en"]:
            print(f"    usage: {it['usage_en'][:100]}")


def stage_stills():
    p37 = _plan37()
    plan35 = W._s35()
    mv = W._movement(plan35)
    prev_all = {}
    for gk in W.EXT_GROUPS + [W.INT_GROUP]:
        prev_all.update(plan35.get("prev", {}).get(gk, {}))
    gmap = {g["key"]: g for g in plan35["groups"]}
    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)
    tag2plate = {}
    for p_ in p37["int_plates"]:
        for t in p_["covers"]:
            tag2plate[t] = W.OUT / f"plate_{p_['key']}.png"
    plan = F.load_plan(PLAN)
    meta = plan.setdefault("still_meta", {})
    OUTS.mkdir(parents=True, exist_ok=True)
    prev2 = plan.get("prev2")
    tag2g = {t: gk for gk in W.EXT_GROUPS for t in gmap[gk]["shots"]}
    tag2g.update({t: W.INT_GROUP for t in p37["int_tags"]})
    if prev2:
        # prev2 = 크로스 그룹 허용 → 스토리 순서로 생성해야 prev 존재
        jobs = [(tag2g[t], t) for t in _all_tags_story_order(p37)]
    else:
        jobs = [(gk, t) for gk in W.EXT_GROUPS
                for t in gmap[gk]["shots"]] \
            + [(W.INT_GROUP, t) for t in p37["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, {})
        fix = p37.get("pose_fix", {}).get(tag) or {}
        movement = fix.get("movement_en") or m.get("movement", "")
        figures = fix.get("figures_en") or m.get("figures", "")
        carried = fix.get("carried_en") or (
            p37.get("int_cont", {}).get(tag, {}).get("carried_en", "")
            if gkey == W.INT_GROUP else "")
        if prev2:
            prev_tag = (prev2.get(tag) or {}).get("prev")
            prev_usage = (prev2.get(tag) or {}).get("usage_en", "")
        else:
            prev_tag = (prev_all.get(tag) or {}).get("prev")
            prev_usage = ""
        prev_used = None
        char_names, prop_names = [], []
        ve_ids = ve.get(R._key(tag), [])
        if not ve_ids:
            ve_ids = sorted(scene_union.get(R._key(tag)[0], set()))
        refs = []
        if prev_tag and (OUTS / f"{prev_tag}_nb2.png").exists():
            # ★사용자 규칙: prev 있으면 참조=이전샷 실사 오롯이 하나만
            prev_used = prev_tag
            refs.append((W.PREV_STILL_LABEL,
                         OUTS / f"{prev_tag}_nb2.png"))
            for cid in ve_ids:
                if cid in chars:
                    char_names.append(chars[cid][0])
        else:
            refs.append((LIGHT_PANEL_LABEL, OUTP / f"{tag}.png"))
            if gkey != W.INT_GROUP:
                # 플레이트 계보 우선순위: bg4(마스터 v2) > bg3 > bg2 > s37
                plate = None
                for pfx_dir, name in ((OUT, f"bg4_{tag}.png"),
                                      (OUT, f"bg3_{tag}.png"),
                                      (OUT, f"bg2_{tag}.png"),
                                      (W.OUT, f"bg_{tag}.png")):
                    if (pfx_dir / name).exists():
                        plate = pfx_dir / name
                        break
            else:
                plate = tag2plate[tag]
            refs.append((W.PLATE_STILL_LABEL, plate))
            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 prev_used:
            parts.append(
                "THIS SHOT CONTINUES THE PREVIOUS SHOT: everything the"
                " attached still established — the place, its fixed"
                " features and wear, each person's clothing and state —"
                " persists. Any person in it who cannot move stays"
                " PRECISELY as photographed (body, pose, contact points,"
                " held objects); only the camera changes.")
            if prev_usage:
                parts.append(
                    "PREVIOUS STILL USAGE (follow exactly — what to take"
                    " from the attached still and what to exclude): "
                    + S._soften(prev_usage))
        pc = _pose_clauses(p37, [tag])
        if pc:
            parts.append("\n".join(pc))
        parts.append(S.REALIZE_STILL)
        parts.append(EXPRESSION_REALISM)
        if movement:
            parts.append("MOVEMENT (follow exactly): "
                         + S._soften(movement))
        if figures:
            parts.append("FIGURES — size & depth (follow exactly): "
                         + S._soften(figures))
        if carried:
            parts.append("CARRIED STATE (persist exactly — must match"
                         " the neighbouring shots of this scene): "
                         + S._soften(carried))
        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: " + ", ".join(char_names)
                + " — 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"s38_still_{tag}", prompt_full, refs,
                  aspect_ratio="16:9", out_path=out)
        meta[tag] = {
            "gkey": gkey, "prev_used": prev_used,
            "prev_usage": prev_usage if prev_used else "",
            "ref_mode": ("prev 단독" if prev_used
                         else "경량콘티+플레이트+엔티티"),
            "prompt": prompt_full,
            "refs": [{"label": lab,
                      "path": (str(p.relative_to(HERE))
                               if str(p).startswith(str(HERE))
                               else str(p))}
                     for lab, p in refs],
        }
        F.save_plan(PLAN, plan)
        print(f"[stills] {tag} 완료 (mode={meta[tag]['ref_mode']},"
              f" prev={prev_used})")


def stage_html():
    import shutil
    p37 = _plan37()
    plan35 = W._s35()
    plan = F.load_plan(PLAN)
    gmap = {g["key"]: g for g in plan35["groups"]}
    prev_all = {}
    for gk in W.EXT_GROUPS + [W.INT_GROUP]:
        prev_all.update(plan35.get("prev", {}).get(gk, {}))
    REFD = OUT / "refs"
    REFD.mkdir(parents=True, exist_ok=True)

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

    def _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/s38/refs/{src.name}"

    sheets_html = "".join(
        f"<figure><a href='out/s38/{sh['file']}' target=_blank>"
        f"<img src='out/s38/{sh['file']}' loading=lazy></a>"
        f"<figcaption>{esc(skey)} ({len(sh['shots'])}샷)</figcaption>"
        f"</figure>"
        for skey, sh in plan["sheets"].items())

    plate_html = ""
    for tag, rec in (plan.get("plate_chain") or {}).items():
        pfx = rec.get("prefix", "bg3")
        cands = ""
        for lab in ("A", "B", "C"):
            rel = f"out/s38/{pfx}_{tag}_{lab.lower()}.png"
            selmark = " ★선택" if rec.get("selected") == lab else ""
            cands += (f"<figure class=pn2><a href='{rel}' target=_blank>"
                      f"<img src='{rel}' loading=lazy></a>"
                      f"<figcaption>{lab}"
                      f" {rec.get('totals', {}).get(lab, '?')}점{selmark}"
                      f"</figcaption></figure>")
        issues = ""
        for m, its in (rec.get("critique") or {}).items():
            for i in its:
                issues += f"<li>[{m}] {esc(i['issue_ko'])}</li>"
        plate_html += (
            f"<div class=shot><h3>{tag}"
            f"<span class=k> — 합산 {esc(str(rec.get('totals')))}"
            f" · 수정 {'생략(결함 0)' if rec.get('fix_skipped') else '적용'}"
            f" · 계보 {esc(pfx)}</span></h3><div class=row>{cands}"
            f"<figure><a href='out/s38/{pfx}_{tag}.png' target=_blank>"
            f"<img src='out/s38/{pfx}_{tag}.png' loading=lazy></a>"
            f"<figcaption><b>최종 플레이트 ({esc(pfx)})</b></figcaption>"
            f"</figure></div>"
            + (f"<details><summary>VLM 결함 목록</summary><ul>{issues}"
               f"</ul></details>" if issues else "")
            + "</div>")
    if plate_html:
        plate_html = (
            "<section><h2>실외 플레이트 — s29 풀 체인 (3롤 → GPT/Gemini"
            " 합산 판정[마스터 일치·지역 사실성 축] → 결함 취합 수정)"
            "</h2><div class=note>마스터="
            "<a href='out/forest_map/top2/top2_photo_fixed_nb2.png'"
            " target=_blank style='color:#9ecbff'>s29 top2 최종본</a>"
            " (45도 항공 전체 부지)</div>" + plate_html + "</section>")

    secs = []
    meta = plan.get("still_meta", {})
    order = [(gk, t) for gk in W.EXT_GROUPS
             for t in gmap[gk]["shots"]] \
        + [(W.INT_GROUP, t) for t in p37["int_tags"]]
    cur = None
    body = ""
    for gkey, tag in order:
        if gkey != cur:
            if cur is not None:
                secs.append(body + "</section>")
            body = (f"<section><h2>{esc(gmap[gkey]['name_ko'])}"
                    f" <span class=k>({gkey})</span></h2>"
                    f"<div class=note>LOCATION lock:"
                    f" {esc(gmap[gkey]['place_en'])}</div>")
            cur = gkey
        mt = meta.get(tag, {})
        mode = mt.get("ref_mode", "?")
        badge = ("<span class=on>prev 단독</span>" if mt.get("prev_used")
                 else "<span class=off>콘티+플레이트</span>")
        ref_items = "".join(
            f"<figure class=pn><a href='{_rel(r['path'])}' target=_blank>"
            f"<img src='{_rel(r['path'])}' loading=lazy></a>"
            f"<figcaption>{esc(r['label'].split('—')[0].strip())}"
            f"</figcaption></figure>"
            for r in mt.get("refs", []))
        pj = (plan.get("prev2") or {}).get(tag) or prev_all.get(tag) or {}
        reason = pj.get("reason_ko") or pj.get("reason", "")
        usage_html = ""
        if mt.get("prev_usage"):
            usage_html = (f"<div class=note>사용 지시:"
                          f" {esc(mt['prev_usage'])}</div>")
        body += (
            f"<div class=shot><h3>{tag} {badge}"
            f"<span class=k> — prev 판정:"
            f" {esc(str(pj.get('prev') or '없음'))}"
            + (f" ({esc(reason)})" if pj.get("prev") else "")
            + f"</span></h3>{usage_html}"
            f"<div class=note>{esc(R.SHOT[R._key(tag)]['desc'])}</div>"
            f"<div class=row>"
            f"<figure><a href='out/s38/stills/{tag}_nb2.png'"
            f" target=_blank><img src='out/s38/stills/{tag}_nb2.png'"
            f" loading=lazy></a><figcaption>nb2 스틸</figcaption></figure>"
            f"<figure class=pn2><a href='out/s38/panels/{tag}.png'"
            f" target=_blank><img src='out/s38/panels/{tag}.png'"
            f" loading=lazy></a><figcaption>경량 콘티 패널"
            + (" (스틸엔 미사용 — prev 단독)" if mt.get("prev_used")
               else " (참조로 사용)")
            + "</figcaption></figure>"
            f"<div class=refs><div class=note>참조"
            f" ({len(mt.get('refs', []))}장) — {esc(mode)}</div>"
            f"<div class=refrow>{ref_items}</div></div></div>"
            f"<details><summary>프롬프트 전문</summary>"
            f"<pre>{esc(mt.get('prompt', ''))}</pre></details></div>")
    if cur is not None:
        secs.append(body + "</section>")

    n_prev = sum(1 for m in meta.values() if m.get("prev_used"))
    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>s38 — 옥탑 23샷: 경량 콘티 + prev 단독 참조</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; max-width:620px; }}
figcaption {{ color:#c9d2dc; font-size:12.5px; }}
img {{ max-width:620px; width:100%; border-radius:8px;
      border:1px solid #2a2f36; }}
figure.pn2 {{ max-width:300px; }} figure.pn2 img {{ max-width:300px; }}
figure.pn2 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: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.pn {{ max-width:150px; }} figure.pn img {{ max-width:150px; }}
figure.pn figcaption {{ font-size:11px; max-width:150px; }}
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>s38 — 옥탑 23샷: 경량 레이아웃 콘티 + prev 단독 참조 스틸
(prev {n_prev} / 콘티+플레이트 {len(meta) - n_prev})</h1>
<div class=box>x_quad v1~v3·체이닝 실험 종합 규칙(사용자 확정, prev 판정
v3 — 배경 동일성 유일 기준):
①콘티=<b>초경량 레이아웃 선화</b>(얇은 윤곽선만 — 질감 오염 방지)
②스틸 참조: <b>prev(배경이 사실상 같은 지점인 이전샷만 — 이동·장소
상이·클로즈업 배경 부재·시간대 상이는 전부 제외) 있으면 이전샷 실사
오롯이 하나만</b>(자세·장소·의상·소품 잠금 — 콘티·플레이트·엔티티
제외) / 없으면 경량 콘티 패널+<b>배경 플레이트</b>+캐릭터·소품.
③자세 정본·carried state·중의어 교정본은 전 샷 텍스트 안전망.
이전 판 비교:
<a href='s37_rooftop_rebuild.html' style='color:#9ecbff'>s37 갤러리</a>
· <a href='x_quad_gallery.html' style='color:#9ecbff'>4분할 실험</a></div>
<section><h2>경량 콘티 시트 ({len(plan['sheets'])}장)</h2>
<div class=row>{sheets_html}</div></section>
{plate_html}
{''.join(secs)}
</body></html>""")
    print(f"[html] {PAGE}")


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--only", required=True,
                    choices=["conti", "crop", "prev2", "stills", "html"])
    a = ap.parse_args()
    {"conti": stage_conti, "crop": stage_crop, "prev2": stage_prev2,
     "stills": stage_stills, "html": stage_html}[a.only]()
