# E2E10 육안 피드백 6건 수정 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:** E2E10 육안 피드백 6건(①lane 팩 계약 강화 ②i2i 수정 후 재판정 ③정류장류 그룹 배경 공유 ④콘티=무조건 배경 우선 전면화 ⑤조명·무드 절 신설 ⑥기타 sweep)을 flag 게이트로 구현하고 E2E11 fresh 실행으로 검증한다. ③은 ④로 근본 해결된다.

**Architecture:** 전부 기존 still-recipe/BGFIRST2 구조의 확장 — (①) lane 판별 프롬프트 팩 v3 + v3 한정 결정론 validator 가드, (②) `multiroll_select._critique_and_fix` 에 선택적 2후보 블라인드 재판정(원본 vs 수정본, 정역순 flip — 기존 `normalize_flip_verdict`/`combine_flip_verdicts` 재사용), (④) `still_bgfirst_full_enabled` 신설: no_plate 샷도 콘티 저작(플레이트 무참조) → share_plan 그룹 단위 배경(groupbg) 1회 생성·재사용 → 기존 2단 체인에 plate:=groupbg 로 합류, complex/seed-bg 콘티 샷도 체인 2택1 편입, (⑤) `shot_staging.lighting_mood`(실측 SOT — scene_still.lighting_json 은 v4 에서 미저작, E2E10 192행 전부 `{}`) 를 LIGHTING & MOOD 절로 결정론 렌더 + 재질 사실감 정적 계약(팩 v8 단일 스템, fix1 camera_frame 선례와 동형).

**Tech Stack:** Python(FastAPI backend), pytest, 프롬프트 팩 디렉토리(`prompts/_base/<module>/<N.YYYYMMDDHHmm>/`), gpt-image-2(Step1/groupbg)·nb2(Step2)·Gemini VLM(판정).

## Global Constraints

- 프롬프트 파일 덮어쓰기 금지 — 새 버전 디렉토리(버전 형식 `N.YYYYMMDDHHmm`)로만 추가. 기존 팩 1~7 바이트 무변경.
- 신규 플래그 전부 default OFF — OFF 시 기존 경로 프롬프트·지문 byte-identical.
- 시나리오 의존 코딩 금지(방/캐릭터/소품/장면 문구 기반 rule 금지), 글자/substring 의미 판단 금지 — 의미 판정은 전부 LLM/스키마 enum, 코드는 결정론 검증만.
- LLM 입력 원문 절대 자르지 않기.
- 코드로 이미지에 그리기 절대 금지(PIL 마커·오버레이 등) — 시각 요소는 전부 이미지 모델.
- 기존 모듈 삭제 금지, pipeline/ 신규 모듈 추가 방식.
- 개발 사이클: 설계 → 구현 → Codex 리뷰 → 테스팅 → E2E11 → acceptance → 갤러리 LAN(localhost 금지) → 육안.
- 커밋 말미: `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`

## 실측 근거 요약 (E2E10, 프로젝트 4a05e836-fcb8-4eb6-8d80-71b8f7bc97a1)

- ①: S11sh3(옥탑 철문=구조물)·S28sh1(바다 위 어선=완전 자연)이 lane=map_marker 배정 — lane 팩 v2 에 제한 계약 부재. 세그먼트 `dominant_mode` enum(movement/structure)이 코드 가드 축.
- ②: `_critique_and_fix`(backend/app/modules/pipeline/multiroll_select.py:300)가 수정본을 무판정으로 `_sel` 확정 — 수정이 더 나빠지는 실측 다수(사용자).
- ③: S15sh1(no_plate→legacy, 배경 참조 0)·S15sh5(맵 스케치) 같은 seaside_bus_stop 그룹인데 공유 배경 asset 0.
- ④: no_plate 샷은 콘티 자체가 skipped(shot_conti_light.py:638-645) → bgfirst 비대상 → legacy 무배경 생성.
- ⑤: S12sh18/S18sh10/S25sh5(붉은 원 bgonly 샷) 프롬프트에 조명·무드 절 부재. **실측 정정: 조명 SOT=`shot_staging` CP 의 샷별 `lighting_mood`(55/55 저작 확인). `scene_still.lighting_json` 은 v4 파이프라인 미저작(192행 전부 `{}`) — 메모리 기록과 다름.** staging_map 은 이미 서비스에 배선돼 있음(camera_frame 과 동일 경로).

---

### Task 1: lane 팩 v3 — 맵=개활지 한정 + 구조물 세그먼트 코드 가드

**Files:**
- Create: `prompts/_base/outdoor_lane_plan/3.202607211500/system.md`
- Create: `prompts/_base/outdoor_lane_plan/3.202607211500/user_template.md` (v2 사본)
- Modify: `backend/app/modules/pipeline/outdoor_lane_plan.py` (PROMPT_VERSION_MAP:19, validate_lane_plan:115, run_outdoor_lane_plan_group:371)
- Test: `backend/tests/pipeline/test_outdoor_lane_plan.py`

**Interfaces:**
- Produces: `validate_lane_plan(result, group_shots, scene_texts, *, forbid_structure_map: bool = False) -> List[str]` — 신규 keyword 인자, default False = 기존 동작 불변.
- Produces: `PROMPT_VERSION_MAP["3"] = "3.202607211500"`. `run_outdoor_lane_plan_group` 은 `prompt_version == "3"` 일 때 `forbid_structure_map=True` 로 validator 호출.

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

```python
def test_v3_forbids_map_marker_on_structure_segment():
    result = {
        "segments": [
            {"segment_id": "seg1", "label_en": "building front",
             "dominant_mode": "structure",
             "evidence": [{"scene_index": 1, "quote_ko": "철문"}],
             "confidence": "high"},
        ],
        "shot_bindings": [
            {"scene_index": 1, "shot_index": 3, "segment_id": "seg1",
             "lane": "map_marker", "confidence": "high",
             "rationale_ko": "배치",
             "evidence": {"scene_index": 1, "quote_ko": "철문"}},
        ],
    }
    shots = [{"scene_index": 1, "shot_index": 3}]
    texts = {1: "옥상 철문 앞"}
    # 기본(구버전 팩)=기존 동작 불변 — 위반 없음
    assert not [
        v for v in validate_lane_plan(result, shots, texts)
        if "structure" in v and "map_marker" in v
    ]
    # v3 가드 — 구조물 세그먼트에 map_marker 바인딩 = 위반
    violations = validate_lane_plan(
        result, shots, texts, forbid_structure_map=True)
    assert any("map_marker" in v for v in violations)
```

- [ ] **Step 2: 실패 확인** — `cd backend && .venv/bin/python -m pytest tests/pipeline/test_outdoor_lane_plan.py::test_v3_forbids_map_marker_on_structure_segment -x` → FAIL (unexpected keyword `forbid_structure_map`)
- [ ] **Step 3: 구현** — `outdoor_lane_plan.py`:

```python
PROMPT_VERSION_MAP = {
    "1": "1.202607142200",
    "2": "2.202607190157",
    # 2026-07-21 E2E10 fix①: 맵=구도·배치가 중요한 "개활지" 한정 계약 명문화
    # — 구조물 지배(S11sh3 옥탑 철문)·완전 자연(S28sh1 바다) map_marker 금지.
    "3": "3.202607211500",
}
```

`validate_lane_plan` 시그니처에 `*, forbid_structure_map: bool = False` 추가, 바인딩 루프에서 (세그먼트 dict 를 segment_id 로 색인해 두고):

```python
        if (
            forbid_structure_map
            and b.get("lane") == "map_marker"
            and seg_by_id.get(b.get("segment_id"), {})
                .get("dominant_mode") == "structure"
        ):
            violations.append(
                f"샷 {label}: structure 세그먼트에 lane=map_marker 금지 — "
                "맵은 개활지 전용, structure_plate 또는 none 으로"
            )
```

`run_outdoor_lane_plan_group` 의 validator 호출부에 `forbid_structure_map=(prompt_version == "3")` 전달 (재시도 루프의 위반 피드백에 자동 포함).

`system.md` v3 = v2 전문 기반으로 lane "map_marker" 절 강화 (핵심 신설 문구 — 범용, 고유명사 0):

```
   - lane "map_marker": OPEN TERRAIN ONLY. Use ONLY when BOTH hold:
     (a) the shot happens in open terrain — ground you can look down
     on and read as a diagram (an open lot, a shoreline road, a wide
     yard, a field crossing); AND (b) the spatial arrangement IS the
     point of the shot. NEVER choose "map_marker" when:
     - the shot is dominated by a fixed man-made structure (a building
       face, a gate, a rooftop, stairs, an interior threshold) — its
       faces and occlusions cannot be staged from above; use
       "structure_plate" (or "none");
     - the environment is fully natural and homogeneous (inside a
       forest, open water, dense vegetation, uniform fields) — there
       is nothing on a map to disambiguate; use "none".
```

- [ ] **Step 4: 통과 확인** — 동일 pytest → PASS. 기존 lane 테스트 전체: `.venv/bin/python -m pytest tests/pipeline/test_outdoor_lane_plan.py tests/core/test_outdoor_lane_plan_step.py -q` → 전부 PASS (기본 인자 불변 확인).
- [ ] **Step 5: Commit** — `feat(lane): lane 팩 v3 — 맵=개활지 한정 계약+구조물 세그먼트 결정론 가드 (E2E10 fix①)`

---

### Task 2: multiroll fix-rejudge — i2i 수정본 포함 재판정 (모듈)

**Files:**
- Modify: `backend/app/modules/pipeline/multiroll_select.py` (`run_multiroll_select`:353, `_critique_and_fix`:300)
- Test: `backend/tests/unit/test_multiroll_select.py`

**Interfaces:**
- Produces: `run_multiroll_select(..., fix_rejudge_fn: Optional[JudgeFn] = None)` — None(default)=기존 동작·지문 byte-identical. 제공 시 `_critique_and_fix` 가 수정본 생성 후 [원본, 수정본] 2후보 블라인드 재판정(정역순 flip, `combine_flip_verdicts` 재사용)으로 최종 `_sel` 을 결정. 동점 priority=[수정본, 원본](critique 지적 반영본 우선 — 사용자 불만은 "더 나빠지는" 경우만).
- Produces: `record["fix_rejudge"] = {"forward_raw","forward_normalized","reverse_raw","reverse_normalized","combined","winner","fix_won"}`. 지문 extra 에 `{"fix_rejudge": FIX_REJUDGE_POLICY_VERSION}` (제공 시만).
- Produces: 모듈 상수 `FIX_REJUDGE_POLICY_VERSION = "fix_rejudge_v1_flip"`.

- [ ] **Step 1: 실패 테스트 작성** — `test_multiroll_select.py` 에 3건:

```python
def test_fix_rejudge_keeps_original_when_fix_worse(tmp_path):
    # critique 가 issue 를 내고, 재판정이 원본(orig) 승 → sel == 원본 bytes
    calls = {"judge": 0}

    def judge_fn(tag, prompt, refs, cand_paths, labels):
        calls["judge"] += 1
        if calls["judge"] == 1:  # 본선정 (3롤)
            return _judge_result(labels, winner=labels[0])
        # 재판정 2후보 — 항상 첫 표시 후보 승(정순=orig, 역순=fix 표시)
        # → 정순 winner=orig, 역순 winner(canonical)=orig: orig 합의승
        return _judge_result(labels, winner=labels[0])

    sel, record = run_multiroll_select(
        tag="t", prompt="p", labeled_refs=[], out_stem=tmp_path / "t",
        gen_fn=_gen_marked, judge_fn=judge_fn,
        critique_fn=_critique_one_issue, fix_gen_fn=_gen_marked_fix,
        roll_count=3, critique_enabled=True,
        fix_head="H", fix_tail="T", fix_label="orig",
        fix_rejudge_fn=judge_fn,
    )
    assert record["fix_rejudge"]["fix_won"] is False
    assert sel.read_bytes() == (tmp_path / "t_a.png").read_bytes()

def test_fix_rejudge_absent_keeps_legacy_and_fingerprint(tmp_path):
    # fix_rejudge_fn=None → record 에 fix_rejudge 없음 + 지문 = 기존과 동일
    ...

def test_fix_rejudge_fingerprint_differs_when_enabled(tmp_path):
    ...
```

(`_judge_result`/`_gen_marked`/`_critique_one_issue` 는 파일 내 기존 fixture 관례를 따라 작성 — verdicts/ranking/winner 완전 형상.)

- [ ] **Step 2: 실패 확인** — pytest 해당 3건 → FAIL (unexpected keyword)
- [ ] **Step 3: 구현** — `_critique_and_fix` 에 `fix_rejudge_fn: Optional[JudgeFn] = None` 추가. issues 존재 → fix 생성 후:

```python
    if fix_rejudge_fn is None:
        _atomic_place(fixed)
        record["fix_prompt"] = fix_prompt
        return sel
    # 2후보 블라인드 재판정 — canonical A=원본, B=수정본. 정순+역순 flip
    # (판정자에게 어느 쪽이 수정본인지 미노출 — 순서 편향은 flip 으로 상쇄)
    labels2 = ["A", "B"]
    cand = {"A": orig, "B": fixed}
    fwd_raw = fix_rejudge_fn(
        f"{tag}_fixjudge", prompt, labeled_refs,
        [cand["A"], cand["B"]], labels2)
    fwd = normalize_flip_verdict(fwd_raw, {l: l for l in labels2}, labels2)
    rev_raw = fix_rejudge_fn(
        f"{tag}_fixjudge_rev", prompt, labeled_refs,
        [cand["B"], cand["A"]], labels2)
    rev = normalize_flip_verdict(
        rev_raw, flip_display_to_canonical(labels2), labels2)
    winner, combined = combine_flip_verdicts(fwd, rev, labels2, ["B", "A"])
    record["fix_rejudge"] = {
        "forward_raw": fwd_raw, "forward_normalized": fwd,
        "reverse_raw": rev_raw, "reverse_normalized": rev,
        "combined": combined, "winner": winner,
        "fix_won": winner == "B",
    }
    record["fix_prompt"] = fix_prompt
    _atomic_place(cand[winner])
    return sel
```

`run_multiroll_select`: `fix_rejudge_fn` param 추가 → `_fp_extra` 에 제공 시만 `{"fix_rejudge": FIX_REJUDGE_POLICY_VERSION}` 병합 → `_critique_and_fix` 호출 2곳(537, 710)에 전달.

- [ ] **Step 4: 통과 확인** — `pytest tests/unit/test_multiroll_select.py -q` 전부 PASS.
- [ ] **Step 5: Commit** — `feat(multiroll): i2i 수정본 2후보 블라인드 재판정 — 수정 개악 차단 (E2E10 fix②)`

---

### Task 3: fix-rejudge 서비스 배선 — 스틸·플레이트 공통 파이프 전체

**Files:**
- Modify: `backend/app/core/config.py` (플래그 블록, still_bgfirst_enabled:701 부근)
- Modify: `backend/app/services/still_recipe_service.py` (judge 어댑터 조립부 ~430-515, `_run_branch`:1429)
- Modify: plate_multiroll 배선부 (`backend/app/modules/pipeline/plate_multiroll.py` 및 그 호출부 — 구현 시 `grep -rn "run_multiroll_select" app/` 로 전 호출 지점 확인 후 전부 배선)
- Modify: `backend/app/core/steps/image_steps.py` (config_hash 스탬프 ~651 블록과 동형)
- Test: `backend/tests/unit/test_still_recipe.py`, plate 관련 기존 테스트 파일

**Interfaces:**
- Consumes: Task 2 의 `fix_rejudge_fn` param.
- Produces: `settings.multiroll_fix_rejudge_enabled: bool = False`. ON 시 공통 생성 파이프 전 지점(스틸 전 브랜치 + 플레이트 멀티롤)에 2후보 judge 어댑터(`make_gemini_judge_fn` + `resolve_judge_texts(2, judge_name=<모듈별 judge>)`) 주입.
- Produces: config_hash payload 에 `multiroll_fix_rejudge` 키(ON 시만) + `run_still_recipe_generation` 의 `extra_fingerprint["fix_rejudge"]`(ON 시만 — 지문 계약은 모듈이 이미 기여하므로 config_hash 만 필수).

- [ ] **Step 1: 플래그 추가** — config.py:

```python
    # 2026-07-21 E2E10 fix②: critique→i2i 수정 후 무판정 확정을 재판정으로
    # 교체 — [선정 원본 vs 수정본] 2후보 블라인드(정역순 flip) VLM 판정,
    # 승자=최종 _sel. 수정이 더 나빠지는 실측 다수 대응. 공통 생성 파이프
    # 전체(스틸·플레이트) 적용. OFF(default)=byte-identical.
    multiroll_fix_rejudge_enabled: bool = False
```

- [ ] **Step 2: 실패 테스트 작성** — 서비스 배선 테스트(기존 `run_branch_select` kwargs 통과 테스트 관례 — test_still_recipe.py:1080 `run_fn(**kw)` seen 패턴): flag ON 이면 `run_multiroll_select` 에 `fix_rejudge_fn` 이 callable 로 전달, OFF 면 미전달(또는 None).
- [ ] **Step 3: 구현** — still_recipe_service: judge 어댑터 조립부에서

```python
    fix_rejudge_fn = None
    if bool(getattr(settings, "multiroll_fix_rejudge_enabled", False)):
        _rj_texts = resolve_judge_texts(2, judge_name="judge_still")
        fix_rejudge_fn = make_gemini_judge_fn(
            judge_sys=_rj_texts["judge_sys"], ...)  # 기존 어댑터 관례 동일
```

`_run_branch` 에서 `run_branch_select(..., fix_rejudge_fn=fix_rejudge_fn)` — `run_branch_select` 는 `**variant_kw` 로 run_fn 에 그대로 전달. bgfirst 브랜치는 `bgfirst_judge_fn`(이미 2후보) 재사용해 동일 param 전달. plate_multiroll: 동일 패턴으로 플레이트 judge 텍스트의 2후보 어댑터 주입 (플레이트 쪽 judge_name 은 구현 시 해당 모듈 상수 확인). image_steps.py config_hash: `multiroll_fix_rejudge_enabled` ON 시 payload["multiroll_fix_rejudge"] = FIX_REJUDGE_POLICY_VERSION.
- [ ] **Step 4: 통과 확인** — 신규 + 기존 스틸·플레이트 유닛 전부 PASS.
- [ ] **Step 5: Commit** — `feat(multiroll): fix-rejudge 서비스 배선 — 스틸·플레이트 공통 파이프 전체 (flag, E2E10 fix②)`

---

### Task 4: LIGHTING & MOOD 절 + 재질 사실감 — 팩 v8 + 렌더러 (모듈)

**Files:**
- Create: `prompts/_base/still_recipe/8.202607211510/lighting_mood_clause.md`
- Modify: `backend/app/modules/pipeline/still_recipe.py` (PROMPT_VERSION_MAP:31, `build_still_prompt`:801, `build_bgfirst_bg_prompt`:1001)
- Test: `backend/tests/unit/test_still_recipe.py`

**Interfaces:**
- Produces: `LIGHTING_MOOD_PROMPT_VERSION = "8"`, `PROMPT_VERSION_MAP["8"] = "8.202607211510"`.
- Produces: `build_lighting_mood_clause(staging: Optional[Dict[str, Any]]) -> str` — `staging["lighting_mood"]`(shot_staging CP SOT) 결정론 렌더: 팩 v8 `lighting_mood_clause` 스템(절 헤더+재질 사실감 정적 계약) + `- LIGHTING & MOOD: {lighting_mood}`. staging 부재/필드 빈값 = `""` (fail-safe, camera_frame 과 동형).
- Produces: `build_still_prompt(..., lighting_mood_en: str = "")` — 비면 byte-identical, 있으면 TIME OF DAY 절 다음·CAMERA/FRAME 앞 삽입. `build_bgfirst_bg_prompt(..., lighting_mood_en: str = "")` — 동일 삽입(TIME OF DAY 다음).

- [ ] **Step 1: 팩 스템 작성** — `lighting_mood_clause.md` (범용 — 시나리오 특정 0):

```
LIGHTING & MOOD (these govern light, color and surface rendering):
{body}
- MATERIAL REALISM: every object and surface must read as a real,
  physical material — correct texture, weight, wear and light response
  (metal reflects, fabric drapes, blood is liquid and glossy, painted
  marks sit ON a surface following its curvature). Nothing may look
  like a flat sticker, doodle or graphic overlay pasted onto the frame.
```

- [ ] **Step 2: 실패 테스트 작성**:

```python
def test_lighting_mood_clause_renders_from_staging():
    clause = build_lighting_mood_clause(
        {"lighting_mood": "Low dusk light, desaturated."})
    assert "LIGHTING & MOOD" in clause
    assert "Low dusk light, desaturated." in clause
    assert "MATERIAL REALISM" in clause

def test_lighting_mood_clause_failsafe_empty():
    assert build_lighting_mood_clause(None) == ""
    assert build_lighting_mood_clause({"lighting_mood": "  "}) == ""

def test_still_prompt_lighting_empty_is_byte_identical():
    base = _base_prompt_kwargs()
    assert build_still_prompt(**base) == build_still_prompt(
        **base, lighting_mood_en="")

def test_still_prompt_lighting_inserted_after_time_of_day():
    p = build_still_prompt(**_base_prompt_kwargs(),
                           lighting_mood_en="LIGHTING & MOOD:\n- X")
    assert p.index("TIME OF DAY") < p.index("LIGHTING & MOOD")

def test_bgfirst_bg_prompt_lighting_param():
    ...  # 동형 — 비면 불변, 있으면 TIME OF DAY 다음
```

- [ ] **Step 3: 실패 확인** → FAIL (함수/param 부재)
- [ ] **Step 4: 구현** — camera_frame(fix1, still_recipe.py:723)과 동형 구조:

```python
LIGHTING_MOOD_PROMPT_VERSION = "8"

def build_lighting_mood_clause(staging: Optional[Dict[str, Any]]) -> str:
    """shot_staging CP 1샷 엔트리 lighting_mood → LIGHTING & MOOD 절 (fix⑤).

    E2E10 실측: 붉은 원(bgonly) 샷들 프롬프트에 조명·무드 절 부재(TIME OF
    DAY 1줄뿐) + 재질 서술 부재 → 평면 낙서화. 조명 SOT = shot_staging.
    lighting_mood (scene_still.lighting_json 은 v4 미저작 — E2E10 전행 `{}`
    실측). 결정론 조립만 — 부재/빈값 = "" (보강 절 fail-safe).
    """
    if not isinstance(staging, dict):
        return ""
    mood = str(staging.get("lighting_mood") or "").strip()
    if not mood:
        return ""
    stem = load_prompt(
        _MODULE, "lighting_mood_clause",
        version=resolve_prompt_version(LIGHTING_MOOD_PROMPT_VERSION),
    ).strip()
    return stem.format(body=f"- LIGHTING & MOOD: {mood}")
```

`build_still_prompt`/`build_bgfirst_bg_prompt` 에 `lighting_mood_en: str = ""` — 조립 리스트에서 TIME OF DAY append 직후 `if lighting_mood_en: parts.append(lighting_mood_en)`.
- [ ] **Step 5: 통과 확인** — 신규 5건 + `pytest tests/unit/test_still_recipe.py -q` 전부 PASS (기존 조립 byte 불변 테스트 포함).
- [ ] **Step 6: Commit** — `feat(still-recipe): LIGHTING&MOOD 절+재질 사실감 계약 — 팩 v8, staging lighting_mood 소비 (E2E10 fix⑤)`

---

### Task 5: lighting 서비스 배선 — 전 샷(bgonly·lane 포함) + Step1 주입

**Files:**
- Modify: `backend/app/core/config.py`
- Modify: `backend/app/services/still_recipe_service.py` (camera_frame 배선부:1357-1365 옆, bgfirst bg_prompt 조립부:1509-1522)
- Modify: `backend/app/core/steps/image_steps.py` (config_hash)
- Test: `backend/tests/unit/test_still_recipe_service*.py` (기존 서비스 테스트 파일 관례 확인 후)

**Interfaces:**
- Consumes: Task 4 의 `build_lighting_mood_clause`, `lighting_mood_en` params.
- Produces: `settings.still_recipe_lighting_enabled: bool = False`. ON 시 전 샷(bgonly 포함, **lane 샷 포함** — 조명은 배치 권위와 무충돌이라 camera_frame 의 lane 제외와 다름)에 절 주입 + bgfirst Step1 bg_prompt 에도 주입. `extra_fingerprint["recipe_lighting_pack"]`(ON 시만) + config_hash `still_recipe_lighting` 키.

- [ ] **Step 1: 플래그 추가** (config.py — camera_frame 플래그 주석 관례와 동형, 위 근거 인용)
- [ ] **Step 2: 실패 테스트** — flag ON 시 build_still_prompt 로 전달된 prompt 에 LIGHTING & MOOD 포함(스틸 record 프롬프트 검사), OFF 시 미포함·지문 불변.
- [ ] **Step 3: 구현** — 서비스 camera_frame 블록 옆:

```python
        lighting_mood_en = ""
        if lighting_on:  # settings.still_recipe_lighting_enabled
            from app.modules.pipeline.still_recipe import (
                build_lighting_mood_clause,
            )
            lighting_mood_en = build_lighting_mood_clause(
                staging_map.get(f"{si}_{shi}") if staging_map else None
            )
```

`build_still_prompt(..., lighting_mood_en=lighting_mood_en)` + bgfirst `build_bgfirst_bg_prompt(..., lighting_mood_en=lighting_mood_en)`. fingerprint/hash 스탬프는 camera_frame(`recipe_camera_frame_pack`) 선례와 동형.
- [ ] **Step 4: 통과 확인** → PASS
- [ ] **Step 5: Commit** — `feat(still-recipe): lighting 절 서비스 배선 — 전 샷+Step1, flag 게이트 (E2E10 fix⑤)`

---

### Task 6: no_plate 샷 콘티 저작 — 플레이트 무참조 콘티 (fix④ 전제)

**Files:**
- Modify: `backend/app/modules/pipeline/shot_conti_light.py` (`run_shot_conti_light`:574, no_plate 분기:638-645)
- Modify: `backend/app/core/steps/shot_conti_light_step.py` (flag 전달)
- Modify: `backend/app/core/config.py` (`still_bgfirst_full_enabled` 플래그 — Task 7~8 도 공용)
- Test: `backend/tests/unit/test_shot_conti_light_persp.py`

**Interfaces:**
- Produces: `settings.still_bgfirst_full_enabled: bool = False` (still_bgfirst_enabled ON 전제 — 서비스에서 fail-closed 검증).
- Produces: `run_shot_conti_light(..., no_plate_conti: bool = False)` — True 이고 대상 샷의 플레이트 부재 시 skipped 대신 **콘티 생성**: `build_conti_prompt(has_plate=False, ...)`(기존 분기 재사용), labeled_refs=캐릭터 비례 참조만, entry 에 `"no_plate": True, "plate_path": None` 마킹. False(default)=기존 skipped_reason="no_plate" byte-identical.
- Produces: entry 계약 — no_plate 콘티 entry 는 `skipped_reason=None`, `image_path` 실재, `no_plate=True`. (스텝의 asset 등록·lineage 는 기존 콘티 경로 공용 — plate 참조만 없음.)

- [ ] **Step 1: 실패 테스트 작성**:

```python
def test_no_plate_conti_generates_without_plate_ref(tmp_path):
    seen = {}
    def gen(tag, prompt, labeled_refs, out_path):
        seen[tag] = {"prompt": prompt, "refs": list(labeled_refs)}
        out_path.write_bytes(b"png")
    res = _run(tmp_path, gen, plate_map={}, no_plate_conti=True,
               prompt_version="2")
    e = res["contis"]["S1sh2"]
    assert e["skipped_reason"] is None and e["no_plate"] is True
    assert e["image_path"] and e["plate_path"] is None
    assert all(l != "plate" for l, _ in seen["S1sh2_..."]["refs"])

def test_no_plate_conti_default_off_keeps_skip(tmp_path):
    res = _run(tmp_path, lambda *a: None, plate_map={})
    assert res["contis"]["S1sh2"]["skipped_reason"] == "no_plate"
```

- [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — no_plate 분기(638-645)를:

```python
        plate = plate_map.get(f"{si}_{shi}")
        if plate is None and not no_plate_conti:
            ...  # 기존 skipped 그대로
            continue
        # plate None + no_plate_conti: 콘티=텍스트+원근 가이드+캐릭터
        # 비례 참조만으로 저작 (fix④ — 이후 스틸 단계가 이 콘티로 그룹
        # 배경을 생성). build_conti_prompt(has_plate=False) 재사용.
```

이후 조립에서 `has_plate=plate is not None`, labeled_refs 에서 plate 항목 조건부, entry 에 `no_plate: plate is None` 병기. 지문은 labeled_refs 내용 기반이라 자동 분리. step 은 `no_plate_conti=bool(settings.still_bgfirst_full_enabled and settings.still_bgfirst_enabled)` 전달 + CP contract 키(`conti_pack` 관례)에 no_plate_conti 여부 병기(스틸 쪽 fail-closed 대조용).
- [ ] **Step 4: 통과 확인** — 신규 2건 + 기존 conti 테스트 전부 PASS.
- [ ] **Step 5: Commit** — `feat(conti): no_plate 샷 콘티 저작 — 플레이트 무참조, full flag 게이트 (E2E10 fix④ 전제)`

---

### Task 7: bgfirst full — eligibility 확장 + groupbg/seed 헬퍼 + 팩 v9 (모듈)

**Files:**
- Create: `prompts/_base/still_recipe/9.202607211520/groupbg_head.md`
- Create: `prompts/_base/still_recipe/9.202607211520/groupbg_tail.md`
- Create: `prompts/_base/still_recipe/9.202607211520/bg_reproject_seed_clause.md`
- Modify: `backend/app/modules/pipeline/still_recipe.py`
- Test: `backend/tests/unit/test_still_recipe.py`

**Interfaces:**
- Produces: `BGFIRST_FULL_PROMPT_VERSION = "9"`, `PROMPT_VERSION_MAP["9"]`, `BGFIRST_FULL_CONTRACT_VERSION = "bgfirst_full_v1"`.
- Produces: `bgfirst_eligible_full(*, conti_present, bg_only, prev_used, lane_used) -> bool` — full 모드 판정: 콘티 실재 샷이면 complex/seed 도 포함(`conti_present and not bg_only and not prev_used and not lane_used`). prev/bgonly/lane 제외 유지(사용자 확정 레시피의 비콘티 샷).
- Produces: `build_groupbg_prompt(*, place_text, time_of_day_en, world_anchor) -> str` — groupbg_head(무인 NO PEOPLE + 콘티=공간 구도 근거·실사 배경 신조 계약) + LOCATION/TIME + groupbg_tail(16:9 실사). 샷 특정 정보(SHOT TEXT/카메라) **불포함** — 그룹 공용 배경.
- Produces: `groupbg_require_input_ids(*, conti_asset_id) -> List[str]` — 정확 1 UUID fail-closed (`bgfirst_require_input_ids` 관례).
- Produces: `build_bgfirst_seed_clause() -> str` — Step1 에 STRUCTURE LOOK 3번째 참조가 있을 때 덧붙일 절(팩 v9 스템 로드): "THIRD image = STRUCTURE LOOK — the structure's identity source; its faces/openings override the plate where they conflict."

- [ ] **Step 1: 팩 스템 작성** — groupbg_head.md (v7 bg_reproject_head 계약 문체 승계, 범용):

```
Create ONE empty live-action location background photograph.
The FIRST image is a rough layout sketch of a shot that happens at this
location — use it ONLY as spatial evidence of what this place contains
and how it is arranged. Do NOT copy its line style; render a fully
photographic, physically plausible place.
ABSOLUTELY NO PEOPLE: no person, body part, silhouette, shadow of a
person, or reflection of a person anywhere in the frame.
```

groupbg_tail.md: `Render one 16:9 photorealistic empty background of this location.` / bg_reproject_seed_clause.md: 위 Interfaces 문안.

- [ ] **Step 2: 실패 테스트 작성**:

```python
def test_bgfirst_eligible_full_includes_complex_and_no_plate():
    assert bgfirst_eligible_full(
        conti_present=True, bg_only=False, prev_used=False, lane_used=False)

def test_bgfirst_eligible_full_excludes_prev_bgonly_lane():
    for kw in ({"bg_only": True}, {"prev_used": True}, {"lane_used": True}):
        base = dict(conti_present=True, bg_only=False,
                    prev_used=False, lane_used=False)
        base.update(kw)
        assert not bgfirst_eligible_full(**base)

def test_groupbg_prompt_no_shot_specific_and_no_people():
    p = build_groupbg_prompt(place_text="seaside road",
                             time_of_day_en="dusk", world_anchor="")
    assert "NO PEOPLE" in p.upper() and "SHOT TEXT" not in p

def test_groupbg_require_input_ids_fail_closed():
    with pytest.raises(ValueError):
        groupbg_require_input_ids(conti_asset_id=None)
```

- [ ] **Step 3: 실패 확인** → FAIL
- [ ] **Step 4: 구현** — 위 Interfaces 대로. `bgfirst_structural_skip` 은 불변(스텝이 no_plate 를 더 이상 skip 안 하므로 자연 해소; prev/bgonly skip 은 유지돼 full 에서도 비대상).
- [ ] **Step 5: 통과 확인** → PASS
- [ ] **Step 6: Commit** — `feat(still-recipe): bgfirst full 모듈 — eligibility 확장+groupbg/seed 헬퍼+팩 v9 (E2E10 fix④)`

---

### Task 8: bgfirst full 서비스 배선 — groupbg 생성·재사용 + complex/seed 체인 편입

**Files:**
- Modify: `backend/app/services/still_recipe_service.py`
- Modify: `backend/app/core/steps/image_steps.py` (config_hash)
- Test: `backend/tests/unit/test_still_recipe.py` + 서비스 배선 테스트

**Interfaces:**
- Consumes: Task 6 콘티 entry(`no_plate=True`), Task 7 헬퍼, 기존 `_run_bgfirst_bg`/share_plan CP(`plan.share_groups`).
- Produces: full ON(`still_bgfirst_full_enabled` and `still_bgfirst_enabled` — 후자 OFF 면 서비스 진입 시 AppError fail-closed) 시:
  1. **eligibility**: `bgfirst_eligible_full` 로 대체. complex_ab/structure_seed/seed_bg 샷 포함, prev/bgonly/lane 제외.
  2. **위치 권위 해석(location authority)**: 샷별 `plate`(실재) → 없으면 `seed_bg` → 없으면 **groupbg**. groupbg: share_plan CP 의 `share_groups` 에서 tag→group_key 매핑(`group_of`) 구축, `_run_groupbg(group_key)` — record key `groupbg::{group_key}`, out=`recipe_dir/groupbg_{group_key}.png`, 엔진/재개/모더레이션/등록 전부 `_run_bgfirst_bg` 관례(참조=[그룹 내 스토리 순 첫 bgfirst 대상 샷의 콘티], `groupbg_require_input_ids`, `is_intermediate=True`, pipeline_role="bgfirst_group_bg"). 같은 그룹 후속 샷=파일·record 재사용(1회 생성) — **③ 정류장류 그룹 배경 공유가 여기서 성립**.
  3. **Step1 확장**: `_run_bgfirst_bg` 의 plate 인자=위치 권위(플레이트/seed_bg/groupbg). structure_seed 실재 시 ref 3개([conti, authority, seed])+`build_bgfirst_seed_clause()` 를 bg_prompt 말미 CAMERA 절 뒤에 append, input_ids 3 UUID(기존 `bgfirst_require_input_ids` 를 optional seed 인자로 확장 — 없으면 기존 2 UUID byte-identical).
  4. **2택1**: 기존 bgfirst 브랜치 그대로(체인 vs 무콘티). 무콘티 후보 refs=`build_ab_branch_refs(plate=authority, structure_seed=..., seed_bg=...)`. complex 샷의 기존 4택1(`ab_active`)은 full 에서 bgfirst 로 대체 — `complex_ab and not ab_active` fail-closed 가드(1323)에 `and not bgfirst_used` 추가.
  5. **lineage/감사**: 체인 승=conti+bgfirst_bg(+groupbg 는 bgfirst_bg 의 input 으로 연결), 무콘티 승=위치 권위 attach(groupbg 는 role="bgfirst_group_bg" 로). record["bgfirst"]["authority"]∈{"plate","seed_bg","groupbg"} + group_key 병기. `extra_fingerprint["bgfirst_full"]`=BGFIRST_FULL_CONTRACT_VERSION + config_hash 키(ON 시만).

- [ ] **Step 1: 실패 테스트 작성** — 최소 4건: (a) full ON + no_plate 샷 → groupbg 1회 생성·같은 그룹 2번째 샷 재사용(gen 호출 횟수 검증) (b) full ON + complex 샷 → bgfirst_used=True·4택1 미진입 (c) full OFF → 기존 경로 지문 불변 (d) full ON + still_bgfirst_enabled OFF → AppError.
- [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — Interfaces 명세대로. share_groups 로딩은 기존 share_plans 로딩부(:194-199) 확장:

```python
    group_of: Dict[str, str] = {}
    if _sp_data:
        for g in _sp_data["plan"].get("share_groups") or []:
            for t in g.get("shot_tags") or []:
                group_of[t] = str(g.get("group_key") or "")
```

- [ ] **Step 4: 통과 확인** — 신규 + 기존 전체: `.venv/bin/python -m pytest tests/unit -q` 신규 실패 0.
- [ ] **Step 5: Commit** — `feat(still-recipe): bgfirst full 서비스 배선 — groupbg 장소 단위 공유+complex/seed 체인 편입 (E2E10 fix③④)`

---

### Task 9: 기타 전수 sweep (fix⑥)

**Files:**
- Create: 세션 scratchpad `sweep_e2e10_records.py` (커밋 금지)
- Modify: 발견 기반 (싼 fix 만 — 큰 발견은 설계 논의로 회부)

- [ ] **Step 1**: E2E10 records/CP 전수 정적 스캔 — unresolved refs, 실패 백업, ref_mode 분포 이상, prompt 절 누락 패턴, share prev 미준수 3건 재확인.
- [ ] **Step 2**: 발견 항목 분류 — 코드 결함(즉시 fix+테스트)/계약 개선(플랜 추가)/무해(기록만). 결과를 최종 보고에 포함.

---

### Task 10: Codex 리뷰 → 반영

- [ ] Codex 에 구현 diff 코드 리뷰 요청(tmux_type + Enter, `[claude로부터 - 요청]` prefix, 답신 tmux-bridge push 요청). 지적은 품질 트레이드오프 자체 분석 후 수용/기각.
- [ ] BLOCKING/HIGH 반영 → 재리뷰 → APPROVED 까지. 각 사이클 커밋.

---

### Task 11: 유닛 full + E2E11 fresh + acceptance + 갤러리

- [ ] **Step 1**: `.venv/bin/python -m pytest tests/unit tests/core tests/pipeline -q` — 기존 실패군 대비 신규 실패 0 확인.
- [ ] **Step 2**: E2E11 러너 = `projects/../82f1bbc4-4460-4118-9910-2555f99cdbfd/scratchpad/run_e2e_round10.py` 사본 → 신규 프로젝트("금월도 E2E 11회차 bgfirst-full 20260721") 생성·기획안+에피소드1 업로드·PID/EID 교체, **nohup 독립 프로세스**. env = E2E10 전부 + 신규 4종: `OUTDOOR_LANE_PLAN_PROMPT_VERSION=3`, `STILL_BGFIRST_FULL_ENABLED=true`, `MULTIROLL_FIX_REJUDGE_ENABLED=true`, `STILL_RECIPE_LIGHTING_ENABLED=true`.
- [ ] **Step 3**: 모니터 = DB step_run + llm_call_log 합산 40분 정체 기준(로그 mtime 단독 오탐 금지). 실패 대응 지식: place_spec 빈 items·share_plan 형상 위반=재시도, lane_plan 재실행=단독 force(하류 무효화 주의), full 러너 재실행 금지(표적 tail).
- [ ] **Step 4**: acceptance = E2E10 항목 전부 + 신규: (①) lane 분포에서 structure 세그먼트 map_marker 0 (②) fix_rejudge record 실측 — fix_won 분포·원본 유지 사례 존재 (③④) no_plate 그룹 groupbg asset 공유 실측(그룹당 1 asset·N샷 참조)·legacy 하강 0·bgfirst authority 분포(plate/seed_bg/groupbg) (⑤) 전 스틸·Step1 프롬프트에 LIGHTING & MOOD 절 실재(staging lighting_mood 있는 샷 전부).
- [ ] **Step 5**: 갤러리 빌더 = `build_e2e10_gallery.py` 사본(PID/EID 교체) → `python3 -m http.server <port> --bind 0.0.0.0` → **LAN IP**(`ipconfig getifaddr en0`) 링크로 육안 보고.

## Self-Review 결과

- 스펙 커버리지: ①=Task 1, ②=Task 2·3, ③=Task 8(2. groupbg 공유), ④=Task 6·7·8, ⑤=Task 4·5, ⑥=Task 9, E2E11=Task 11 — 전 항목 매핑 확인.
- ⑤ 전제 정정(중요): 조명 SOT 를 scene_still.lighting_json 이 아닌 shot_staging.lighting_mood 로 확정 — E2E10 DB 실측(192행 전부 `{}`) + shot_staging CP 55/55 저작 실측 근거.
- 타입 일관성: `fix_rejudge_fn: Optional[JudgeFn]`(Task 2 정의→Task 3 소비), `no_plate_conti: bool`(Task 6 정의→Task 8 전제 entry 계약), `bgfirst_eligible_full`/`build_groupbg_prompt`/`groupbg_require_input_ids`(Task 7 정의→Task 8 소비) — 명칭·시그니처 일치.
