# Production 콘티·스틸 레시피 이식 (s40/s41) Implementation Plan

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

**Goal:** s40/s41 실험으로 확정된 콘티·스틸 레시피(공통 3롤+Gemini 선정+결함 i2i 수정, bgonly/prev v3 샷 분류, 경량 콘티, 야외 복잡 구도=맵 기반→콘티 변환, 스틸 다중 참조+텍스트 안전망)를 production 파이프라인 v4에 opt-in 플래그 뒤로 이식한다.

**Architecture:** 신규 StepRunner 스텝 3개(`shot_ref_classify` → `shot_continuity` → `shot_conti_light`)가 분류·연속성·콘티 체크포인트를 만들고, 씬 스틸 생성 시 `still_recipe_mode="v1"`이면 신규 모듈 `still_recipe.py`가 참조 조립(플레이트+콘티+prev 선정본+엔티티)과 텍스트 안전망을 담당하며, 모든 이미지 생성은 공통 모듈 `multiroll_select.py`(N롤→Gemini 판정→선정→결함 검사→i2i 수정→`_sel`)를 통과한다. 야외 복잡 구도는 LLM 판별 후 맵 기반(사이트 맵 grounding→맵 기반 플레이트)으로 콘티를 만들어 동일 파이프에 병합. 플레이트(배경 렌더)에도 같은 공통 파이프를 opt-in 적용.

**Tech Stack:** Python/FastAPI backend, StepRunner 체크포인트 체계, GeminiImageClient(nb2=`gemini-3.1-flash-image-preview`), gpt_image_primitive(i2=`gpt-image-2`), llm_client `call_structured`(판정=`gemini-pro` 단독), prompt pack(`prompts/_base/<module>/<N.YYYYMMDDHHmm>/`).

## Global Constraints (CLAUDE.md + 메모리 규칙 — 모든 태스크에 적용)

- LLM 입력 원문 절대 자르지 않기(`[:400]` 류 금지). 씬 원문은 전문 전달.
- pipeline/ 신규 모듈 추가만, 기존 모듈 삭제·덮어쓰기 금지.
- 프롬프트는 새 버전 디렉토리(`prompts/_base/<module>/<N.YYYYMMDDHHmm>/`), 기존 팩 덮어쓰기 금지.
- 시나리오 의존 코딩 금지: 방/캐릭터/소품/장면 문구 기반 rule 금지. 작품 고유명사 코드·프롬프트 금지.
- 글자/substring 의미 판단 금지 — 의미 판정은 전부 LLM structured extraction(실험의 `_time_of` 헤딩 매칭, `_soften` 치환은 이식 금지 대상).
- scene_still sync UPSERT 유지(DELETE→INSERT 금지), 이미지 파일·DB row 삭제 금지(force=is_primary=0).
- 신규 플래그는 전부 default OFF, `_config_hash`에는 ON일 때만 스탬프(legacy hash byte-identical 보존 — `background_prompt_step.py:187` 패턴).
- 판정 VLM은 **Gemini 단독**(사용자 확정 "gpt 이상해"). 이미지: 스틸·플레이트=nb2, 콘티·도면·맵 재투영=gpt-image-2.
- TDD는 결정론 영역만(선정/동점/재개/참조 조립/config_hash/스텝 등록). LLM·이미지 완성도는 테스트로 주장 금지.
- DB=PostgreSQL 전용(`PGPASSWORD=theroad_dev_2026 psql -h localhost -U theroad -d theroad`).

## 소스 계약의 정본 위치 (커밋 98fa26a2, `scratchpad/forest_exp/`)

프롬프트 원문은 아래 실험 파일에서 **verbatim 복사**한다(전부 범용 계약 — 작품 고유명사 없음). 이식 금지 대상(§9)은 표기.

| 계약 | 정본 위치 |
|---|---|
| JUDGE_SCHEMA / CRITIQUE_SCHEMA / FIX_LABEL | `s29_top_v2.py:86-106, 126-142, 155-156` |
| 스틸/실내 판정 JUDGE_SYSTEM | `s39_threeroll_vlm.py:50-70` |
| 실외 플레이트 판정 JUDGE_SYS·CRITIQUE_SYS | `x_plate_chain.py:32-52, 54-65` |
| CRITIQUE40_SYS / FIX40_HEAD / FIX40_TAIL | `s40_full_multiref.py:46-60, 61-64, 65-70` |
| LIGHT_FRAME / BG_REF_CLAUSE | `s41_conti_light.py:38-49, 51-58` |
| LIGHT_PANEL_LABEL / EXPRESSION_REALISM | `s38_lightconti_full.py:61-67, 48-59` |
| bgonly 판정 sys | `s39_threeroll_vlm.py:225-236` |
| prev v3 판정 sys | `s38_lightconti_full.py:238-269` |
| PEOPLE / NO PEOPLE / PROP_ORIENTATION | `s39_threeroll_vlm.py:189-198, 184-187, 39-48` |
| REALIZE_STILL | `s34_stillrun.py:330-346` |
| 자세 정본/중의어 교정/carried sys | `s37_rooftop_rebuild.py:635-651, 682-692, 500-521` |
| FP 스펙 자가발견 / FP_MEDIUM / 그리기 순서 | `s37_rooftop_rebuild.py:358-373, 343-352, 406-429` |
| 참조 라벨(FP/ROOT/PREV/PLATE_STILL/PREV_STILL/MASTER) | `s37_rooftop_rebuild.py:465-473, 726-738, 270-274` |
| 실외 마스터 자가추론 sys | `s37_rooftop_rebuild.py:106-128` (x_master2 REWRITE_SYS는 작품 동선 하드코딩 → 이식 금지, FACADE & LIVED-IN·국적 앵커·사진 언어 3개 절만 승계) |
| 맵 STYLE_MAP / VERTICAL_NOTE / GROUND_SYSTEM / LOOK_NOTE·MAP_NOTE | `s27_forest_map.py:41-56`, `s29_top_v2.py:159-172`, `s30_shot_apply.py:201-213, 264-306` |

이식 금지(대체 구현): `_time_of`(→ Task B에서 LLM 저작 time_of_day), `_soften`(→ production `prompt_sanitizer` 경로 유지), x_master2 REWRITE_SYS의 동선 문구, `EXT_GROUPS`/`INT_GROUP` 하드코딩(→ 체크포인트 데이터 파생).

## 신규 설정 플래그 (`backend/app/core/config.py`)

```python
# ── s40/s41 콘티·스틸 레시피 (2026-07-13 이식) — 전부 opt-in, default OFF ──
still_recipe_mode: Literal["off", "v1"] = "off"      # 마스터 게이트: 분류·콘티·스틸 레시피
still_recipe_roll_count: int = 3                     # 공통 파이프 롤 수 (1~5)
still_recipe_critique_enabled: bool = True           # 결함 검사+i2i 수정 게이트
plate_multiroll_enabled: bool = False                # 배경 플레이트에 공통 파이프 적용 (Task G)
outdoor_map_conti_enabled: bool = False              # 야외 복잡 구도 맵 기반 분기 (Task E)
```
ENV: `STILL_RECIPE_MODE` 등 대문자. `field_validator`로 `still_recipe_roll_count` 1~5 clamp 검증.

## 스텝 DAG (신규 3 + 소비 1)

```
scene_detail(기존) ──► shot_ref_classify ──► shot_conti_light ──► scene_image_pipeline(기존, recipe 분기)
                  └──► shot_continuity  ──┘        ▲
background_render / background_chain_render(기존 플레이트) ──┘
```
세 신규 스텝 모두 `still_recipe_mode=="off"`이면 `check_applicability` → not applicable(기존 파이프라인 무영향).

---

### Task A: 공통 생성 파이프 — `multiroll_select.py` + 판정 프롬프트 팩

**Files:**
- Create: `backend/app/modules/pipeline/multiroll_select.py`
- Create: `prompts/_base/multiroll_judge/1.202607132300/judge_still.md` (s39 JUDGE_SYSTEM verbatim)
- Create: `prompts/_base/multiroll_judge/1.202607132300/judge_plate_ext.md` (x_plate_chain JUDGE_SYS)
- Create: `prompts/_base/multiroll_judge/1.202607132300/critique.md` (CRITIQUE40_SYS)
- Create: `prompts/_base/multiroll_judge/1.202607132300/fix_head.md` / `fix_tail.md` / `fix_label.md`
- Create: `prompts/_base/multiroll_judge/1.202607132300/judge_schema.json` / `critique_schema.json`
- Modify: `backend/app/core/config.py` (위 플래그 5종 + validator)
- Test: `backend/tests/unit/test_multiroll_select.py`

**Interfaces (Produces):**
```python
# multiroll_select.py — 엔진/판정을 callable 주입으로 받아 결정론 테스트 가능
GenFn = Callable[[str, str, List[Tuple[str, Path]], Path], Path]
#        (tag, prompt, labeled_refs, out_path) -> out_path
JudgeFn = Callable[[str, str, List[Tuple[str, Path]], List[Path]], Dict]
#        (tag, prompt, labeled_refs, cand_paths) -> JUDGE_SCHEMA dict
CritiqueFn = Callable[[str, str, List[Tuple[str, Path]], Path], Dict]
#        (tag, prompt, labeled_refs, image_path) -> {"issues":[{issue_ko,fix_en}]}

def gemini_select(judge: Dict) -> Tuple[Dict[str, int], str]
    # totals {label:score}, selected — 최고점, 동점=ranking 앞선 것 (s39:207-216 이식)

def run_multiroll_select(*, tag, prompt, labeled_refs, out_stem: Path,
    gen_fn: GenFn, judge_fn: JudgeFn, critique_fn: Optional[CritiqueFn],
    fix_gen_fn: Optional[GenFn], roll_count: int = 3,
    critique_enabled: bool = True, record: Optional[Dict] = None) -> Tuple[Path, Dict]
    # 반환: (sel_path={out_stem}_sel.png, record)
    # record = {prompt, totals, selected, ranking, verdicts, refs,
    #           critique:{issues}, fix_prompt?, fix_skipped?}
    # 재개: sel 존재+record에 critique 있음 → 그대로 skip.
    #        sel 존재+critique 없음 → 결함 검사·수정만 소급 (s40 _resume).
    #        롤 파일 존재 → 해당 롤 skip (파일 단위 재개).

def build_fix_prompt(issues: List[Dict], fix_head: str, fix_tail: str) -> str
    # FIX40_HEAD + "CORRECTIONS:\n- {fix_en}..." + FIX40_TAIL
```
롤 라벨은 `roll_count`개: `A,B,...`(최대 5, `judge_schema.json`의 enum은 라벨 동적 생성이 아니라 A~E 고정 superset — 판정 입력에 실제 후보만 첨부). i2i 수정은 **선정 원본 단독 참조**(`[(fix_label, orig)]`).

- [ ] **Step A1:** config.py에 플래그 5종+validator 추가
- [ ] **Step A2:** 프롬프트 팩 파일 8개 작성 — 실험 정본에서 verbatim 복사(위 표), schema 2종은 s29 JUDGE_SCHEMA/CRITIQUE_SCHEMA JSON
- [ ] **Step A3:** 실패 테스트 작성 — `test_gemini_select_tie_uses_ranking`(15점 동점 B/C, ranking=[C,A,B] → C), `test_resume_skips_when_critiqued`, `test_resume_backfills_critique_only`, `test_roll_files_skip_existing`, `test_fix_prompt_assembly`, `test_critique_disabled_copies_selected`(fake gen/judge/critique callable, tmp_path)
- [ ] **Step A4:** `pytest backend/tests/unit/test_multiroll_select.py -v` FAIL 확인
- [ ] **Step A5:** `multiroll_select.py` 구현(s40 `_roll3`/`_judge_gemini`/`_critfix`/`_select`/`_resume` 이식, 파일 단위 재개 포함)
- [ ] **Step A6:** pytest PASS 확인
- [ ] **Step A7:** commit `feat(pipeline): multiroll_select 공통 생성 파이프 — N롤+Gemini 선정+결함 i2i 수정`

### Task B: `shot_ref_classify` 스텝 — bgonly + prev v3 + time_of_day

**Files:**
- Create: `backend/app/modules/pipeline/shot_ref_classify.py`
- Create: `backend/app/core/steps/shot_ref_classify_step.py`
- Create: `prompts/_base/shot_ref_classify/1.202607132300/bgonly_system.md` (s39:225-236 verbatim) / `bgonly_schema.json`
- Create: `prompts/_base/shot_ref_classify/1.202607132300/prev_system.md` (s38:238-269 verbatim) / `prev_schema.json`
- Create: `prompts/_base/shot_ref_classify/1.202607132300/time_of_day_system.md` (신규 저작, 아래) / `time_of_day_schema.json`
- Modify: `backend/app/core/steps/__init__.py` (STEP_CLASSES 등록)
- Modify: `backend/app/core/step_manifest.py` (entry + depends_on=["scene_detail"], 기존 image 스텝 entry 형식 복사)
- Test: `backend/tests/unit/test_shot_ref_classify.py`

**Interfaces (Produces — 체크포인트 `shot_ref_classify/manifest.json` `data`):**
```json
{"shots": {"S{si}sh{shi}": {"person_visible": true, "bgonly_reason_ko": "...",
   "prev": "S{si}sh{shj}|null", "prev_reason_ko": "...", "usage_en": ""}},
 "scenes": {"{scene_index}": {"time_of_day_en": "night", "basis_ko": "..."}}}
```
규칙(코드 강제): ① bgonly(`person_visible=false`) 샷은 prev 판정 결과 무시(`prev=null` 강제) ② prev는 반드시 스토리 순서상 앞 샷 & 목록 내(검증 실패 항목은 null+warning) ③ LLM 입력=씬 원문 전문+샷 목록(각 샷 `[LOCATION: {장소 서술} | sub-space: {서브공간}]` 병기 — 장소 서술은 background_master_plan/출처 체크포인트에서 파생, 하드코딩 금지).

**time_of_day_system.md (신규 계약, 범용):** 씬 헤딩+씬 원문 전문을 주고 씬별 촬영 시간대(영어 한 구·예: "night", "dawn", "day, overcast")와 근거를 저작. substring 규칙 금지, 원문 근거만. INT 씬은 실내 광원 상태 포함 가능.

- [ ] **Step B1:** 프롬프트 팩 작성(verbatim 2종+신규 time_of_day)
- [ ] **Step B2:** 실패 테스트 — `test_bgonly_forces_prev_null`, `test_prev_must_be_earlier_shot`(뒤 샷 지정 → null), `test_prev_not_in_list_rejected`, `test_checkpoint_payload_shape`(fake LLM), `test_step_not_applicable_when_off`
- [ ] **Step B3:** FAIL 확인 → 모듈 구현(`run_shot_ref_classify(shots, scene_texts, location_map, llm_fn) -> dict` — LLM 3콜: bgonly/prev/time_of_day, 모델=`gemini-pro`) → 스텝 구현(applicability=still_recipe_mode ON, `_config_hash`=mode+roll/critique+resolved prompt version) → 등록 2개소
- [ ] **Step B4:** pytest PASS + `python -c "from app.core.step_catalog import STEP_CATALOG; assert 'shot_ref_classify' in STEP_CATALOG"` 확인
- [ ] **Step B5:** commit `feat(steps): shot_ref_classify — bgonly·prev v3·time_of_day LLM 분류 스텝`

### Task C: `shot_continuity` 스텝 — 자세 정본 + 중의어 교정 + carried

**Files:**
- Create: `backend/app/modules/pipeline/shot_continuity_author.py`
- Create: `backend/app/core/steps/shot_continuity_step.py`
- Create: `prompts/_base/shot_continuity/1.202607132300/pose_canon_system.md` (s37:635-651) / `pose_fix_system.md` (s37:682-692) / `carried_system.md` (s37:500-521) + schema 3종
- Modify: `backend/app/core/steps/__init__.py`, `backend/app/core/step_manifest.py` (depends_on=["scene_detail"])
- Test: `backend/tests/unit/test_shot_continuity.py`

**Interfaces (Produces — 체크포인트 `shot_continuity/manifest.json` `data`):**
```json
{"pose_canon": [{"character_short_id": "C01", "pose_en": "...", "shots": ["S1sh2"], "basis_ko": "..."}],
 "pose_fix": {"S1sh2": {"movement_en": "...", "figures_en": "...", "carried_en": "..."}},
 "carried": {"S1sh2": {"carried_en": "...", "basis_ko": "..."}}}
```
carried는 씬 단위 LLM 콜(같은 장소 샷 목록+씬 원문 전문), 양방향 전파·씬 경계 차단은 프롬프트 계약이 담당(코드 재구현 금지). pose_canon 캐릭터는 short_id 매핑(name→short_id는 EntityCanon 조회, 코드).

- [ ] **Step C1:** 프롬프트 팩 verbatim 작성 → 실패 테스트(`test_pose_canon_short_id_mapping`, `test_payload_shape`, `test_not_applicable_when_off`) → FAIL 확인
- [ ] **Step C2:** 모듈+스텝 구현(모델=`gemini-pro`) → 등록 → PASS 확인
- [ ] **Step C3:** commit `feat(steps): shot_continuity — 자세 정본·중의어 교정·carried state 저작 스텝`

### Task D: `shot_conti_light` 스텝 — 경량 콘티 (일반 경로)

**Files:**
- Create: `backend/app/modules/pipeline/shot_conti_light.py`
- Create: `backend/app/core/steps/shot_conti_light_step.py`
- Create: `prompts/_base/shot_conti_light/1.202607132300/light_frame.md` (LIGHT_FRAME) / `bg_ref_clause.md` (BG_REF_CLAUSE) / `no_text.md`
- Modify: `backend/app/core/steps/__init__.py`, `backend/app/core/step_manifest.py` (depends_on=["shot_ref_classify","shot_continuity","background_render"(soft — 플레이트 없으면 텍스트 전용 degrade+warning)])
- Test: `backend/tests/unit/test_shot_conti_light.py`

**Interfaces:**
- Consumes: Task B 분류(`person_visible`/`prev`), Task C(pose_fix/carried), shot→플레이트 경로 맵(`applies_to_shots`[A계열] 또는 `shot_backgrounds`[B계열] — 로더 함수 `resolve_shot_plate_map(project_id, episode_id) -> Dict[tag, Path]` 신설, 두 계열 순차 조회).
- Produces: 체크포인트 `data.contis = {tag: {"image_path": "conti/{tag}.png", "skipped_reason": null|"bgonly"|"prev"|"no_plate"}}`. 파일: `<projects_dir>/<pid>/images/<eid>/conti/{tag}.png` (상대경로 저장).

대상=`person_visible==true and prev is null`인 샷만(bgonly·prev 샷 콘티 없음 — s41 확정). 생성=gpt-image-2 `1536x864` 단일 롤(콘티는 3롤 파이프 미적용 — s41 실증), 참조=해당 샷 플레이트 1장. 프롬프트 조립(s41:100-121 순서): `"Draw ONE storyboard layout frame, 16:9, filling the entire image edge-to-edge."` + LIGHT_FRAME + BG_REF_CLAUSE + `THE LOCATION: {place_en}` + `SHOT TEXT` + movement/figures(pose_fix 우선)/pose_canon 절/carried + no_text.

- [ ] **Step D1:** 실패 테스트 — `test_targets_exclude_bgonly_and_prev`, `test_no_plate_degrades_with_reason`, `test_prompt_assembly_order`(fake gen), `test_resume_skips_existing_png`, `test_not_applicable_when_off`
- [ ] **Step D2:** FAIL 확인 → `resolve_shot_plate_map` + 모듈 + 스텝 구현 → 등록 → PASS
- [ ] **Step D3:** commit `feat(steps): shot_conti_light — 플레이트 느슨 참조 경량 콘티 스텝`

### Task E: 야외 복잡 구도 = 맵 기반 메인 → 경량 콘티 변환 (신규 설계)

**Files:**
- Create: `backend/app/modules/pipeline/outdoor_map_conti.py`
- Create: `prompts/_base/outdoor_map_conti/1.202607132300/complex_judge_system.md` (신규 판별 계약, 아래) / `complex_judge_schema.json`
- Create: `prompts/_base/outdoor_map_conti/1.202607132300/ground_system.md` (s30 GROUND_SYSTEM verbatim+INVENTION_BOUNDARY) / `ground_schema.json`
- Create: `prompts/_base/outdoor_map_conti/1.202607132300/map_bg_notes.md` (LOOK_NOTE+MAP_NOTE+NO_ANNOTATION)
- Modify: `backend/app/modules/pipeline/shot_conti_light.py` (분기 호출)
- Test: `backend/tests/unit/test_outdoor_map_conti.py`

**complex_judge_system.md (신규 판별 계약 — 하드코딩 금지, LLM 판정):** 야외 샷 텍스트만 입력. 판정 기준: "이 샷의 구도가 **여러 인물·구조물의 상대 위치/거리/방향 관계**에 결정적으로 의존하고, 그 공간 관계를 글만으로는 오해하기 쉬운가"(s34 conti_need 계약의 공간 배치 특화판). 출력 `{items:[{shot, complex: bool, reason_ko}]}`. 실내 샷·단순 구도는 false.

**흐름(`outdoor_map_conti.run_for_shot`):** ① 야외 샷 & `outdoor_map_conti_enabled` & complex==true ② 사이트 맵 확보 — `outdoor_site_layout` 체크포인트의 site plan PNG 존재 시 사용, 없으면 일반 경로 fallback(+`skipped_reason="no_site_map"`) ③ ground: SITE PLAN 이미지+마커 legend+샷 텍스트 → `{map_zone, anchor_markers, camera_position_en, look_direction_en, in_frame_en}` (모델=`gemini-pro`) ④ 맵 기반 배경 플레이트: nb2, 참조=[룩 마스터(있으면)+SITE PLAN(배치 전용 라벨)], 프롬프트=CAMERA/MAP POINT/IN FRAME+LOOK_NOTE+MAP_NOTE ⑤ 그 플레이트를 Task D 콘티의 참조 플레이트로 사용(LIGHT_FRAME 변환) — 이후 스틸 단계는 일반 샷과 완전 동일.

- [ ] **Step E1:** 실패 테스트 — `test_complex_false_uses_normal_path`, `test_no_site_map_falls_back`, `test_ground_schema_enum_from_markers`(마커 코드 enum 동적 제약), `test_map_bg_prompt_assembly`, `test_flag_off_bypasses`
- [ ] **Step E2:** FAIL 확인 → 판별·ground·맵 플레이트·콘티 연결 구현 → PASS
- [ ] **Step E3:** commit `feat(pipeline): outdoor_map_conti — 야외 복잡 구도 맵 grounding→경량 콘티 변환`

### Task F: 스틸 생성 레시피 — 참조 규칙 + 텍스트 안전망 + 공통 파이프 배선

**Files:**
- Create: `backend/app/modules/pipeline/still_recipe.py`
- Create: `prompts/_base/still_recipe/1.202607132300/` — `realize_still.md`(s34), `expression_realism.md`(s38), `prop_orientation.md`(s39), `people_clause.md`/`no_people_clause.md`(s39), `plate_label.md`/`prev_label.md`/`conti_label.md`/`char_label.md`/`prop_label.md`(s37·s38 라벨), `head.md`(스틸 헤드 — "Create ONE FINAL photorealistic live-action film still..." 국가·연대는 프로젝트 style_rules/world 데이터에서 주입, 하드코딩 금지)
- Modify: `backend/app/services/scene_generation_coordinator.py`(또는 scene_image_service 소비 지점 — 구현 시 `_generate_scene_in_loop` 확인) — `still_recipe_mode=="v1"` 분기: 신규 경로 호출
- Modify: `backend/app/core/steps/image_steps.py` `SceneImagePipelineStep._config_hash` — mode ON일 때만 recipe 키 스탬프
- Test: `backend/tests/unit/test_still_recipe.py`

**Interfaces:**
```python
# still_recipe.py
def build_still_refs(tag, classify, conti_map, plate_map, prev_sel_map, ve_entities, char_refs, prop_refs) -> List[Tuple[str, Path]]
   # bgonly: [플레이트만]  /  prev: [플레이트, prev _sel, 엔티티...]  /  일반: [플레이트, 콘티, 엔티티...]
def build_still_prompt(tag, shot, classify, continuity, traits_map, place_en, time_of_day_en, style_ctx) -> str
   # s39 _build_prompt ①~⑭ 순서 이식. bgonly → NO PEOPLE + 자세 정본 절 생략.
   # prev → CONTINUES 절 + usage_en. traits는 entity_canon.stable_traits 주입.
def generate_still_v1(tag, prompt, refs, out_stem, clients, record_map) -> Path
   # multiroll_select.run_multiroll_select 호출 (nb2 gen_fn + gemini-pro judge/critique + nb2 fix)
```
스토리 순서 순차 생성(prev 앵커=선정·수정본 `_sel`): recipe 분기에서는 배치 병렬 대신 `(scene_index, shot_index)` 정렬 순차 루프. 생성물 영속: 최종 `_sel`→기존 `save_scene_image` 경로(ImageAsset primary), 롤 A/B/C·수정 전 원본→`capture_generated_image(disposition="rejected"/"intermediate")` (persist-all 선례). VE 공백 fallback=같은 씬 선택 샷 VE 합집합(코드).

- [ ] **Step F1:** 실패 테스트 — `test_refs_bgonly_plate_only`, `test_refs_prev_uses_sel`, `test_refs_normal_plate_conti_entities`, `test_prompt_bgonly_no_people_no_pose`, `test_prompt_prev_continues_and_usage`, `test_prompt_traits_injection`, `test_story_order_sequential`, `test_ve_fallback_scene_union`, `test_config_hash_stamped_only_when_on`
- [ ] **Step F2:** FAIL 확인 → still_recipe.py 구현 → coordinator 분기 배선(플래그 OFF 시 기존 경로 byte-identical) → PASS
- [ ] **Step F3:** 기존 회귀 확인 — `pytest backend/tests/unit -k "scene_image or still" -x`
- [ ] **Step F4:** commit `feat(pipeline): still_recipe v1 — 다중 참조 스틸 + 텍스트 안전망 + 3롤 파이프 배선`

### Task G: 플레이트 공통 파이프 적용 (opt-in) + 실외 마스터 v2 절

**Files:**
- Create: `backend/app/modules/pipeline/plate_multiroll.py` (background_render 노드 렌더를 nb2 3롤+판정+수정으로 수행하는 대체 렌더러 — 기존 함수 무변경, 분기 호출)
- Create: `prompts/_base/background_master_plan/<기존 최신+1>.202607132300/` — 실외 마스터 프롬프트에 v2 교정 3절(사진 언어·국적 앵커는 데이터 주입·FACADE & LIVED-IN 위임 절) 추가한 새 팩(기존 팩 복사 후 수정)
- Modify: `backend/app/modules/pipeline/background_render.py` — `plate_multiroll_enabled` 분기(함수 앞단에서 plate_multiroll로 위임)
- Modify: `backend/app/core/steps/background_render_step.py` `_config_hash` — flag ON일 때만 스탬프
- Test: `backend/tests/unit/test_plate_multiroll.py`

실외 플레이트 판정 sys=`judge_plate_ext.md`(마스터 구조 일치+지역 사실성), 실내=`judge_still.md` 계열. 실내 체이닝 참조 규칙(FP+뿌리+직전 **선정본**)은 기존 background_render ref 우선순위에 `_sel` 산출물을 앵커로 쓰도록 위임 렌더러에서 처리.

- [ ] **Step G1:** 실패 테스트 — `test_flag_off_uses_legacy_renderer`, `test_multiroll_chain_anchor_is_sel`, `test_config_hash_legacy_preserved`
- [ ] **Step G2:** FAIL 확인 → 구현 → PASS → 기존 background_render 테스트 회귀 확인
- [ ] **Step G3:** commit `feat(pipeline): plate_multiroll — 배경 플레이트 3롤+판정+수정 opt-in + 마스터 v2 프롬프트 팩`

### Task H: Codex diff 리뷰 → 수정 → 테스트 → E2E → 최종 커밋

- [ ] **Step H1:** `git diff main...HEAD --stat` 정리 후 tmux-bridge로 Codex에 `[claude로부터 - 요청]` diff 리뷰 요청(Enter 필수, 답신 push 요청 포함)
- [ ] **Step H2:** 리뷰 지적 수정 → 재리뷰 → APPROVED
- [ ] **Step H3:** 전체 유닛 테스트 `pytest backend/tests/unit -x -q` PASS
- [ ] **Step H4:** E2E — 금월도(또는 활성 프로젝트) 에피소드에 `STILL_RECIPE_MODE=v1`로 신규 스텝 3종 실행 + 스틸 소량 생성 → 갤러리/UI 확인(LAN IP)
- [ ] **Step H5:** 최종 커밋·메모리 갱신

## Self-Review 체크 결과

- 스펙 커버리지: 공통 파이프=A, 샷 분류=B, 안전망 저작(pose/carried)=C, 경량 콘티=D, 맵 기반=E, 스틸 참조+안전망+순차 체인=F, 플레이트+마스터 v2=G. 스펙 '단계 1 실내 FP 자가발견'은 production floor_plan 계열이 이미 담당(스켈레톤 존재) — G에서 체이닝 앵커만 레시피화. ✅
- 규칙 충돌 4종(_time_of/_soften/동선 하드코딩/그룹키)은 전부 대체 구현 명시. ✅
- 타입 일관성: `tag = "S{si}sh{shi}"` 전 태스크 공통, classify payload 키(B)를 D/E/F가 동일 참조. ✅
