# 야외 3레인 Stage B — 레인1 마커 맵 수직 슬라이스 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 레인1(map_marker) 수직 슬라이스 — LLM geometry 저작 → PIL 결정론 마커 맵 합성 → marker-free 스케치(leakage VLM gate) → 스틸(no-prev→prev) 3샷 canary.

**Architecture:** ① LLM은 normalized(0..1) geometry JSON만 저작(entity slot E1..En + camera origin/look_target), 코드가 fail-closed 검증 후 PIL로 shot_map_control.png를 결정론 합성(맵 PNG는 immutable SOT). ② 스케치는 control PNG를 참조한 gpt-image-2 단일 프레임(LIGHT_FRAME 계약 + 마커 leakage 금지, Gemini VLM gate로 잔재 검출 시 재생성). ③ 스틸은 기존 multiroll 프리미티브 재사용 — 참조=[스케치+엔티티 passport(+같은 세그먼트 직전 selected still)], photo canon 참조 0. 같은 세그먼트 첫 선정 still이 zone visual anchor.

**Tech Stack:** PIL(Pillow 12.1.1), litellm call_structured(멀티모달), gpt-image-2(스케치), nb2+multiroll_select(스틸), pytest(결정론만).

## Global Constraints

- **LLM 전달 데이터 절대 자르지 않음** (씬 원문 전문, `[:N]` 금지 — CLAUDE.md 절대 규칙)
- **시나리오 의존 코딩 금지**: rule/prompt/code에 작품 고유명 0, fixture=`SAMPLE_*`. 마커 이미지 안에 고유명·장소명 0(슬롯 코드 E1../CAM만)
- **글자/substring 의미 판단 금지**: 의미 판정=LLM structured only. 코드 검증은 형식·범위·기하 무결성만
- **TDD는 deterministic 영역만**: 스키마/validator/PIL 렌더/프롬프트 조립만 유닛. LLM geometry·스케치·스틸 완성도=canary 육안
- **프롬프트 팩=신규 버전 디렉토리** `prompts/_base/<module>/1.YYYYMMDDHHmm/` (리포 루트, `.md` 확장자 — Stage A 실측 관례)
- **photo canon 참조 0** (레인1 acceptance): 스케치·스틸 어느 단계에도 outdoor_place_canon 실사 마스터를 참조로 넣지 않는다. base map(탑다운)만 geometry 저작·control 합성에 사용
- **base map immutable**: 렌더는 복사본에만 — base bytes 무변경을 테스트로 잠금
- **canary 러너·갤러리 스크립트=scratchpad, 커밋 금지**. 스텝/디스패치 배선은 Stage D(이 플랜 범위 아님)
- **모델 분업**: geometry 저작=gpt(분석 주력), leakage 판정·스틸 판정=gemini-pro, 스케치=gpt-image-2, 스틸=nb2
- 개발 사이클: 개발 → Codex diff 리뷰 → 사용자 육안. 커밋은 태스크 단위

## 참조 컨텍스트 (implementer가 알아야 할 기존 계약)

- **Stage A 산출**(5회차 8ad94c03/e666c62b, `outdoor_lane_plan/manifest.json`): `data.groups[gid].plan.segments[]`(segment_id/label_en/dominant_mode) + `shot_bindings[]`(scene_index/shot_index/segment_id/lane). canary 대상은 lane=="map_marker"만
- **base map**: `outdoor_place_canon/manifest.json` → `data.groups[gid].map_png_path` (실측: bg_coastal_bus_stop/bg_harbor_rocks 존재)
- **재시도 뼈대 원형**: `backend/app/modules/pipeline/outdoor_shot_grounding.py`의 `run_outdoor_shot_grounding_shot`(맵 PNG 첨부 멀티모달+위반 힌트 재시도) — `build_legend_block`/`build_shot_block`/`_png_part` 재사용
- **스틸 프리미티브**: `multiroll_select.run_multiroll_select`(3롤→판정→critique→i2i fix, record/지문), `multiroll_gemini.make_nb2_gen_fn/make_gemini_judge_fn/make_gemini_critique_fn/resolve_judge_texts`, `multiroll_select.build_judge_schema/build_critique_schema/roll_labels`
- **연속성 텍스트**: `shot_continuity` CP(pose_canon/carried/pose_fix), `shot_conti_light.build_pose_clauses`
- **canary 경계 실측**: bg_harbor_rocks SEG_01에 S26sh2→S26sh9 동일 세그먼트 체인(no-prev→prev 시연), bg_coastal_bus_stop은 S15sh2=SEG_01/S15sh6=SEG_02(세그먼트 상이 → 각각 no-prev가 정상)

## 이 플랜의 파일 구조

- `backend/app/modules/pipeline/outdoor_marker_map.py` — 결정론 코어+LLM 러너: geometry 스키마·validator·PIL 렌더러·스케치 프롬프트 조립·leakage 스키마·`run_marker_geometry_shot`
- `prompts/_base/outdoor_marker_geometry/1.202607150100/` — geometry 저작 계약(system/user_template)
- `prompts/_base/marker_map_sketch/1.202607150110/` — 스케치 계약(sketch_head/light_frame/no_marker/no_text/leakage_judge)
- `backend/tests/pipeline/test_outdoor_marker_map.py` — 결정론 테스트
- `<scratchpad>/run_lane1_canary.py`, `<scratchpad>/build_lane1_gallery.py` — canary 러너·갤러리(커밋 금지)

## Prev(배경 연속성) 규칙 — Stage B 확정 근사

설계 결정 3의 "같은 세그먼트에서 실제 겹침 있는 가장 가까운 앞쪽 selected still"에서 '실제 겹침' 판정 계약은 Stage D에서 확정한다. Stage B canary는 **같은 segment_id의 스토리 순서상 가장 가까운 앞쪽 완료 still**을 `previous_background_only` 역할로 참조(usage 절: "고정 배경 지형·톤만, 인물·포즈·구도·시간·조명 복사 금지, 현재 SHOT TEXT가 SOT"). 세그먼트가 다르면 no-prev가 정상. 이 근사는 갤러리·Codex 리뷰에 명시 공유한다.

---

### Task B1: geometry 스키마 + fail-closed validator + PIL 마커 렌더러 (결정론)

**Files:**
- Create: `backend/app/modules/pipeline/outdoor_marker_map.py`
- Test: `backend/tests/pipeline/test_outdoor_marker_map.py`

**Interfaces:**
- Produces:
  - `MAX_SLOTS = 6`
  - `build_marker_geometry_schema(max_slots: int = MAX_SLOTS) -> dict` — LLM structured 스키마(slot enum E1..E{max}, x/y 0..1, camera origin/look_target, rationale_ko)
  - `validate_marker_geometry(geometry: dict) -> list[str]` — 위반 리스트(빈 리스트=통과): bounds/슬롯 순차·중복/subject_en/방향 벡터 성립(dist≥0.02)
  - `render_marker_map(base_png: bytes, geometry: dict) -> bytes` — 결정론 PNG 합성(엔티티 원+슬롯 라벨, 카메라 원점+시야 콘+방향선). base 불변, 고유명 0

- [ ] **Step 1: 실패 테스트 작성** — `backend/tests/pipeline/test_outdoor_marker_map.py`

```python
"""outdoor_marker_map 결정론 테스트 — 스키마/validator/PIL 렌더만.

LLM geometry 저작 완성도는 검증하지 않는다. fixture 전부 시나리오 중립.
"""
import io

import pytest

from app.modules.pipeline.outdoor_marker_map import (
    MAX_SLOTS,
    build_marker_geometry_schema,
    render_marker_map,
    validate_marker_geometry,
)


def _geometry(**over):
    base = {
        "entity_placements": [
            {"slot": "E1", "subject_en": "running adult figure",
             "x": 0.42, "y": 0.61},
            {"slot": "E2", "subject_en": "seated adult figure",
             "x": 0.55, "y": 0.30},
        ],
        "camera": {
            "origin": {"x": 0.80, "y": 0.85},
            "look_target": {"x": 0.45, "y": 0.45},
        },
        "rationale_ko": "SAMPLE 근거",
    }
    base.update(over)
    return base


def _base_png(size=(320, 200)) -> bytes:
    from PIL import Image

    buf = io.BytesIO()
    Image.new("RGB", size, (240, 236, 228)).save(buf, format="PNG")
    return buf.getvalue()


def test_schema_locks_slot_enum_and_bounds():
    schema = build_marker_geometry_schema()
    pl = schema["properties"]["entity_placements"]["items"]["properties"]
    assert pl["slot"]["enum"] == [f"E{i}" for i in range(1, MAX_SLOTS + 1)]
    assert pl["x"]["minimum"] == 0 and pl["x"]["maximum"] == 1
    cam = schema["properties"]["camera"]["properties"]
    assert set(cam.keys()) == {"origin", "look_target"}


def test_validate_passes_clean():
    assert validate_marker_geometry(_geometry()) == []


def test_validate_rejects_out_of_bounds_and_bad_slot():
    g = _geometry()
    g["entity_placements"][0]["x"] = 1.4
    assert any("범위" in v for v in validate_marker_geometry(g))
    g2 = _geometry()
    g2["entity_placements"][0]["slot"] = "X1"
    assert any("slot" in v for v in validate_marker_geometry(g2))


def test_validate_rejects_duplicate_or_nonsequential_slots():
    g = _geometry()
    g["entity_placements"][1]["slot"] = "E1"
    assert any("중복" in v for v in validate_marker_geometry(g))
    g2 = _geometry()
    g2["entity_placements"][1]["slot"] = "E3"  # E2 건너뜀
    assert any("순차" in v for v in validate_marker_geometry(g2))


def test_validate_rejects_empty_subject_and_degenerate_camera():
    g = _geometry()
    g["entity_placements"][0]["subject_en"] = "  "
    assert any("subject_en" in v for v in validate_marker_geometry(g))
    g2 = _geometry()
    g2["camera"]["look_target"] = {"x": 0.801, "y": 0.85}  # origin 과 거의 동일
    assert any("방향 벡터" in v for v in validate_marker_geometry(g2))


def test_render_is_deterministic_and_leaves_base_untouched():
    base = _base_png()
    base_copy = bytes(base)
    out1 = render_marker_map(base, _geometry())
    out2 = render_marker_map(base, _geometry())
    assert out1 == out2                     # 결정론
    assert base == base_copy                # base immutable
    assert out1 != base                     # 마커가 실제로 그려짐
    from PIL import Image

    im = Image.open(io.BytesIO(out1))
    assert im.size == (320, 200)            # 크기 보존


def test_render_rejects_invalid_geometry():
    g = _geometry()
    g["entity_placements"] = []
    with pytest.raises(ValueError):
        render_marker_map(_base_png(), g)
```

- [ ] **Step 2: 실패 확인**

Run: `cd /Users/manta/Documents/Projects/TheRoad-I1/backend && .venv/bin/pytest tests/pipeline/test_outdoor_marker_map.py -v`
Expected: FAIL (`ModuleNotFoundError: app.modules.pipeline.outdoor_marker_map`)

- [ ] **Step 3: 구현** — `backend/app/modules/pipeline/outdoor_marker_map.py`

```python
"""레인1 마커 맵 — geometry 스키마·검증·결정론 합성 (설계 v2 Stage B).

LLM 은 normalized(0..1) geometry JSON 만 저작한다(합의 결정 2). 코드는
스키마 잠금·fail-closed 검증·PIL 결정론 합성만 담당 — base map PNG 는
immutable SOT(복사본에만 그림), 이미지 안에 고유명·장소명 0(슬롯 코드
E1../CAM 만).
"""
from __future__ import annotations

import io
import math
import re
from typing import Any, Dict, List

MAX_SLOTS = 6
_SLOT_RE = re.compile(r"^E[1-9]$")
_MIN_CAMERA_DIST = 0.02

_MODULE = "outdoor_marker_geometry"

PROMPT_VERSION_MAP = {
    "1": "1.202607150100",
}

# 렌더 스타일 상수 — 결정론의 일부 (변경 시 canary 재검증)
_STYLE = {
    "entity_fill": (220, 50, 50, 230),
    "entity_outline": (255, 255, 255, 255),
    "camera_color": (40, 90, 220, 255),
    "cone_fill": (40, 90, 220, 60),
    "entity_radius_frac": 0.018,
    "line_width_frac": 0.004,
    "cone_half_angle_deg": 28.0,
}


def resolve_prompt_version(version: str) -> str:
    if version not in PROMPT_VERSION_MAP:
        raise ValueError(f"outdoor_marker_geometry 프롬프트 버전 없음: {version}")
    return PROMPT_VERSION_MAP[version]


def build_marker_geometry_schema(max_slots: int = MAX_SLOTS) -> Dict[str, Any]:
    """normalized geometry 스키마 — slot enum·0..1 bounds 잠금."""
    point = {
        "type": "object",
        "properties": {
            "x": {"type": "number", "minimum": 0, "maximum": 1},
            "y": {"type": "number", "minimum": 0, "maximum": 1},
        },
        "required": ["x", "y"],
        "additionalProperties": False,
    }
    placement = {
        "type": "object",
        "properties": {
            "slot": {"enum": [f"E{i}" for i in range(1, max_slots + 1)]},
            "subject_en": {"type": "string", "minLength": 3},
            "x": {"type": "number", "minimum": 0, "maximum": 1},
            "y": {"type": "number", "minimum": 0, "maximum": 1},
        },
        "required": ["slot", "subject_en", "x", "y"],
        "additionalProperties": False,
    }
    return {
        "type": "object",
        "properties": {
            "entity_placements": {
                "type": "array", "items": placement,
                "minItems": 1, "maxItems": max_slots,
            },
            "camera": {
                "type": "object",
                "properties": {"origin": point, "look_target": point},
                "required": ["origin", "look_target"],
                "additionalProperties": False,
            },
            "rationale_ko": {"type": "string", "minLength": 5},
        },
        "required": ["entity_placements", "camera", "rationale_ko"],
        "additionalProperties": False,
    }


def _check_point(label: str, pt: Any, violations: List[str]) -> None:
    if not isinstance(pt, dict):
        violations.append(f"{label} 가 객체 아님")
        return
    for k in ("x", "y"):
        val = pt.get(k)
        if not isinstance(val, (int, float)) or not (0 <= val <= 1):
            violations.append(f"{label}.{k}={val!r} 범위(0..1) 밖")


def validate_marker_geometry(geometry: Dict[str, Any]) -> List[str]:
    """결정론 무결성 검증 — 위반 리스트 반환 (fail-closed 재시도용)."""
    violations: List[str] = []
    placements = geometry.get("entity_placements") or []
    if not placements:
        violations.append("entity_placements 비어 있음")
    seen: List[str] = []
    for i, pl in enumerate(placements):
        slot = pl.get("slot") or ""
        if not _SLOT_RE.match(slot):
            violations.append(f"[{i}] slot {slot!r} 형식 위반 (E1..E9)")
        elif slot in seen:
            violations.append(f"slot {slot} 중복")
        seen.append(slot)
        if not (pl.get("subject_en") or "").strip():
            violations.append(f"{slot or i} subject_en 비어 있음")
        _check_point(f"{slot or i}", pl, violations)
    expected = [f"E{i}" for i in range(1, len(placements) + 1)]
    if placements and sorted(set(seen)) != expected:
        violations.append(
            f"슬롯은 E1 부터 빠짐없이 순차여야 함 (현재 {sorted(set(seen))})")
    cam = geometry.get("camera") or {}
    _check_point("camera.origin", cam.get("origin"), violations)
    _check_point("camera.look_target", cam.get("look_target"), violations)
    try:
        dist = math.hypot(
            cam["look_target"]["x"] - cam["origin"]["x"],
            cam["look_target"]["y"] - cam["origin"]["y"],
        )
        if dist < _MIN_CAMERA_DIST:
            violations.append(
                f"camera 방향 벡터 미성립 — origin≈look_target "
                f"(dist={dist:.4f} < {_MIN_CAMERA_DIST})")
    except (KeyError, TypeError):
        pass  # 좌표 결측은 위 range 검사가 이미 보고
    return violations


def render_marker_map(base_png: bytes, geometry: Dict[str, Any]) -> bytes:
    """base map 위 마커 결정론 합성 → PNG bytes.

    base 는 불변(메모리 복사본에 그림). 텍스트는 슬롯 코드(E1..)와
    'CAM' 만 — 고유명·장소명 0 (합의 결정 2).
    """
    violations = validate_marker_geometry(geometry)
    if violations:
        raise ValueError(f"invalid geometry: {'; '.join(violations)}")

    from PIL import Image, ImageDraw, ImageFont

    base = Image.open(io.BytesIO(base_png)).convert("RGBA")
    overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
    draw = ImageDraw.Draw(overlay)
    w, h = base.size
    unit = min(w, h)
    r = max(6, int(unit * _STYLE["entity_radius_frac"]))
    lw = max(2, int(unit * _STYLE["line_width_frac"]))
    font = ImageFont.load_default()

    cam = geometry["camera"]
    ox, oy = cam["origin"]["x"] * w, cam["origin"]["y"] * h
    tx, ty = cam["look_target"]["x"] * w, cam["look_target"]["y"] * h
    ang = math.atan2(ty - oy, tx - ox)
    half = math.radians(_STYLE["cone_half_angle_deg"])
    length = math.hypot(tx - ox, ty - oy)
    p1 = (ox + length * math.cos(ang - half),
          oy + length * math.sin(ang - half))
    p2 = (ox + length * math.cos(ang + half),
          oy + length * math.sin(ang + half))
    draw.polygon([(ox, oy), p1, p2], fill=_STYLE["cone_fill"])
    draw.line([(ox, oy), (tx, ty)], fill=_STYLE["camera_color"], width=lw)
    draw.ellipse(
        [ox - r, oy - r, ox + r, oy + r],
        fill=_STYLE["camera_color"], outline=_STYLE["entity_outline"],
        width=max(1, lw // 2),
    )
    draw.text((ox + r + 2, oy - r), "CAM",
              fill=_STYLE["camera_color"], font=font)

    for pl in geometry["entity_placements"]:
        ex, ey = pl["x"] * w, pl["y"] * h
        draw.ellipse(
            [ex - r, ey - r, ex + r, ey + r],
            fill=_STYLE["entity_fill"], outline=_STYLE["entity_outline"],
            width=max(1, lw // 2),
        )
        draw.text((ex + r + 2, ey - r), pl["slot"],
                  fill=_STYLE["entity_fill"], font=font)

    out = Image.alpha_composite(base, overlay)
    buf = io.BytesIO()
    out.save(buf, format="PNG")
    return buf.getvalue()
```

- [ ] **Step 4: 통과 확인**

Run: `cd /Users/manta/Documents/Projects/TheRoad-I1/backend && .venv/bin/pytest tests/pipeline/test_outdoor_marker_map.py -v`
Expected: 7 passed

- [ ] **Step 5: Commit**

```bash
git add backend/app/modules/pipeline/outdoor_marker_map.py backend/tests/pipeline/test_outdoor_marker_map.py
git commit -m "feat(lane1): marker geometry 스키마·fail-closed 검증·PIL 결정론 합성 (Stage B1)"
```

### Task B2: geometry 저작 프롬프트 팩 + LLM 러너

**Files:**
- Create: `prompts/_base/outdoor_marker_geometry/1.202607150100/system.md`
- Create: `prompts/_base/outdoor_marker_geometry/1.202607150100/user_template.md`
- Modify: `backend/app/modules/pipeline/outdoor_marker_map.py` (러너 append)
- Test: `backend/tests/pipeline/test_outdoor_marker_map.py` (러너 테스트 append)

**Interfaces:**
- Consumes: B1 `build_marker_geometry_schema`/`validate_marker_geometry`/`resolve_prompt_version`; 기존 `outdoor_shot_grounding.build_legend_block`/`build_shot_block`/`_png_part` 패턴, `prompt_loader.load_prompt`, `llm_client.call_structured`
- Produces: `run_marker_geometry_shot(*, spec, shot, scene_text, map_png, segment_label_en, prompt_version="1", call_structured_fn=None, project_config=None, opik_metadata=None, max_attempts=3) -> {"geometry": dict, "attempts": int}` — 위반 힌트 재시도, 소진 시 `AppError(step.contract_violation.outdoor_marker_geometry)`

- [ ] **Step 1: system.md 작성** (범용 계약 — 작품 고유명 0)

```
You are a film pre-production spatial planner working on a TOP-DOWN site
plan image (attached). Coordinate system: x runs left(0.0) to right(1.0),
y runs top(0.0) to bottom(1.0), both normalized to the image.

For ONE shot, author the staging geometry as JSON:

1. entity_placements: where each person/moving subject of THIS shot
   stands on the map at THIS moment. Use slots E1, E2, ... in order,
   one per subject. subject_en is a short GENERIC English description
   ("running young woman", "parked bicycle") — NEVER a proper name.
   Place subjects consistently with the scene text: distances and
   directions between them are the whole point of this map.
2. camera: origin = where the camera physically stands on the map,
   look_target = the point it looks at. The vector origin→look_target
   is the shooting direction. Respect the shot text's framing (a wide
   shot from behind means the camera is far behind the subjects).
3. rationale_ko: 한국어로 배치 근거를 간단히.

Anchor positions to the mapped elements in the legend (by their
descriptive names). Do not invent terrain that is not on the map.
Output strictly in the given JSON schema.
```

- [ ] **Step 2: user_template.md 작성**

```
PLACE SPEC (mapped elements — anchor your coordinates to these):
{legend_block}

ZONES:
{zones_block}

PLACE SEGMENT for this shot (spatial-consistency focus):
{segment_label}

THE SHOT (stage exactly this moment):
{shot_block}

FULL SCENE TEXT (authoritative context — do not stage other moments):
{scene_text_block}
```

- [ ] **Step 3: 러너 테스트 append** (test_outdoor_marker_map.py)

```python
def _spec():
    return {
        "zone_labels_en": ["Open Field"],
        "items": [{"code": "P1", "kind": "gate",
                   "name_en": "front entry gate",
                   "placement_en": "south edge"}],
    }


def _shot():
    return {"scene_index": 3, "shot_index": 1,
            "description": "달려가는 인물의 뒷모습 와이드"}


def _run_kwargs(fake_fn):
    return dict(
        spec=_spec(),
        shot=_shot(),
        scene_text="그가 달려간다. 멀어진다.",
        map_png=_base_png(),
        segment_label_en="open approach path",
        call_structured_fn=fake_fn,
    )


def test_run_ok_returns_validated_geometry():
    g = _geometry()
    captured = {}

    def fake(step, system, user, schema, **kw):
        captured["user"] = user
        return g

    from app.modules.pipeline.outdoor_marker_map import (
        run_marker_geometry_shot,
    )
    out = run_marker_geometry_shot(**_run_kwargs(fake))
    assert out["attempts"] == 1
    assert out["geometry"]["camera"]["origin"]["x"] == 0.80
    # 멀티모달 파트: 맵 PNG 첨부 + 씬 전문/세그먼트 라벨 포함
    texts = [p.get("text", "") for p in captured["user"]
             if p.get("type") == "text"]
    joined = "\n".join(texts)
    assert "open approach path" in joined
    assert "그가 달려간다. 멀어진다." in joined
    assert any(p.get("type") == "image_url" for p in captured["user"])


def test_run_retries_on_invalid_geometry_then_ok():
    bad = _geometry()
    bad["entity_placements"][0]["x"] = 1.7
    responses = [bad, _geometry()]
    calls = []

    def fake(step, system, user, schema, **kw):
        calls.append(user)
        return responses[len(calls) - 1]

    from app.modules.pipeline.outdoor_marker_map import (
        run_marker_geometry_shot,
    )
    out = run_marker_geometry_shot(**_run_kwargs(fake))
    assert out["attempts"] == 2
    assert "재시도" in calls[1][-1]["text"]


def test_run_exhausts_raises_app_error():
    bad = _geometry()
    bad["camera"]["look_target"] = dict(bad["camera"]["origin"])

    def fake(step, system, user, schema, **kw):
        return bad

    from app.core.errors import AppError
    from app.modules.pipeline.outdoor_marker_map import (
        run_marker_geometry_shot,
    )
    with pytest.raises(AppError) as ei:
        run_marker_geometry_shot(**_run_kwargs(fake), max_attempts=2)
    assert ei.value.code == "step.contract_violation.outdoor_marker_geometry"
```

- [ ] **Step 4: 실패 확인** — `cd /Users/manta/Documents/Projects/TheRoad-I1/backend && .venv/bin/pytest tests/pipeline/test_outdoor_marker_map.py -v` → 신규 3건 FAIL (`ImportError`)

- [ ] **Step 5: 러너 구현** (outdoor_marker_map.py에 append)

```python
def run_marker_geometry_shot(
    *,
    spec: Dict[str, Any],
    shot: Dict[str, Any],
    scene_text: str,
    map_png: bytes,
    segment_label_en: str,
    prompt_version: str = "1",
    call_structured_fn=None,
    project_config: Dict[str, Any] | None = None,
    opik_metadata: Dict[str, Any] | None = None,
    max_attempts: int = 3,
) -> Dict[str, Any]:
    """샷 1개 geometry 저작 — 무결성 위반 시 위반 힌트 재시도.

    반환 {"geometry": <검증 통과 결과>, "attempts": n}.
    소진 시 AppError(step.contract_violation.outdoor_marker_geometry).
    """
    from app.core.errors import AppError

    if call_structured_fn is None:
        from app.modules.llm.llm_client import call_structured

        call_structured_fn = call_structured

    from app.modules.pipeline.outdoor_shot_grounding import (
        _png_part,
        build_legend_block,
        build_shot_block,
    )
    from app.modules.prompt_loader import load_prompt

    resolved = resolve_prompt_version(prompt_version)
    system = load_prompt(_MODULE, "system", version=resolved)
    template = load_prompt(_MODULE, "user_template", version=resolved)
    schema = build_marker_geometry_schema()

    filled = template
    for key, val in {
        "legend_block": build_legend_block(spec),
        "zones_block": "\n".join(
            f"- {z}" for z in spec.get("zone_labels_en", []) or []
        ),
        "segment_label": segment_label_en,
        "shot_block": build_shot_block(shot),
        # 씬 원문 전문 — 절대 자르지 않는다 (CLAUDE.md 절대 규칙)
        "scene_text_block": scene_text,
    }.items():
        filled = filled.replace("{" + key + "}", val)
    base_parts = [
        {"type": "text", "text": "TOP-DOWN SITE PLAN of the filming property:"},
        _png_part(map_png),
        {"type": "text", "text": filled},
    ]

    attempts = 0
    parts = base_parts
    last: List[str] = []
    while attempts < max_attempts:
        attempts += 1
        result = call_structured_fn(
            _MODULE, system, parts, schema,
            project_config=project_config,
            schema_name=_MODULE,
            opik_metadata=opik_metadata,
        )
        violations = validate_marker_geometry(result or {})
        if not violations:
            return {"geometry": result, "attempts": attempts}
        last = violations
        hint = "\n".join(
            ["", "", "[재시도 — 직전 응답이 아래 계약을 위반했습니다. 전부",
             " 고쳐서 전체 결과를 다시 출력하세요:]"]
            + [f"  - {v}" for v in violations]
        )
        parts = base_parts + [{"type": "text", "text": hint}]

    raise AppError(
        code="step.contract_violation.outdoor_marker_geometry",
        message=f"marker geometry 계약 위반 (attempts={max_attempts}): "
                + "; ".join(last[:8]),
        status_code=422,
    )
```

주의: `outdoor_shot_grounding._png_part`가 언더스코어 프라이빗이므로 import가 관례에 어긋나면(구현 시 확인) `multiroll_gemini.png_part`(공개, 동일 기능)로 대체한다 — 그 경우 테스트 assert는 변경 불필요.

- [ ] **Step 6: 통과 확인** — `cd /Users/manta/Documents/Projects/TheRoad-I1/backend && .venv/bin/pytest tests/pipeline/test_outdoor_marker_map.py -v` → 10 passed

- [ ] **Step 7: Commit**

```bash
git add prompts/_base/outdoor_marker_geometry backend/app/modules/pipeline/outdoor_marker_map.py backend/tests/pipeline/test_outdoor_marker_map.py
git commit -m "feat(lane1): marker geometry 저작 팩 v1 + fail-closed LLM 러너 (Stage B2)"
```

### Task B3: marker_map 스케치 프롬프트 팩 + 조립 + leakage judge 스키마

**Files:**
- Create: `prompts/_base/marker_map_sketch/1.202607150110/sketch_head.md`
- Create: `prompts/_base/marker_map_sketch/1.202607150110/light_frame.md`
- Create: `prompts/_base/marker_map_sketch/1.202607150110/no_marker.md`
- Create: `prompts/_base/marker_map_sketch/1.202607150110/no_text.md`
- Create: `prompts/_base/marker_map_sketch/1.202607150110/leakage_judge.md`
- Modify: `backend/app/modules/pipeline/outdoor_marker_map.py` (조립·스키마 append)
- Test: `backend/tests/pipeline/test_outdoor_marker_map.py` (조립 테스트 append)

**Interfaces:**
- Consumes: B1 geometry shape, 기존 `shot_conti_light.build_pose_clauses`(호출부는 canary — 여기서는 pose_clauses 인자로만 받음)
- Produces:
  - `SKETCH_PACK_MODULE = "marker_map_sketch"`, `resolve_sketch_pack_version(version: str) -> str`
  - `build_marker_sketch_prompt(*, shot_desc: str, place_text: str, geometry: dict, pose_clauses: list[str], carried_en: str, prompt_version: str = "1") -> str`
  - `build_leakage_schema() -> dict` — `{has_marker_leakage: bool, details_ko: str}` (판정 호출은 canary에서 gemini-pro `call_structured` 멀티모달)

- [ ] **Step 1: 프롬프트 팩 작성**

`sketch_head.md`:
```
Convert the attached TOP-DOWN MARKER MAP into a single GROUND-LEVEL
storyboard frame (16:9), seen from the camera marked on the map.

How to read the map: each red circle labeled E1, E2, ... is where a
subject stands. The blue dot labeled CAM is the camera position; the
blue wedge shows its viewing direction and field of view. Draw the
scene AS SEEN BY THAT CAMERA: subjects appear at the distances,
directions and relative depths the map implies. Subjects outside the
blue wedge are out of frame.
```

`light_frame.md` (기존 shot_conti_light light_frame 계약과 동일 취지 — control_kind=marker_map 전용 신판):
```
STYLE — LIGHT STORYBOARD FRAME: thin uniform dark outlines on plain
paper. No shading, no hatching, no texture, no facial features beyond
a minimal head shape, no costume detail. Human figures are correctly
proportioned full-body outlines in their canonical poses. Environment
is reduced to a few placement lines (ground line, key landmark
silhouettes) — just enough to read positions and depth.
```

`no_marker.md`:
```
CRITICAL — THE MAP IS A STAGING DIAGRAM, NOT CONTENT: do NOT draw the
map itself, and do NOT draw any circles, wedges, dots, slot codes
(E1, E2, CAM) or any other marker symbols in the frame. The output is
a clean ground-level storyboard drawing only.
```

`no_text.md`:
```
No text, no letters, no numbers, no captions anywhere in the image.
```

`leakage_judge.md` (Gemini VLM gate system — 판정만):
```
You are checking ONE storyboard sketch image for marker leakage.
The sketch was generated from a top-down staging map that contains
red circles with codes (E1, E2, ...), a blue camera dot labeled CAM
and a blue viewing wedge. Answer strictly in the JSON schema:
has_marker_leakage=true if the sketch contains ANY of: circle/dot
markers, wedge shapes, slot codes or letters/numbers, or a top-down
map-like view instead of a ground-level scene. Otherwise false.
details_ko: 한국어로 근거를 짧게.
```

- [ ] **Step 2: 조립 테스트 append** (test_outdoor_marker_map.py)

```python
def test_sketch_prompt_assembles_slots_and_contracts():
    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_sketch_prompt,
    )
    p = build_marker_sketch_prompt(
        shot_desc="달려가는 인물의 뒷모습 와이드",
        place_text="EXT. SAMPLE 개활지 - 낮",
        geometry=_geometry(),
        pose_clauses=["POSE CANON: running full stride"],
        carried_en="a canvas bag on the left shoulder",
    )
    # 슬롯→subject 매핑이 텍스트로 실림 (이미지 라벨은 코드만)
    assert "E1 = running adult figure" in p
    assert "E2 = seated adult figure" in p
    assert "달려가는 인물의 뒷모습 와이드" in p
    assert "EXT. SAMPLE 개활지 - 낮" in p
    assert "POSE CANON: running full stride" in p
    assert "canvas bag" in p
    # 계약 절 존재 (팩 로드 성공 검증)
    assert "MARKER MAP" in p          # sketch_head
    assert "LIGHT STORYBOARD" in p    # light_frame
    assert "do NOT draw the" in p     # no_marker
    assert "No text" in p             # no_text


def test_leakage_schema_shape():
    from app.modules.pipeline.outdoor_marker_map import build_leakage_schema

    s = build_leakage_schema()
    assert s["properties"]["has_marker_leakage"]["type"] == "boolean"
    assert "details_ko" in s["required"]
```

- [ ] **Step 3: 실패 확인** — `cd /Users/manta/Documents/Projects/TheRoad-I1/backend && .venv/bin/pytest tests/pipeline/test_outdoor_marker_map.py -v` → 신규 2건 FAIL

- [ ] **Step 4: 구현** (outdoor_marker_map.py에 append)

```python
SKETCH_PACK_MODULE = "marker_map_sketch"

SKETCH_PACK_VERSION_MAP = {
    "1": "1.202607150110",
}


def resolve_sketch_pack_version(version: str) -> str:
    if version not in SKETCH_PACK_VERSION_MAP:
        raise ValueError(f"marker_map_sketch 팩 버전 없음: {version}")
    return SKETCH_PACK_VERSION_MAP[version]


def build_marker_sketch_prompt(
    *,
    shot_desc: str,
    place_text: str,
    geometry: Dict[str, Any],
    pose_clauses: List[str],
    carried_en: str,
    prompt_version: str = "1",
) -> str:
    """마커 맵 → 스케치 프롬프트 조립 (head → LIGHT_FRAME → slot 매핑 →
    LOCATION → SHOT TEXT → pose → carried → no_marker → no_text)."""
    from app.modules.prompt_loader import load_prompt

    resolved = resolve_sketch_pack_version(prompt_version)
    slot_lines = [
        f"{pl['slot']} = {pl['subject_en']}"
        for pl in geometry.get("entity_placements") or []
    ]
    parts = [
        load_prompt(SKETCH_PACK_MODULE, "sketch_head",
                    version=resolved).strip(),
        load_prompt(SKETCH_PACK_MODULE, "light_frame",
                    version=resolved).strip(),
        "MAP SLOT MEANINGS:\n" + "\n".join(slot_lines),
        f"THE LOCATION: {place_text}",
        f"SHOT TEXT (authoritative, Korean): {shot_desc}",
    ]
    if pose_clauses:
        parts.append("\n".join(pose_clauses))
    if carried_en:
        parts.append("CARRIED STATE (persist exactly): " + carried_en)
    parts.append(
        load_prompt(SKETCH_PACK_MODULE, "no_marker", version=resolved).strip()
    )
    parts.append(
        load_prompt(SKETCH_PACK_MODULE, "no_text", version=resolved).strip()
    )
    return "\n\n".join(parts)


def build_leakage_schema() -> Dict[str, Any]:
    """스케치 마커 leakage VLM gate 스키마 (판정 호출은 caller)."""
    return {
        "type": "object",
        "properties": {
            "has_marker_leakage": {"type": "boolean"},
            "details_ko": {"type": "string"},
        },
        "required": ["has_marker_leakage", "details_ko"],
        "additionalProperties": False,
    }
```

- [ ] **Step 5: 통과 확인** — `cd /Users/manta/Documents/Projects/TheRoad-I1/backend && .venv/bin/pytest tests/pipeline/test_outdoor_marker_map.py -v` → 12 passed. 전체 유닛 회귀: `.venv/bin/pytest tests/unit -q` → 1027 passed

- [ ] **Step 6: Commit**

```bash
git add prompts/_base/marker_map_sketch backend/app/modules/pipeline/outdoor_marker_map.py backend/tests/pipeline/test_outdoor_marker_map.py
git commit -m "feat(lane1): marker_map 스케치 팩 v1 + 프롬프트 조립 + leakage gate 스키마 (Stage B3)"
```

### Task B4: 3샷 수직 canary + 갤러리 + Codex 리뷰

**Files:**
- Create: `<scratchpad>/run_lane1_canary.py` (커밋 금지)
- Create: `<scratchpad>/build_lane1_gallery.py` (커밋 금지)

**Interfaces:**
- Consumes: B1~B3 전부; 5회차 CP(outdoor_lane_plan/outdoor_place_spec/outdoor_place_canon/scene_save/shot_validator/shot_continuity); `multiroll_select.run_multiroll_select`+`multiroll_gemini` 어댑터(스틸 3롤); `gpt_image_primitive.call_gpt_image_bytes`(스케치, `shot_conti_light_step._make_gen_fn` 패턴); 이전 세션 러너(`67c09862…/scratchpad/run_still_e2e.py`)의 엔티티 passport 로딩 로직 복제
- Produces: canary 산출 디렉토리(control spec JSON/control PNG/스케치/스틸 롤·sel) + 갤러리 HTML(8899 docroot)

- [ ] **Step 1: canary 러너 작성** — 대상 3샷: `S15sh6`(bg_coastal_bus_stop SEG_02, no-prev), `S26sh2`(bg_harbor_rocks SEG_01, no-prev→zone anchor), `S26sh9`(SEG_01, prev=S26sh2 still). 샷별 흐름:
  1. lane plan CP에서 binding·segment 로드(lane=="map_marker" 확인), place spec+canon map_png 로드, shot_validator에서 description/characters 병합(Stage A 스텝과 동일 규칙), scene_save 씬 전문
  2. `run_marker_geometry_shot` → geometry JSON을 `<out>/S{si}sh{shi}_control.json` 저장 → `render_marker_map` → `<out>/S{si}sh{shi}_control.png` 저장 (base map은 무변경 — immutable SOT)
  3. 스케치: `build_marker_sketch_prompt`(pose_clauses=`build_pose_clauses`(shot_continuity CP), carried=continuity CP) → gpt-image-2 edit(참조=control PNG 1장) → leakage gate: gemini-pro `call_structured`(leakage_judge.md system + 스케치 첨부 + `build_leakage_schema`) → `has_marker_leakage=true`면 위반 상세를 프롬프트에 덧붙여 재생성(최대 2회, 전부 leak면 해당 샷 실패 기록하고 계속)
  4. 스틸: `run_multiroll_select`(nb2 3롤+gemini 판정+critique fix) — labeled_refs=[("STORYBOARD SKETCH — staging/composition only", 스케치), (엔티티 passport…)] + S26sh9만 ("PREVIOUS SHOT STILL — fixed background terrain/tone ONLY; do not copy people, poses, framing, time or lighting", S26sh2 _sel). **photo canon 참조 금지**. 프롬프트=SHOT TEXT+LOCATION+traits+pose/carried+world_anchor(기존 still_recipe 프롬프트 조립 재사용이 어려우면 러너에서 동등 조립 — 씬 전문 아닌 프롬프트 절은 러너 로컬 허용)
  5. 전 산출을 순서대로 기록(재실행 대비 지문은 multiroll record가 담당)
- [ ] **Step 2: 실행** — `cd /Users/manta/Documents/Projects/TheRoad-I1/backend && .venv/bin/python <scratchpad>/run_lane1_canary.py` (프로세스 로컬 env, 서버 무영향). 실패 샷은 원인 조사 후 프롬프트/조립 수정 반복(팩 수정은 신규 버전 디렉토리)
- [ ] **Step 3: 갤러리** — 샷별 [base map → control PNG(geometry rationale 병기) → 스케치(leakage 판정 결과) → 스틸 A/B/C → 선정 → fix] 체인 + prev 참조 명시. 8899 docroot(`projects/8ad94c03…/images/e666c62b…/lane1_canary_gallery.html`), URL은 LAN IP(`ipconfig getifaddr en0`)로 제시
- [ ] **Step 4: acceptance 자가 점검** — ①photo canon ref 0(러너 코드·record.refs로 증명) ②geometry 구조 저장(control.json) ③스케치 leakage 0(gate 판정 결과) ④S26sh9에 prev edge(record.refs에 previous still 라벨) — 결과를 갤러리에 명시
- [ ] **Step 5: Codex diff 리뷰 요청**(`[claude로부터 - 요청]`, 커밋 3개 범위) → 지적 반영 커밋 → 사용자에게 갤러리 URL 공유 + Stage C 착수 합의 대기

## Self-Review 결과

- 스펙 커버리지: 합의 결정 2(프로그래매틱 합성/immutable/validator/산출 3종 저장/고유명 0)=B1·B4, 결정 3(텍스트 SOT+스케치+passport, photo canon 0, zone anchor→prev)=B4, 레인1 acceptance 4항=B4 Step 4에 명시적 점검. '실제 겹침' 판정은 same-segment 근사로 명시(Stage D 확정) — 의도적 범위 절단이며 문서 상단에 기록.
- 플레이스홀더: B4는 scratchpad 러너 특성상 코드 전문 대신 흐름+정확한 함수 시그니처·참조 라벨 문구·대상 샷을 특정(러너는 커밋 금지 실험 스크립트 — 기존 run_still_e2e.py 복제 기반). B1~B3은 코드 전문 수록.
- 타입 일관성: `_geometry()` fixture shape=B1 스키마=B2 러너 반환=B3 조립 입력 동일. `resolve_prompt_version`(geometry 팩)과 `resolve_sketch_pack_version`(스케치 팩) 이름 분리로 충돌 없음.
