"""still_recipe — 스틸 프롬프트 조립·참조 규칙 (2026-07-13 레시피 이식).

s39 `_build_prompt` + s40 `stage_stills` 참조 조립의 production 이식(순수
로직 — 파일/DB/LLM 없음, 서비스 어댑터는 still_recipe_service).

참조 규칙 (s40/s41 실증):
  - bgonly 샷: [플레이트만] + NO PEOPLE 절 + 자세 정본 절 생략(인물 유도 방지)
  - prev 샷:   [플레이트 + prev **선정본**(_sel) + 엔티티] + usage_en 절 (콘티 없음)
  - 일반 샷:   [플레이트 + 경량 콘티 + 엔티티]

텍스트 안전망 (전부 범용 계약, prompts/_base/still_recipe 팩):
  head(TIME OF DAY lock) → SHOT TEXT → LOCATION lock → CONTINUES/usage(prev)
  → 자세 정본 → REALIZE → EXPRESSION_REALISM → PROP_ORIENTATION
  → MOVEMENT/FIGURES → CARRIED → PEOPLE(traits 병기)/NO PEOPLE → no_text.

실험의 `_soften`(고어 치환)·국가 하드코딩 head 는 이식하지 않는다 —
world_anchor 는 호출자가 프로젝트 데이터에서 파생해 주입(빈 문자열 허용).
"""
from __future__ import annotations

import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple

from app.modules.prompt_loader import load_prompt

logger = logging.getLogger(__name__)

_MODULE = "still_recipe"

PROMPT_VERSION_MAP = {
    "1": "1.202607132300",
    # v2 (Stage D): v1 동일 + lane 참조 라벨 2종(sketch_label/seed_label) —
    # 야외 lane 샷 전용 selector. 비 lane 샷은 "1" 유지(byte-identical).
    "2": "2.202607160030",
    # v3 (Codex Stage D HIGH-3): v2 동일 + lane location authority 2종
    # (location_lock_lane_sketch/seed) — lane 샷 프롬프트가 존재하지 않는
    # LOCATION PHOTOGRAPH 를 참조하던 모순 제거. lane 샷 현행 selector.
    "3": "3.202607160200",
    # v4 (2026-07-16 복잡 구조물=A/B, Codex R6): 플레이트·seed 관할
    # tie-break — plate_label(주변·서브공간·시간·조명 SOT)+seed_label
    # (구조물 identity SOT)+structure_look_clause(충돌 시 구조물=seed
    # 우선). 복잡 구조물 샷(A/B·bg_only) 전용 selector — 일반 샷은 "1",
    # 레인1 스케치 샷은 "3" 유지(byte-identical).
    "4": "4.202607162320",
    # v5 (2026-07-17 E2E7 실측, Codex seed-bg 합의): 플레이트 producer 가
    # 구조적으로 없는 structure_plate 그룹의 seed-bg 단일 권위 —
    # seed_bg_label(LOCATION+구조물+영구 site detail 단일 SOT)+
    # location_lock_seed_bg. structure_look_clause·plate tie-break 는
    # seed-bg 케이스 비적용. v4 는 무변경(덮어쓰기 금지).
    "5": "5.202607171100",
    # v6 (2026-07-19 E2E9 S19sh4 실측 — 스틸 공간 계약 fix1): CAMERA/FRAME
    # 절 **단일 스템 팩** — camera_frame_clause 만 포함. 기존 팩 1~5 는
    # 무변경(덮어쓰기 금지); build_camera_frame_clause 가 이 스템만 명시
    # 버전으로 로드해 base 프롬프트에 주입한다(다른 스템은 샷별 기존
    # selector 유지 — 팩 간 wording 드리프트 0).
    "6": "6.202607192140",
    # v7 (2026-07-20 BGFIRST2 이식 ②③): 2단 체인 전용 스템 팩 —
    # bg_reproject_head/tail(Step1 GPT 재투영 인물 0 빈 배경),
    # stage_head(Step2 nb2 인물 삽입 헤더), bg_label/sketch_label(체인
    # 참조 라벨), judge_header(2택1 중립 판정 헤더). 기존 팩 1~6 무변경.
    # 정본=exp_chainA_true_bgfirst.bg_prompt/final_prompt (0adc4df1…).
    "7": "7.202607201550",
    # v8 (2026-07-21 E2E10 fix⑤): LIGHTING & MOOD 절 **단일 스템 팩** —
    # lighting_mood_clause 만 포함(절 헤더+재질 사실감 정적 계약). 조명
    # SOT=shot_staging.lighting_mood (scene_still.lighting_json 은 v4
    # 미저작 — E2E10 192행 전부 '{}' 실측). 기존 팩 1~7 무변경, v6
    # camera_frame_clause 단일 스템 선례와 동형.
    "8": "8.202607211510",
    # v9 (2026-07-21 E2E10 fix③④, BGFIRST full): 전면화 전용 스템 팩 —
    # groupbg_head/tail(no_plate 그룹의 장소 단위 배경 — 콘티=공간 근거·
    # 무인·샷 중립), bg_reproject_seed_clause(Step1 3번째 참조 STRUCTURE
    # LOOK 관할 — complex 샷 체인 편입). 기존 팩 1~8 무변경.
    "9": "9.202607211540",
    # v10 (2026-07-22 E2E11 fix④⑤⑥): ①naturalism_clause(차렷 직립·무표정
    # 렌즈 응시 금지 — 예외=샷 텍스트 명시 제식·운동·진료·직시, S15sh1
    # 실측) ②drawn_mark_clause(그려진 표식=스트로크 윤곽 — 원문 "붓으로
    # 원을 그리는 것처럼"이 채운 원반으로 렌더된 S12sh11 실측) ③groupbg/
    # bg_reproject head 개정 — 무인="사람만": 장소 상주 동물·물품 유지
    # (보호소 빈 개우리 S7sh2 실측)+휴먼 스케일 앵커. 기존 팩 1~9 무변경.
    "10": "10.202607221120",
    # v11 (2026-07-22 E2E11 ③ S3sh3 실측 — groupbg 장소 정합): 장소 근거
    # 절 헤더 2종 추가 — groupbg_detail_head(LOCATION DETAIL: entity_merge
    # loc description/visual_traits 코드 조립)+groupbg_evidence_head(SCENE
    # EVIDENCE: share 그룹 evidence 원문 인용 — 장소 사실만, 순간 연출
    # 미묘사). 기존 groupbg 근거=place_en 1문장+콘티뿐이라 감식 현장이
    # 허허벌판으로 렌더되던 결손. 나머지 스템=v10 사본(1~10 무변경).
    "11": "11.202607220237",
    # v12 (2026-07-26 확정 흐름 — lane 배경 i2i): bg_fill_head/tail 2종
    # 추가 — 참조 이미지 0 으로 마네킹 콘티 자체를 i2i 로 채색하는 스템
    # (마네킹 보존 계약 + 장소 사실 텍스트의 배치 무권한 선언). 나머지
    # 스템=v11 사본(1~11 무변경, 덮어쓰기 금지).
    "12": "12.202607270537",
    # v13 (2026-08-06): no_people_clause **단일 스템 팩** — 무인 샷 조항이
    # "any body part" 까지 금지해, 소지품 인서트에서 쥔 손이 지워지고 물건이
    # 허공에 떴다(실측 S42sh4 — 폰이 손도 지지물도 없이 떠 있고, 네 판정
    # 모델이 모두 "닿은 것 없음"으로 확인). 사람은 계속 금지하되, **연출이
    # 요구하는 손·팔은 허용**한다. 기존 팩 1~5 는 무변경(덮어쓰기 금지) —
    # `NO_PEOPLE_PROMPT_VERSION` 이 이 스템만 명시 버전으로 로드한다.
    "13": "13.202608062330",
    # v14 (2026-08-06): people_clause_scene **단일 스템 팩** — 샷 VE 가 비어
    # `ve_ids_for_shot` 이 같은 씬 다른 샷의 인물을 끌어왔을 때 쓰는 비배타
    # 문구. 기존 `people_clause` 는 "must be one of X — never anyone else" 라
    # 배타 조항이 붙는데, 그 X 가 **추측**일 때는 정답을 배제한다(실측 S11sh3:
    # 샷 텍스트는 `정인우`인데 옆 샷에서 끌어온 `김지후`가 박혀, 흰 순찰차+
    # 남성 형사인 정답 후보 A·B 가 두 심판 모두에게 "허용 안 된 인물" hard
    # violation 으로 탈락하고 검은 일반차 후보가 확정됐다). 255 선택 샷 중
    # 이 경로를 타는 샷 36개(14%). 확정 배정(샷 VE 존재)일 때는 기존
    # `people_clause` 를 그대로 쓴다 — 그 경우 배타 조항이 옳다.
    # 기존 팩 1~13 무변경(덮어쓰기 금지).
    "14": "14.202608062342",
    # v15 (2026-08-07): 좁고 복잡한 실내의 **기하 권위** 스템 2종 —
    # stage_head_geom / sketch_label_geom. 실물 대조가 근거다: 자동차
    # 캐빈 샷의 배경 산출은 핸들 1개·좌석 배치가 정확한데 **인물 삽입 뒤**
    # 림이 이중이 되고 좌석 관계가 흐트러졌다. 프롬프트가 그렇게 시키고
    # 있었다 — `sketch_label` 이 콘티를 "people placement only" 로 규정하고
    # `stage_head` 가 "Ignore the sketch's background lines" 라고 명시해,
    # 콘티에 정확히 그려진 기하의 권위를 삽입 단계에서 스스로 버렸다.
    # v15 는 그 두 문장을 대체한다: 배경의 조작 장치·좌석·거울을 **세고**
    # 같은 수·같은 자리로 유지하게 하고, 스케치의 배경선을 인물과 구조의
    # 관계(어느 좌석·핸들 어느 쪽·무엇의 앞뒤)를 읽는 데 쓰게 한다.
    # 가리는 것과 다시 그리는 것은 다르다고 못 박는다.
    # ★좁은 실내 **전부**가 아니라 복잡한 구조일 때만 — 가구 없는 방 같은
    # 단순 공간은 대상이 아니다(사용자 확정). 기존 팩 1~14 무변경.
    "15": "15.202608071200",
    # v16 (2026-08-12 차렷/증명사진 대응): identity_ref_role_clause **단일
    # 스템 팩** — 캐릭터 identity 참조(정면 응시 시트)의 포즈·시선·구도가
    # 스틸로 전이되는 경로를 역할 한정 문장으로 차단한다(참조=얼굴·헤어·
    # 체격·복장 동일성 전용, 포즈·시선 권위=본 프롬프트 CAMERA/동작 서술).
    # `still_identity_ref_role_enabled`(기본 OFF) + 캐릭터 참조 실첨부 샷
    # 에서만 절이 나간다 — OFF 또는 캐릭터 참조 0 샷은 조립 byte-identical.
    # 기존 팩 1~15 무변경(덮어쓰기 금지), v6 camera_frame 단일 스템 선례 동형.
    "16": "16.202608121833",
    # v17 (2026-08-13 grok 컴팩트): grok 계열 8,000바이트 프롬프트 상한 대응
    # — v5 전체 스템 승계 + 지도 절 9종 컴팩트 재저작(계약 보존·원문 무손실)
    # + cinematic_finish/broll_composition_variation 신설. guidance_version
    # 오버라이드 전용 selector — 기본 조립(비 grok)은 1 byte 도 안 움직인다.
    "17": "17.202608130310",
    # v18 (2026-08-13 육안 8건 wave — 사용자 지시): 2스템 팩.
    # ①identity_ref_role_clause 확장 — v16 문면 + 얼굴 가림 우선 조항
    #  (S65sh10: SHOT TEXT 가 탈로 얼굴을 가리는데 참조가 얼굴을 끌어와
    #  반착용 기형 — 가림 연출이 참조 identity 보다 우선함을 명문).
    # ②support_stillness_clause 신규 — 몸-지지 정합(착석은 가구 설계 방향
    #  대로, S64sh4 역방향 착석)+정지=행동 중단 재료(S39sh4 차렷: 굳는
    #  순간은 대칭 차렷 복귀가 아니라 하던 동작의 중간 정지). 두 스템 다
    #  guidance 컴팩트와 무관한 단일 SOT(camera_frame v6 선례) — 전 백엔드
    #  공통 로드. 기존 팩 1~17 무변경(덮어쓰기 금지).
    "18": "18.202608131121",
    # v19 (2026-08-13 #108 i2i 시네마틱 변환 스테이지): cine_transform 단일
    # 스템 팩 — sel 확정 후 grok 2.0 i2i 변환의 문안 SOT(파일럿
    # grok_cine_batch 문안 승격, 시나리오 고유명사 0). 화풍 교체 = 새 팩
    # 버전 발행 + CINE_TRANSFORM_PROMPT_VERSION bump. 기존 팩 1~18 무변경.
    "19": "19.202608131440",
    # v20 (2026-08-14 사용자 지시 "원어 텍스트를 그리게 하자, 꼭 필요한
    # 경우만"): **표기 정책 개정 스템 팩** — no_text·bg_reproject_tail·
    # bg_fill_tail·groupbg_tail·cine_transform 5스템. 절대 금지("No text
    # ... anywhere" — 모델이 글자를 못 그리던 시절의 유산)를 걷어내고:
    # 장면·장소가 실제로 부르는 실물 표기(간판·현수막·서류·차량 표기)는
    # 그 세계의 원어로 허용 / 자막·캡션·워터마크·오버레이 금지 유지
    # (grok 이 SHOT TEXT 를 화면 자막으로 굽던 실측) / 지폐 액면 등 정밀
    # 문자는 연출로 가림 유지(액면을 못 그리는 실측) / 발명 금지.
    # bg_fill 은 스케치 도식·마커 금지도 유지(lane 기능 제약). 기존 팩
    # 1~19 무변경 — 표기 스템만 아래 전용 selector 로 이 버전을 읽는다.
    "20": "20.202608141300",
    # v21 (2026-08-14 #119②③ — 카나리아 육안 3결함 수리): ⓐprev 스틸
    # 인물 계승 스코프 — 앵커와 이 샷의 인물 겹침을 코드가 계산해 겹친
    # 인물만 의상 잠금, 겹침 0 이면 "그 사진의 인물은 이 샷에 없다"
    # 명문(prev_label_cast·continues_clause_cast·people_rule 2종) —
    # S64sh4 롤 A 가 앵커 인물 외모·의상을 다른 인물에게 복제한 실측
    # 대응. ⓑsignage_section — 저작 공급된 원어 문안 렌더 머리말(S3
    # 백지 팻말 실측 대응). 기존 팩 1~20 무변경.
    "21": "21.202608141700",
    # v22 (2026-08-18 grok 텍스트 상한 대응 — 사용자 확정 "grok 일 때만"):
    # 컴팩트 지도 절(v17) 이후 추가된 절 다섯의 **컴팩트판**. v17 은 grok
    # 조립을 8,000바이트 아래로 내리려고 만들었는데, 그 뒤 들어온 이 절들이
    # guidance selector 를 안 따라 원본 크기 그대로 실려 상한을 다시 넘겼다
    # (직전 판 실측 중앙값 9,787B, 상한 초과 92.3%). 뜻은 그대로 두고 문장만
    # 줄인다 — 되돌리기가 아니라 현행 판을 보고 새로 저작한 것이다(옛 버전
    # 복귀는 그 사이 개정을 잃는다: identity v18 의 얼굴 가림 우선 조항,
    # no_text v20 의 원어 표기 허용 정책). 기존 팩 1~21 무변경.
    # ★이 팩은 grok 조립에서만 읽힌다 — nb2 는 기존 스템 그대로 바이트 동일
    # (grok_stem_version 이 갈림의 단일 지점).
    "22": "22.202608180100",
}

# lane 샷 현행 selector — service/hash 공용 SOT
LANE_PROMPT_VERSION = "3"
# 복잡 구조물(A/B, plate 있는 그룹) 샷 현행 selector — service/hash 공용 SOT
COMPLEX_PROMPT_VERSION = "4"
# 복잡 구조물 seed-bg(플레이트 구조적 부재 그룹) selector
SEED_BG_PROMPT_VERSION = "5"
# CAMERA/FRAME 절 스템 selector (fix1) — service/hash 공용 SOT
CAMERA_FRAME_PROMPT_VERSION = "6"
# 무인 샷 조항 단일 스템 팩 selector (2026-08-06) — camera_frame 선례 동형.
# 샷별 기존 selector 를 건드리지 않고 이 스템만 v13 으로 로드한다.
NO_PEOPLE_PROMPT_VERSION = "13"
# 표기 정책 스템 selector (2026-08-14 v20) — no_text 와 bg 계열 tail 을
# 샷별 selector(_pack)·guidance 와 무관하게 이 버전으로 읽는다: 표기
# 정책은 경로(일반/lane/seed/grok 컴팩트/배경) 무관 전역 계약이다.
TEXT_POLICY_PROMPT_VERSION = "20"

# prev 스틸 인물 계승 스코프 + 표기 문안 렌더 스템 selector (2026-08-14
# #119②③) — camera_frame 단일 스템 선례 동형: 샷별 selector 와 무관하게
# 이 버전으로만 읽는다. 절이 렌더되는 조건 자체는 호출자가 소유한다
# (prev_people_rule/signage_en 빈 문자열 = 기존 조립 byte-identical).
PREV_CAST_SCOPE_PROMPT_VERSION = "21"

# 씬 단위 추측 인물 목록 전용 스템 — `char_names` 가 샷 확정 배정이 아니라
# `ve_ids_for_shot` 의 씬 합집합 fallback 에서 왔을 때만 이 스템을 쓴다.
PEOPLE_SCENE_PROMPT_VERSION = "14"
# LIGHTING & MOOD 절 스템 selector (E2E10 fix⑤) — service/hash 공용 SOT
LIGHTING_MOOD_PROMPT_VERSION = "8"
# 좁고 복잡한 실내의 기하 권위 스템 selector (2026-08-07) — camera_frame
# 단일 스템 선례 동형. 이 selector 는 **판별이 참인 샷에만** 적용되고,
# 나머지 샷의 조립은 1 byte 도 움직이지 않는다.
GEOM_AUTHORITY_PROMPT_VERSION = "15"
# R3 (Codex 설계 리뷰 BLOCKING-3): 복잡 구조물 A/B 강제 계약 버전 —
# A/B 대상·refs 조립·seed 관할이 바뀌면 bump (scene config_hash 스탬프).
# v2 (2026-07-17): seed-bg typed single-authority fallback (Codex 조건
# 5 — failed entry resume 고착 방지 겸용).
COMPLEX_AB_CONTRACT_VERSION = 2
# lane 라벨 stem 은 v2 부터, location authority stem 은 v3 부터 —
# selector 별 조립 계약
_LANE_LABEL_PACKS = {"2", "3"}
_LANE_LOCATION_PACKS = {"3"}
# 플레이트와 공존하는 STRUCTURE LOOK(seed) 조립·절은 v4 부터
_STRUCTURE_LOOK_PACKS = {"4", "5"}
# seed-bg 단일 권위 조립·절은 v5 부터
_SEED_BG_PACKS = {"5"}
# BGFIRST2 (2026-07-20 이식 ②③) — 2단 체인 스템 selector·계약 버전.
# eligibility/체인 조립/2택1 구조가 바뀌면 contract bump (지문·hash 스탬프).
BGFIRST_PROMPT_VERSION = "7"
# v2 (2026-07-20, E2E10 결함 1호): eligibility 에 구조적 skip 3종
# (no_plate/prev/bgonly) 비대상 도입 — bgfirst_structural_skip
BGFIRST_CONTRACT_VERSION = 2
# Step1 재투영 엔진 — nb2 는 플레이트 프레이밍 고수 실측(재투영 실패,
# S22sh2 코너 뷰 수렴) → gpt-image-2 필수 (스케치-사진 정렬 강점)
BGFIRST_BG_IMAGE_MODEL = "gpt-image-2"
BGFIRST_BG_SIZE = "1536x864"
# BGFIRST full (2026-07-21 E2E10 fix③④, 사용자 확정 "콘티=무조건 배경
# 우선 전면화") — 전용 스템 selector·계약 버전. eligibility 확장/groupbg/
# 위치 권위 해석/complex 편입 구조가 바뀌면 contract bump.
# v10/v2 (2026-07-22 E2E11 fix⑥): groupbg·재투영 head 개정(무인="사람만"
# — 장소 상주 동물·물품 유지+휴먼 스케일 앵커) — Step1/groupbg 산출 실질
# 변경이라 contract bump.
# v11/v3 (2026-07-22 E2E11 ③): groupbg 장소 근거 강화 — LOCATION DETAIL
# (loc description/traits)+SCENE EVIDENCE(share 그룹 인용) 절 조립.
# groupbg 산출 실질 변경이라 contract bump(sidecar 재사용 자동 무효화).
BGFIRST_FULL_PROMPT_VERSION = "11"
BGFIRST_FULL_CONTRACT_VERSION = "bgfirst_full_v3"
# lane(map_marker) 확정 흐름 전용 — v7(비 full)·v11(full) 과 역할이
# 갈라져 있어 한 상수에 세 역할을 섞지 않는다. bg_fill_* 스템(참조 0
# i2i 채색)과 장소·world 사실 블록 필수 계약이 이 selector 의 내용이다.
BGFIRST_LANE_PROMPT_VERSION = "12"
BGFIRST_LANE_CONTRACT_VERSION = "1"
# 체인 Step2 LOCATION authority 스템 selector (2026-07-27 리뷰 F1) —
# 체인 저작 base 는 팩 "1" 로 조립되므로 샷 팩과 무관한 **명시 버전**으로
# 이 스템만 로드한다(v6 camera_frame / v8 lighting_mood 단일 스템 선례
# 동형 — 다른 스템·다른 경로 조립은 1 byte 도 움직이지 않는다).
# 팩 자체는 BGFIRST_LANE_PROMPT_VERSION 과 동일 디렉토리라, config_hash
# 의 bgfirst_lane_pack 스탬프(still_lane_prev_bgfirst_enabled 게이트 —
# 체인 저작이 열리는 유일한 조건)가 그대로 이 스템을 덮는다.
CHAIN_BG_LOCATION_PROMPT_VERSION = "12"
# 연기·형상 계약(naturalism+drawn_mark) 스템 selector — E2E11 fix④⑤
STILL_CONDUCT_PROMPT_VERSION = "10"
# 캐릭터 identity 참조 역할 한정 절 스템 selector (2026-08-12 차렷/증명사진
# 대응) — camera_frame 단일 스템 선례 동형. 플래그 OFF(기본)면 이 selector
# 는 어떤 조립에도 닿지 않는다.
# v18 (2026-08-13): 얼굴 가림 우선 조항 동반 — guidance 오버라이드와 무관한
# 단일 SOT 로 로드한다(컴팩트 v17 의 구판 스템은 더 이상 이 절의 SOT 아님).
IDENTITY_ROLE_PROMPT_VERSION = "18"
# 몸-지지 정합·정지=행동 중단 재료 절 selector (2026-08-13 육안 8건 wave)
# — 인물 샷 전 백엔드 공통 주입, 단일 스템 SOT.
SUPPORT_STILLNESS_PROMPT_VERSION = "18"
# grok 컴팩트 지도 절 selector (2026-08-13) — still_image_backend="grok2"
# 일 때만 build_still_prompt(guidance_version=...) 로 주입된다. xAI 이미지
# 계열의 8,000바이트 텍스트 상한(운영 실측 400) 대응: 재료(원문·전사·잠금)
# 는 무손실, 지도 절만 컴팩트판으로 로드. 기본(빈 문자열)=byte-identical.
STILL_COMPACT_PROMPT_VERSION = "17"
# i2i 시네마틱 변환 문안 스템 selector (2026-08-13 #108) — sel 확정 후
# grok 2.0 변환 스테이지 전용 단일 SOT. 조립 지도 절과 무관한 별도 스테이지
# 재료라 이 selector 는 still_cine_transform_enabled ON 에서만 닿는다.
# 화풍 교체 = 새 팩 버전 발행 + 이 selector bump (코드 무변경).
CINE_TRANSFORM_PROMPT_VERSION = "20"


# grok 텍스트 상한 대응 컴팩트 스템 팩 (2026-08-18) — 아래 헬퍼로만 읽는다.
STILL_GROK_STEM_VERSION = "22"


def grok_stem_version(version_selector: str, default: str) -> str:
    """grok 조립에서만 컴팩트 스템으로 가른다 — **갈림의 단일 지점**.

    조건은 하나뿐이다: `version_selector` 가 실려 있는가. 그 값은
    still_recipe_service 가 `still_image_backend == "grok2"` 일 때만 채우고
    nb2 에서는 빈 문자열이다. 비어 있으면 `default`(기존 스템 버전)를 그대로
    돌려주므로 **nb2 조립은 바이트 동일**이다 — 이미 완주한 에피소드의 지문을
    흔들지 않는다는 것이 이 함수가 지키는 계약이고, 시험이 그것을 잠근다
    (tests/unit/test_grok_compact_stems_backend_split.py).

    왜 필요한가: xAI 이미지 모델의 텍스트 상한은 8,000바이트다(모델이 스스로
    선언하고 초과 요청은 400 으로 거부된다). 롤 하나가 상한을 넘으면 그 롤이
    죽고, 롤 하나가 죽으면 샷 전체가 죽는다(multiroll_select 가 예외를 그대로
    올린다). 컴팩트 지도 절(v17)만으로는 −1,752B 라 모자란다.
    """
    return STILL_GROK_STEM_VERSION if version_selector else default


# 상한을 넘는 롤에서 덜어낼 **일반 규칙 절**의 순서 (2026-08-18).
# 컴팩트 스템을 전부 적용해도 직전 판 실측으로 378롤 중 61롤(샷 43/189)이
# 상한을 넘는다 — 넘는 양은 중앙 246B, 최대 1,729B 다. 아래 여섯을 다 덜면
# 2,019B 라 전 구간을 덮는다. 순서는 "그 샷에 해당할 확률이 낮은 것"부터.
# ★샷 고유 재료(샷 텍스트·장소 잠금·카메라·조명·인물·간판·소지품·연속성)는
#  목록에 없다 — 덜어내지 않는다. 여섯 모두 변수가 없는 고정 문안이라
#  **팩에서 렌더한 정확한 문자열을 지우는 것**으로 처리한다(글자 패턴으로
#  뜻을 판단하지 않는다 — 문자열이 없으면 아무것도 안 지운다).
GROK_SHED_ORDER = (
    "drawn_mark_clause",
    "prop_orientation",
    "naturalism_clause",
    "expression_realism",
    "realize_still",
    "immobile_physics",
)

# 덜어내기 정책 서명 — 보내는 바이트를 정하는 것은 목표 상한과 덜어낼
# 순서 둘뿐이다. 덜어내기는 발송 직전(grok_image_client)에 일어나 지문에
# 접히는 명목 프롬프트에는 안 남으므로, 정책만 바꾸면 스텝은 다시 열려도
# 완료 샷의 지문이 안 움직여 옛 정책으로 만든 그림이 그대로 남는다.
#
# 아래 기준선과 서명이 달라지는 순간 샷 지문에 키가 붙어 **완료 샷이 전부
# 다시 만들어진다**. 정책을 바꿨다면 그것이 의도한 결과다 — 기준선을 따라
# 고쳐 무마하지 말 것(그러면 낡은 그림이 그대로 남는다).
#
# 하드 상한(GROK_TEXT_BYTE_HARD_LIMIT)은 일부러 뺐다: 보낼지 예외로 죽을지만
# 가르고 만들어진 그림의 바이트는 안 바꾼다. 죽은 샷은 산출이 없어 지킬
# 그림도 없다. (스텝 층 지문은 image_steps 가 하드 상한까지 접는다 — 거기는
# "스텝을 다시 열까"를 정하는 자리라 판단 기준이 다르다.)
GROK_SHED_POLICY_BASELINE = (
    "7900|drawn_mark_clause,prop_orientation,naturalism_clause,"
    "expression_realism,realize_still,immobile_physics"
)


def grok_shed_policy_signature() -> str:
    """덜어내기 정책의 현재 서명 — 값에서 뽑는다(손으로 올리는 버전 아님)."""
    from app.modules.llm.grok_image_client import GROK_TEXT_BYTE_LIMIT

    return f"{GROK_TEXT_BYTE_LIMIT}|" + ",".join(GROK_SHED_ORDER)


def fit_grok_prompt(
    prompt: str,
    extra_bytes: int,
    limit: int,
    version_selector: str = STILL_COMPACT_PROMPT_VERSION,
) -> Tuple[str, List[str]]:
    """xAI 텍스트 상한에 맞춰 일반 규칙 절만 덜어낸다.

    Args:
        prompt: 조립이 끝난 롤 프롬프트.
        extra_bytes: 프롬프트 밖에서 상한에 함께 세어지는 바이트(참조 라벨).
        limit: 상한(바이트).
        version_selector: 덜어낼 절을 렌더할 팩 — grok 조립이 실제로 실은
            판과 같아야 문자열이 일치한다.

    Returns:
        (맞춘 프롬프트, 덜어낸 스템 이름 목록). 상한 안이면 원본과 빈 목록.

    상한을 넘는 롤은 그대로 두면 롤이 죽고 롤이 죽으면 샷이 죽는다
    (multiroll_select 가 예외를 그대로 올린다). 그림이 아예 없는 것보다
    일반 규칙 문안 몇 절이 빠진 그림이 낫다는 판단이고, 무엇을 덜었는지는
    호출부가 반드시 기록한다(조용한 손실 금지).
    """
    def _b(text: str) -> int:
        return len(text.encode("utf-8"))

    if _b(prompt) + extra_bytes <= limit:
        return prompt, []

    out = prompt
    shed: List[str] = []
    for stem in GROK_SHED_ORDER:
        if _b(out) + extra_bytes <= limit:
            break
        try:
            text = load_prompt(
                _MODULE, stem,
                version=resolve_prompt_version(version_selector),
            ).strip()
        except Exception:
            continue
        if not text:
            continue
        if "\n\n" + text in out:
            out = out.replace("\n\n" + text, "", 1)
        elif text + "\n\n" in out:
            out = out.replace(text + "\n\n", "", 1)
        elif text in out:
            out = out.replace(text, "", 1)
        else:
            continue
        shed.append(stem)
    return out, shed


def resolve_prompt_version(selector: str) -> str:
    try:
        return PROMPT_VERSION_MAP[selector]
    except KeyError:
        raise ValueError(
            f"unknown still_recipe prompt version selector "
            f"{selector!r} (known: {sorted(PROMPT_VERSION_MAP)})"
        )


def recipe_pack_content_hash(selector: str) -> str:
    """레시피 팩의 **내용** 해시 — judge_pack_content_hash 와 동형 (#77-A).

    버전 문자열 스탬프는 팩 내용이 바뀌어도 안 움직일 수 있다 — 실제 로드
    디렉토리의 bytes 해시를 config_hash 에 접어, 팩만 올린 재실행이
    whole-step clean skip 되던 구멍을 막는다."""
    from app.modules.prompt_loader import pack_dir_content_hash

    return pack_dir_content_hash(_MODULE, resolve_prompt_version(selector))


def recipe_stem_content_hash(selector: str, stem: str) -> str:
    """레시피 팩 안 **한 스템**의 내용 해시 (2026-08-13 Codex R2).

    소비 범위가 스템 하나인 절(b 변주·몸-지지)은 스템 bytes 만 지문에
    접는다 — 디렉토리 전체 해시는 동거 스템(컴팩트 지도 절·identity 등)
    개정까지 전량 재생성을 일으킨다."""
    from app.modules.prompt_loader import pack_stem_content_hash

    return pack_stem_content_hash(
        _MODULE, resolve_prompt_version(selector), stem)


def extract_outfit_assignments(
    raw_variations_json: Optional[str],
    norm_uuid: Any,
) -> Dict[str, Any]:
    """t2i_variations_json[*].outfit_assignments → {char_uuid: outlook_uuid}.

    production shape(Codex 2차 리뷰 B1): VE 에는 outlook 이 실리지 않고
    배정은 여기(short id C##/O##)에만 있다. norm_uuid 는 short→UUID 정규화
    callable(불일치=None). variation 간 상이 배정은 임의 first 선택 금지 —
    {"__conflict__": <상세 json>} 반환(호출자 fail-closed).

    3차 리뷰 M3: '배정 자체가 없음'(={})과 'SOT 존재하나 손상/미해결'을
    구분 — malformed JSON·unknown character_id/outlook_id 는
    {"__invalid__": <사유>} 반환(호출자 fail-closed, base 얼굴로 조용한
    degrade 금지).
    """
    import json as _json

    if not raw_variations_json:
        return {}
    try:
        variations = _json.loads(raw_variations_json)
    except _json.JSONDecodeError as exc:
        return {"__invalid__": f"json_decode_error: {exc}"}
    # 4차 리뷰 M2: 구조 검증 — 문법은 유효하나 shape 가 다른 SOT 는
    # AttributeError(에피소드 전체 중단)도, 조용한 '배정 없음'도 아니고
    # __invalid__(샷 격리)다.
    if not isinstance(variations, list):
        return {
            "__invalid__":
                f"root must be list, got {type(variations).__name__}"
        }
    per_char: Dict[str, set] = {}
    invalid: List[str] = []
    for var in variations:
        if var is None:
            continue
        if not isinstance(var, dict):
            invalid.append(
                f"variation must be dict, got {type(var).__name__}"
            )
            continue
        assignments = var.get("outfit_assignments")
        if assignments is None:
            continue
        if not isinstance(assignments, list):
            invalid.append(
                "outfit_assignments must be list, got "
                f"{type(assignments).__name__}"
            )
            continue
        for a in assignments:
            if not isinstance(a, dict):
                invalid.append(
                    f"assignment must be dict, got {type(a).__name__}"
                )
                continue
            raw_cid = a.get("character_id") or ""
            raw_oid = a.get("outlook_id") or ""
            if not raw_cid and not raw_oid:
                # assignment 객체가 존재하는데 양 ID 가 빔 = SOT 손상
                invalid.append("empty assignment object")
                continue
            cid = norm_uuid(raw_cid)
            oid = norm_uuid(raw_oid)
            if not cid:
                invalid.append(f"unknown character_id: {raw_cid!r}")
                continue
            if not oid:
                invalid.append(f"unknown outlook_id: {raw_oid!r}")
                continue
            per_char.setdefault(cid, set()).add(oid)
    if invalid:
        return {"__invalid__": "; ".join(sorted(set(invalid)))}
    conflicts = {c: sorted(o) for c, o in per_char.items() if len(o) > 1}
    if conflicts:
        return {"__conflict__": _json.dumps(conflicts, ensure_ascii=False)}
    return {c: next(iter(o)) for c, o in per_char.items()}


def resolve_char_ref_key(
    *,
    eid: str,
    sid: str,
    state_sids: Dict[str, Dict[str, Any]],
    outlook_by_eid: Dict[str, str],
    scene_ref_image_map: Dict[str, Any],
) -> Optional[str]:
    """캐릭터 ref 키 우선순위 — state_variant > 의상 composite > base."""
    if sid and sid in state_sids:
        key = state_sids[sid].get("key")
        if key and key in scene_ref_image_map:
            return key
    ol = outlook_by_eid.get(eid)
    if ol and f"composite:{eid}:{ol}" in scene_ref_image_map:
        return f"composite:{eid}:{ol}"
    if eid in scene_ref_image_map:
        return eid
    return None


def partition_lineage_ids(
    attached_refs: Sequence[Dict[str, Any]],
) -> Tuple[List[str], List[str]]:
    """(input_image_ids 전체, entity 전용 reference_image_ids) 분리.

    Codex 2차 리뷰 H5: reference_image_ids 는 visible entity/prop 구조
    lineage 전용 — plate/conti/prev 를 넣으면 pipeline_graph 가 거짓
    reference edge 를 만든다. 실첨부 전체는 input 채널에만.
    """
    input_ids: List[str] = []
    entity_ids: List[str] = []
    for r in attached_refs:
        aid = (r or {}).get("asset_id")
        if not aid:
            continue
        if aid not in input_ids:
            input_ids.append(aid)
        if r.get("role") in ("character_ref", "prop_ref") and (
            aid not in entity_ids
        ):
            entity_ids.append(aid)
    return input_ids, entity_ids


def run_branch_select(
    *,
    branch_tag: str,
    branch_refs: Sequence[Tuple[str, Any]],
    rec_key: str,
    out_stem: Any,
    prompt: str,
    records: Any,
    make_gen_fn: Any,
    judge_fn: Any,
    critique_fn: Any,
    roll_count: int,
    critique_enabled: bool,
    judge_texts: Dict[str, str],
    extra_fingerprint: Dict[str, Any],
    run_fn: Any = None,
    roll_prompts: Any = None,
    roll_refs: Any = None,
    parallel_rolls: bool = False,
    judge_flip: bool = False,
    flip_priority: Any = None,
    critique_selected_prompt_only: bool = False,
    judge_prompt_header: Any = None,
    fix_rejudge_fn: Any = None,
    composition_critique_fn: Any = None,
    make_fix_gen_fn: Any = None,
    fix_ref_gate: bool = False,
    fix_missing_texts: Any = None,
) -> Tuple[Any, Dict[str, Any]]:
    """단일/A·B branch 공용 multiroll 실행 (Codex 74ebf365 B1).

    계약: gen callable 은 branch 당 **1회** 생성해 gen_fn/fix_gen_fn
    양쪽에 동일 객체 전달(critique ON 에서 미정의 참조 금지 — NameError
    실행 차단 실측의 재발 방지, 모듈 헬퍼로 추출해 실행 경로를 유닛
    테스트로 잠근다). records=durable persist(rec_key 단위).

    make_fix_gen_fn (2026-08-13 G+G46): 제공 시 fix i2i 만 **별도 gen**
    (branch 당 1회 생성)으로 가른다 — 사용자 확정 "수정은 Grok": 롤 생성
    (nb2)과 수정 편집(grok)의 백엔드가 다른 구성의 이음새다. None(default)
    =기존 계약(gen 재사용) byte-identical.

    still-variants(2026-07-17) kwargs 는 **미사용 시 전달 자체를 생략** —
    OFF 경로의 기존 run_fn 호출·지문·record shape 를 그대로 유지한다
    (Codex 설계 리뷰 R6/R7 조건).
    """
    if run_fn is None:
        from app.modules.pipeline.multiroll_select import (
            run_multiroll_select,
        )

        run_fn = run_multiroll_select

    gen = make_gen_fn(branch_tag)
    fix_gen = (
        make_fix_gen_fn(branch_tag) if make_fix_gen_fn is not None else gen)

    def _persist(rec: Dict[str, Any]) -> None:
        records.data[rec_key] = rec
        records.save()

    variant_kwargs: Dict[str, Any] = {}
    if roll_prompts is not None:
        variant_kwargs["roll_prompts"] = roll_prompts
    if roll_refs is not None:
        variant_kwargs["roll_refs"] = roll_refs
    if parallel_rolls:
        variant_kwargs["parallel_rolls"] = True
    if judge_flip:
        variant_kwargs["judge_flip"] = True
    if flip_priority is not None:
        variant_kwargs["flip_priority"] = flip_priority
    if critique_selected_prompt_only:
        variant_kwargs["critique_selected_prompt_only"] = True
    if judge_prompt_header is not None:
        variant_kwargs["judge_prompt_header"] = judge_prompt_header
    if fix_rejudge_fn is not None:
        # E2E10 fix② — 미사용 시 전달 자체 생략(기존 호출·지문 불변 계약)
        variant_kwargs["fix_rejudge_fn"] = fix_rejudge_fn
    if composition_critique_fn is not None:
        # E2E11 fix③ — 동일 생략 계약
        variant_kwargs["composition_critique_fn"] = composition_critique_fn
    if fix_ref_gate:
        # 참조 선별(2026-08-19) — 동일 생략 계약. 문안은 추가-스템 팩에서
        # 서비스가 읽어 넘긴다(없으면 절 없이 선별만).
        variant_kwargs["fix_ref_gate"] = True
        _mt = fix_missing_texts or {}
        variant_kwargs["fix_missing_head"] = _mt.get("missing_head", "")
        variant_kwargs["fix_missing_tail"] = _mt.get("missing_tail", "")

    return run_fn(
        tag=branch_tag,
        prompt=prompt,
        labeled_refs=branch_refs,
        out_stem=out_stem,
        gen_fn=gen,
        judge_fn=judge_fn,
        critique_fn=critique_fn if critique_enabled else None,
        fix_gen_fn=fix_gen if critique_enabled else None,
        roll_count=roll_count,
        critique_enabled=critique_enabled,
        fix_head=judge_texts["fix_head"],
        fix_tail=judge_texts["fix_tail"],
        fix_label=judge_texts["fix_label"],
        # ★편집에도 critique 가 본 참조를 동봉 (2026-08-07, 팩 v7).
        #  스틸 경로에만 켠다 — 근거 실측 4건이 전부 인물 정본 대조였고
        #  (비니·의상·머리 모양), 배경 플레이트·구조물 씨드는 "나쁜 참조가
        #  구조를 전이시킨다"는 7/30 실측이 있는 자리라 같이 켜지 않는다.
        #  그쪽은 별도 대조가 필요하다.
        fix_ref_label=judge_texts.get("fix_ref_label", ""),
        record=records.data.get(rec_key),
        extra_fingerprint=extra_fingerprint,
        persist_record_fn=_persist,
        **variant_kwargs,
    )


def fix_stage_won(record: Dict[str, Any]) -> bool:
    """수리 단계 산출(fix i2i/재생성 후보)이 최종 `_sel` 이 됐는가 —
    자산 provenance(exact 호출 태그·생성 모델·prompt_used·fix_applied)의
    **단일 판정** (2026-08-13 Codex GG46 R1 BLOCK-2).

    종전에는 소비처마다 `fix_prompt` **존재**로 판정해, 재판정(fix
    rejudge)에서 수정본이 **져서** 원본 롤이 최종인 샷도 fix 호출에
    링크되고 fix_applied=True 로 남았다(반대로 이겼을 땐 모델·프롬프트
    기록이 롤 것). 계약:
      · 재평가 record 가 있으면 그 승부가 SOT — winner=="B"(수정본).
      · 없으면 legacy(수리 산출 무판정 확정) — 수리 프롬프트가 있으면 채택.
      · 수리 시도 자체가 없으면(fix_skipped 포함) False.
    """
    if record.get("fix_skipped"):
        return False
    if not (record.get("fix_prompt") or record.get("regen_prompt")):
        return False
    rejudge = record.get("fix_rejudge")
    if isinstance(rejudge, dict) and rejudge.get("winner"):
        # canonical 라벨 계약: A=원본, B=수정/재생성본 (_critique_and_fix)
        return rejudge.get("winner") == "B"
    return True


def effective_prompt_used(
    record: Dict[str, Any], base_prompt: str
) -> str:
    """최종 asset prompt_used — 실제 생성에 쓰인 프롬프트 (리뷰 NARROW-4).

    수리 산출이 최종이면(`fix_stage_won`) 최종 bytes 를 만든 프롬프트는
    수리 프롬프트다 — 롤 프롬프트를 실으면 모델·지시가 다른 호출의
    산물로 오독된다(Codex GG46 R1 BLOCK-2 — Grok fix 에서 교차 모델로
    드러났지만 nb2 fix 도 같은 결함이었다). 그 외에는 변형 모드=선정
    라벨의 roll_prompts 전문(시각 접근 provenance 보존), legacy/결손=
    base 그대로 (기존 값 불변).
    """
    if fix_stage_won(record):
        repair_prompt = (
            record.get("regen_prompt")
            if record.get("repair_mode") == "regenerate"
            else record.get("fix_prompt"))
        if repair_prompt:
            return repair_prompt
    rp = record.get("roll_prompts") or {}
    sel = record.get("selected")
    return rp.get(sel) or base_prompt


def winner_exact_multiroll_tag(
    branch_tag: str, record: Dict[str, Any]
) -> str:
    """승자 branch record → 최종 산출을 만든 exact 호출 태그 (Codex H2).

    수리 산출이 최종이면(`fix_stage_won` — 재평가 승부 반영) 수리 호출
    태그('{branch}_fix'/'{branch}_regen'), 아니면 '{branch}_{selected
    소문자}' (run_multiroll_select 태그 관례). 종전의 fix_prompt 존재
    판정은 재판정에서 진 fix 의 호출로 최종 자산을 잇는 거짓 링크였다
    (Codex GG46 R1 BLOCK-2). selected 결손이면 빈 문자열 — resolver 는
    exact miss 를 None 으로 반환(거짓 링크 금지).
    """
    if fix_stage_won(record):
        suffix = ("_regen" if record.get("repair_mode") == "regenerate"
                  else "_fix")
        return f"{branch_tag}{suffix}"
    sel = (record.get("selected") or "").strip().lower()
    return f"{branch_tag}_{sel}" if sel else ""


def decide_direct_seed_location_ref_mode(
    *,
    prev_tag: Optional[str],
    prev_sel_exists: bool,
    bg_only: bool,
) -> str:
    """(DEPRECATED — 2026-07-16 복잡 구조물=A/B 재설계로 생산처 소멸)
    direct_seed 샷 장소 권위 결정 — classify prev 판정이 SOT.
    서비스 live 분기는 제거됨; 함수는 보존만(삭제 금지 원칙).

    Codex f7c81336 BLOCKING-1: prev_tag(classify SOT)가 있으면 mode 는
    prev_only 로 **고정** — prev asset 결손은 seed_only 로의 조용한 정책
    전환이 아니라 ValueError(fail-closed, 샷 실패 격리). bg_only 는
    classify 가 prev 를 무효화하는 경로라 seed_only.
    """
    if bg_only or not prev_tag:
        return "seed_only"
    if not prev_sel_exists:
        raise ValueError(
            f"direct_seed prev_only 요구(classify prev={prev_tag})인데 "
            "prev 선정본/primary 결손 — seed_only 전환 금지(fail-closed)"
        )
    return "prev_only"


def plate_flow_mode(
    *,
    plate_select_on: bool,
    complex_ab: bool,
    bg_only: bool,
    prev_used: bool,
    lane_used: bool,
    is_map_plate: bool,
    plate_present: bool,
) -> str:
    """스틸 플레이트 처리 모드 결정 (재리뷰 HIGH-2 — caller 조건의 순수
    함수 분리).

    - "bg_late_select": bg_only(콘티 없음) 즉석 VLM 판정 — flag ON +
      플레이트 실재 시에만 (기존 계약).
    - "reconcile": 콘티형 샷(non-bg·non-prev) 권위/콘티 플레이트
      reconciliation — flag ON **또는** complex_ab 면 플레이트 mapping
      이 비어 있어도 호출(R1 배선 완성: 재개 시 mapping 소실이 권위
      해석을 건너뛰던 누락 차단). flag OFF 일반 샷 = legacy 유지.
    - "none": lane/맵 플레이트/그 외.
    """
    if lane_used or is_map_plate:
        return "none"
    if bg_only:
        return (
            "bg_late_select"
            if plate_select_on and plate_present else "none"
        )
    if prev_used:
        return "none"
    return "reconcile" if (plate_select_on or complex_ab) else "none"


def resolve_conti_plate_authority(
    *,
    authority_entry: Optional[Dict[str, Any]],
    conti_plate_path: Optional[str],
    current_plate: Optional[Path],
) -> Tuple[Optional[Path], Optional[Dict[str, Any]]]:
    """콘티형 샷의 스틸 플레이트 해석 — 권위 기록 우선 (R1 선행 고정).

    Codex 설계 리뷰 BLOCKING-1: 스틸 시점 재판정은 콘티가 그려진 플레이트
    와 다른 플레이트를 스틸에 붙여 A/B 가 '콘티 유무' 외 변수를 비교하게
    만든다. 콘티(+A/B 양 브랜치)와 스틸은 정확히 같은 plate path 를
    소비해야 한다.

    - authority_entry 존재: 그 plate_path 가 SOT — 파일 결손·콘티가 다른
      플레이트로 생성된 경우 = ValueError(fail-closed, 상류 재실행 필요).
    - authority_entry 부재(판정 비대상/flag 조합): **콘티가 그려진
      플레이트가 SOT** (배치 리뷰 BLOCKING-2 — current mapping 이 비어도
      콘티 플레이트로 해석, 결손·불일치=fail-closed).
    - 콘티 기록도 없으면 현재 배정 유지 (콘티 생략 샷).
    """
    if authority_entry:
        p = authority_entry.get("plate_path") or ""
        if not p or not Path(p).is_file():
            raise ValueError(
                f"plate authority 기록 플레이트 결손: {p!r} — "
                "shot_conti_light 재실행 필요"
            )
        if conti_plate_path and str(conti_plate_path) != str(p):
            raise ValueError(
                "plate authority 와 콘티 생성 플레이트 불일치 "
                f"(authority={p} / conti={conti_plate_path}) — "
                "shot_conti_light 재실행 필요"
            )
        return Path(p), authority_entry.get("record") or {}
    if conti_plate_path:
        cp = Path(conti_plate_path)
        if not cp.is_file():
            raise ValueError(
                f"콘티 생성 플레이트 결손: {conti_plate_path!r} — "
                "상류 배경/콘티 재실행 필요"
            )
        if current_plate is not None and str(current_plate) != str(cp):
            raise ValueError(
                "콘티 생성 플레이트와 현재 배정 플레이트 불일치 "
                f"(conti={conti_plate_path} / assigned={current_plate}) — "
                "상류 배경/콘티 재실행 필요"
            )
        return cp, None
    return current_plate, None


def locked_pose_short_ids(
    pose_canon: Sequence[Dict[str, Any]],
    tag: str,
    prev_tag: Optional[str],
) -> Set[str]:
    """prev 참조 샷에서 캐릭터 참조를 제외할 정본 자세 인물 short_id.

    E2E6 피드백 ④ (2026-07-16): pose_canon(LLM 가동성 판정 SOT)이 현재
    샷과 prev 샷을 **모두** 커버하는 인물 — prev 스틸이 이미 자세·외형
    LOCK 이라 별도 캐릭터 참조는 이중 참조 충돌(S12sh9 실측). short_id
    미해소(None)면 제외하지 않는다(참조 유지=안전측, 글자 매칭 금지).
    """
    if not prev_tag:
        return set()
    out: Set[str] = set()
    for pc in pose_canon or []:
        sid = (pc or {}).get("character_short_id")
        cov = set(pc.get("shots") or [])
        if sid and tag in cov and prev_tag in cov:
            out.add(sid)
    return out


def ve_ids_for_shot(
    ve_by_key: Dict[Tuple[int, int], List[str]], key: Tuple[int, int]
) -> List[str]:
    """샷 VE, 비면 같은 씬 선택 샷 VE 합집합 fallback (실험 공통 규칙)."""
    return ve_ids_for_shot_ex(ve_by_key, key)[0]


def ve_ids_for_shot_ex(
    ve_by_key: Dict[Tuple[int, int], List[str]], key: Tuple[int, int]
) -> Tuple[List[str], bool]:
    """`ve_ids_for_shot` + **확정 배정인지** 여부.

    두 번째 값 False = 샷 자체의 VE 가 없어 같은 씬 다른 샷에서 끌어온 추측이다.
    호출자는 이걸로 PEOPLE 절의 배타 조항을 붙일지 결정한다 — 추측에 "never
    anyone else" 를 붙이면 정답을 배제한다(실측 S11sh3, 팩 v14 주석 참조).
    """
    ids = ve_by_key.get(key) or []
    if ids:
        return list(ids), True
    scene_union: Set[str] = set()
    for (si, _shi), v in ve_by_key.items():
        if si == key[0]:
            scene_union.update(v or [])
    return sorted(scene_union), False


def build_still_refs(
    *,
    bg_only: bool,
    plate: Optional[Path],
    conti: Optional[Path],
    prev_sel: Optional[Path],
    char_refs: Sequence[Tuple[str, Any]],
    prop_refs: Sequence[Tuple[str, Any]],
    lane_sketch: Optional[Path] = None,
    lane_seed: Optional[Path] = None,
    lane_direct_seed: bool = False,
    structure_seed: Optional[Path] = None,
    seed_bg: Optional[Path] = None,
    prompt_version: str = "1",
    geom_authority: bool = False,
    handled_by: str = "",
    # prev 스틸 인물 계승 스코프 절 (#119③) — 빈 문자열=레거시 라벨
    # 그대로(byte-identical). build_prev_people_rule 렌더 결과를 받는다.
    prev_people_rule: str = "",
) -> List[Tuple[str, Any]]:
    """라벨드 참조 조립 — (라벨, 이미지 소스[Path|bytes]) 순서 리스트.

    char_refs/prop_refs = [(이름, 소스)]. bgonly 는 플레이트 외 전부 제외.
    prev 샷은 콘티 없음(분류 단계 배제) — prev_sel 이 있으면 conti 는 무시,
    플레이트도 제외(E2E6 ⑦: prev=배경 SOT, refs=[prev+엔티티]).

    geom_authority (2026-08-07, 팩 v15): 좁고 복잡한 실내만 참. prev 샷에도
    콘티를 **함께** 붙여 공간 기하를 준다(상세는 해당 분기 주석). 기본값
    False = 기존 조립 byte-identical.

    handled_by (2026-08-07, 분류 팩 v4): 그 순간 물건을 다루고 있는 사람.
    bgonly 샷에서 그 사람의 참조 **한 장만** 손 전용 라벨로 싣는다. 빈
    문자열 = 기존 조립 byte-identical.

    ★E2E6 ⑦ 스코프 = **non-lane 경로 한정** (Codex 배치 리뷰 CONTRACT-2
    명시): lane 샷은 사용자 설계 v2(2026-07-14)의 명시 결정대로 prev 가
    있어도 스케치(+seed)를 유지한다(아래 lane 분기 — 스케치=배치 SOT,
    prev=연속성 추가 참조). lane 의 장소 권위 단일화(location_ref_mode
    prev_only/seed_only)는 outdoor_frame_mode(피드백 ③) 구현에서 통합
    예정 — 여기서 암묵 제거하지 않는다.

    lane_sketch(Stage D, v2+ 팩 전용): 야외 lane 샷 — 플레이트/photo canon
    참조 0, [마커 스케치(+lane_seed 구조물 룩)] + prev + 엔티티.
    prev 가 있어도 스케치 유지(설계 v2: 레인 스틸 참조에 prev **추가**).
    bgonly lane 샷도 스케치(+seed)는 유지 — 구조물 룩·배치 SOT.

    structure_seed(2026-07-16 복잡 구조물=A/B, v4 팩 전용): 플레이트와
    **공존**하는 STRUCTURE LOOK — A/B 양 브랜치와 bg_only 에 공통 부착
    (관할=structure_look_clause tie-break). prev 샷은 미부착(prev=배경·
    구조물 look SOT — 권위 이중화 금지, Codex R6) — prev_sel 과 동시
    전달 = ValueError.

    seed_bg(2026-07-17 seed-bg 승격, v5 팩 전용): 플레이트 producer 가
    구조적으로 없는 structure_plate 그룹 — seed 가 LOCATION+구조물 단일
    권위로 **정확히 1회** 부착 (Codex 조건 1: STRUCTURE LOOK 이중 첨부
    금지 → structure_seed/plate 와 상호 배타). bg_only=seed_bg 만,
    prev 샷=미부착(prev-only).
    """
    resolved = resolve_prompt_version(prompt_version)
    if seed_bg is not None:
        if prompt_version not in _SEED_BG_PACKS:
            raise ValueError(
                f"still_recipe v{prompt_version} 팩에는 seed-bg 단일 권위 "
                "계약이 없음 — seed_bg 는 v5 전용"
            )
        if plate is not None or structure_seed is not None:
            raise ValueError(
                "seed_bg 는 단일 권위 — plate/structure_seed 와 동시 부착 "
                "금지 (Codex 조건 1: 동일 seed 이중 첨부 차단)"
            )
        if prev_sel is not None:
            raise ValueError(
                "seed_bg 는 prev 샷에 부착 금지 — prev-only (Codex 조건 4)"
            )
        if lane_sketch is not None or lane_seed is not None:
            raise ValueError("seed_bg 와 lane 참조는 상호 배타")
    if structure_seed is not None:
        if prompt_version not in _STRUCTURE_LOOK_PACKS:
            raise ValueError(
                f"still_recipe v{prompt_version} 팩에는 플레이트 공존 "
                "STRUCTURE LOOK 계약이 없음 — structure_seed 는 v4 전용"
            )
        if prev_sel is not None:
            raise ValueError(
                "structure_seed 는 prev 샷에 부착 금지 — prev 가 배경·"
                "구조물 look SOT (Codex R6 권위 이중화 금지)"
            )
        if lane_sketch is not None or lane_seed is not None:
            raise ValueError(
                "structure_seed 와 lane 참조는 상호 배타 — 복잡 구조물 "
                "샷은 lane 스케치 경로를 쓰지 않는다"
            )
    if lane_sketch is not None or (lane_direct_seed and lane_seed is not None):
        # E2E6 ③ direct_seed: 스케치 없이 seed 만 (structure_dominant —
        # prev_only 케이스는 caller 가 lane 인자 없이 non-lane prev 경로로
        # 호출한다). 라벨 팩 요구는 스케치 경로와 동일(v2+).
        if prompt_version not in _LANE_LABEL_PACKS:
            raise ValueError(
                f"still_recipe v{prompt_version} 팩에는 lane 라벨이 없음 — "
                "lane_sketch/direct_seed 는 v2+ 전용"
            )
        refs = []
        if lane_sketch is not None:
            refs.append(
                (load_prompt(_MODULE, "sketch_label",
                             version=resolved).strip(), lane_sketch)
            )
        if lane_seed is not None:
            refs.append(
                (load_prompt(_MODULE, "seed_label",
                             version=resolved).strip(), lane_seed)
            )
        if bg_only:
            return refs
        if prev_sel is not None:
            refs.append(
                (_prev_label_stem(resolved, prev_people_rule), prev_sel)
            )
        char_label = load_prompt(
            _MODULE, "char_label", version=resolved).strip()
        prop_label = load_prompt(
            _MODULE, "prop_label", version=resolved).strip()
        for name, src in char_refs:
            refs.append((char_label.format(name=name), src))
        for name, src in prop_refs:
            refs.append((prop_label.format(name=name), src))
        return refs
    refs = []
    # E2E6 육안 피드백 ⑦ (2026-07-16): prev_used 샷은 prev 선정본이 배경
    # SOT (LOCATION lock 도 PREVIOUS SHOT STILL 지칭) — 플레이트 동시
    # 첨부는 배경 권위 이중화라 제외. 2026-07-19 (E2E9 육안 #2 사용자
    # 확정): bgonly 도 배경 공유 계획이 prev 를 지휘하면 caller 가
    # prev_sel 을 전달 — prev 가 배경 SOT 이므로 판정은 prev_sel 실재
    # 단일 기준 (bgonly 의 "prev 무시·플레이트 강제"는 계획 부재 시의
    # fail-safe 로만 유지: caller 가 prev_sel 을 안 준다).
    if plate is not None and prev_sel is None:
        refs.append(
            (load_prompt(_MODULE, "plate_label", version=resolved).strip(),
             plate)
        )
    # seed-bg 단일 권위 (v5) — 플레이트 자리, 정확히 1회
    if seed_bg is not None and prev_sel is None:
        refs.append(
            (load_prompt(_MODULE, "seed_bg_label",
                         version=resolved).strip(), seed_bg)
        )
    # 복잡 구조물 STRUCTURE LOOK — 플레이트 다음, bg_only 조기 return
    # **이전** (Codex R6: bg_only 도 seed 부착)
    if structure_seed is not None:
        refs.append(
            (load_prompt(_MODULE, "seed_label", version=resolved).strip(),
             structure_seed)
        )
    if bg_only:
        # 계획 prev 지휘 bgonly — prev 스틸이 배경 단일 권위 참조.
        if prev_sel is not None:
            refs.append(
                (_prev_label_stem(resolved, prev_people_rule), prev_sel)
            )
        # ★물건을 다루는 손의 정체 (2026-08-07). bgonly 는 인물 참조를
        #  전부 떼는데, 그 물건을 쥔 손은 프레임에 있어야 한다 —
        #  참조가 없으면 손의 피부·나이·성별·소매가 무작위가 된다.
        #  **그 사람의 참조만** 싣는다(전원이 아니다). 이름이 맞는 것이
        #  하나도 없으면 아무것도 싣지 않는다 — 엉뚱한 사람의 손을
        #  주느니 주지 않는 편이 낫다.
        if handled_by:
            hand_label = load_prompt(
                _MODULE, "handled_char_label",
                version=resolve_prompt_version(
                    GEOM_AUTHORITY_PROMPT_VERSION)).strip()
            for name, src in char_refs:
                if name and name in handled_by:
                    refs.append((hand_label.format(name=name), src))
                    break
        return refs
    if prev_sel is not None:
        refs.append(
            (_prev_label_stem(resolved, prev_people_rule), prev_sel)
        )
        # ★좁고 복잡한 실내는 prev 와 콘티를 **함께** 준다 (2026-08-07, v15).
        #
        # 여기서 오래 잃고 있었다. prev 샷은 콘티 대상에서 아예 빠지고
        # (`conti_targets` = person_visible and **not prev**), 설령 콘티가
        # 있어도 이 분기가 버렸다. 그 결과 자동차 캐빈 prev 샷들이 받는
        # 공간 정보는 LOCATION 한 문장과 이전 샷 사진 한 장뿐이었다 —
        # 운전석이 어느 쪽인지, 카메라가 어디 있는지, 룸미러가 어디 붙는지가
        # 어디에도 없다. prev 경로가 전체의 49%(126샷)다.
        #
        # 둘은 관할이 다르므로 함께 있어도 싸우지 않는다: prev 는 장소의
        # 재질·조명·의상·부동 인물, 콘티는 구도와 구조 안에서의 배치.
        # lane 분기에 둘을 함께 유지하는 선례가 이미 있다.
        if geom_authority and conti is not None:
            refs.append((
                load_prompt(
                    _MODULE, "conti_label_geom",
                    version=resolve_prompt_version(
                        GEOM_AUTHORITY_PROMPT_VERSION)).strip(),
                conti,
            ))
    elif conti is not None:
        refs.append(
            (load_prompt(_MODULE, "conti_label", version=resolved).strip(),
             conti)
        )
    char_label = load_prompt(_MODULE, "char_label", version=resolved).strip()
    prop_label = load_prompt(_MODULE, "prop_label", version=resolved).strip()
    for name, src in char_refs:
        refs.append((char_label.format(name=name), src))
    for name, src in prop_refs:
        refs.append((prop_label.format(name=name), src))
    return refs


def build_ab_branch_refs(
    *,
    plate: Optional[Path],
    conti: Path,
    char_refs: Sequence[Tuple[str, Any]],
    prop_refs: Sequence[Tuple[str, Any]],
    structure_seed: Optional[Path] = None,
    seed_bg: Optional[Path] = None,
    prompt_version: str = "1",
) -> Tuple[List[Tuple[str, Any]], List[Tuple[str, Any]]]:
    """A/B 두 브랜치 refs 를 **한 곳에서** 조립 — (refs_a, refs_b).

    Codex R6: 브랜치별 개별 조립은 B 브랜치에서 seed/팩 라벨이 누락되는
    드리프트를 허용한다 — 두 브랜치는 콘티 유무 **만** 다르고 플레이트·
    seed·엔티티·팩(라벨)은 동일해야 비교가 순수하다. A/B 는 eligible
    (person-visible·no-prev) 샷 전용 — bg_only/prev 는 호출 금지.

    배치 리뷰 BLOCKING-2: 복잡 구조물(structure_seed) A/B 는 LOCATION
    플레이트 필수 — plate=None 이면 합의된 A=plate+seed+conti /
    B=plate+seed 가 아니라 seed-only 비교가 된다 (ValueError).
    seed-bg 그룹(v5, Codex 조건 1)은 seed_bg 가 LOCATION 권위 —
    plate/structure_seed 와 상호 배타(가드=build_still_refs).
    """
    if structure_seed is not None and plate is None:
        raise ValueError(
            "복잡 구조물 A/B 는 LOCATION 플레이트 필수 — plate=None 금지"
        )
    if plate is None and seed_bg is None:
        raise ValueError("A/B 는 LOCATION 권위(plate 또는 seed_bg) 필수")

    def _branch(branch_conti: Optional[Path]) -> List[Tuple[str, Any]]:
        return build_still_refs(
            bg_only=False,
            plate=plate,
            conti=branch_conti,
            prev_sel=None,
            char_refs=char_refs,
            prop_refs=prop_refs,
            structure_seed=structure_seed,
            seed_bg=seed_bg,
            prompt_version=prompt_version,
        )

    return _branch(conti), _branch(None)


# framing_scale enum(shot_staging schema SOT) → 스틸 프롬프트 NL — 렌더링
# 전용 매핑(의미 판단 아님). 계약 밖 값은 라인 생략(fail-safe 보강 절).
_FRAMING_SCALE_PHRASES = {
    "close": "close-up",
    "medium": "medium shot",
    "wide": "wide shot",
    "insert": "insert close-up on a detail",
}
_GESTURE_PHRASES = {
    "points_to": "points to",
    "reaches_for": "reaches for",
    "looks_toward": "looks toward",
    "moves_toward": "moves toward",
}


def build_camera_frame_clause(
    staging: Optional[Dict[str, Any]], version_selector: str = "",
) -> str:
    """shot_staging CP 1샷 엔트리 → CAMERA/FRAME 절 (fix1, 2026-07-19).

    E2E9 S19sh4 실측: staging 엔 camera_direction·frame_spatial_contract·
    framing_scale·key_bg_elements(구도·스케일 SOT)가 실재하는데 스틸 base
    프롬프트에 구조적으로 누락 — 무콘티 브랜치(C/D)·prev 샷·배경 제거
    변형에서 구도 정보가 0 이 되어 원근·스케일 붕괴(CCTV 거대화)가 남.

    구조화 필드의 결정론 렌더링만 수행(zone/depth/gesture enum → NL 구절 —
    글자 의미 판단 없음). staging 부재/유효 필드 0 = "" (보강 절이라
    fail-safe — 스틸 자체를 막지 않는다). 절 헤더·스케일 계약 문구는
    프롬프트 팩 v6 camera_frame_clause 스템 SOT.
    """
    if not isinstance(staging, dict):
        return ""
    from app.core.frame_spatial_contract import DEPTH_PHRASES, ZONE_PHRASES

    lines: List[str] = []
    cam = str(staging.get("camera_direction") or "").strip()
    if cam:
        lines.append(f"- CAMERA: {cam}")
    scale = _FRAMING_SCALE_PHRASES.get(
        str(staging.get("framing_scale") or ""))
    if scale:
        lines.append(f"- FRAMING SCALE: {scale}")
    fsc = staging.get("frame_spatial_contract")
    if isinstance(fsc, dict):
        placed: List[str] = []
        for c in fsc.get("constraints") or []:
            if not isinstance(c, dict):
                continue
            label = str(c.get("label") or "").strip()
            zone = (ZONE_PHRASES.get(
                str(c.get("screen_zone") or "")) or [None])[0]
            depth = (DEPTH_PHRASES.get(
                str(c.get("depth_plane") or "")) or [None])[0]
            if not (label and zone and depth):
                continue
            seg = f"{label} in the {zone} of the frame, {depth}"
            gesture = _GESTURE_PHRASES.get(
                str(c.get("gesture_action") or ""))
            gtl = str(c.get("gesture_target_label") or "").strip()
            if gesture and gtl:
                seg += f", {gesture} {gtl}"
            placed.append(seg)
        if placed:
            lines.append("- FRAME LAYOUT: " + "; ".join(placed) + ".")
    bg_lines: List[str] = []
    for el in staging.get("key_bg_elements") or []:
        if not isinstance(el, dict):
            continue
        name = str(el.get("element") or "").strip()
        if not name:
            continue
        seg = name
        # staging 자유문은 문장 마침표를 포함할 수 있어 이어붙이기 전
        # 끝 구두점 정리 (렌더 표면 정규화 — 의미 판단 아님)
        state = str(el.get("state") or "").strip().rstrip(".")
        if state:
            seg += f" ({state})"
        orientation = str(el.get("orientation") or "").strip().rstrip(".")
        if orientation:
            seg += f" — {orientation}"
        camera_use = str(el.get("camera_use") or "").strip().rstrip(".")
        if camera_use:
            seg += f"; used as {camera_use}"
        bg_lines.append(seg)
    if bg_lines:
        lines.append("- KEY BACKGROUND ELEMENTS: " + "; ".join(bg_lines) + ".")
    if not lines:
        return ""
    return load_prompt(
        _MODULE, "camera_frame_clause",
        version=resolve_prompt_version(
            grok_stem_version(version_selector, CAMERA_FRAME_PROMPT_VERSION)),
        body="\n".join(lines),
    ).strip()


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

    E2E10 실측: 붉은 원 계열(bgonly) 샷 프롬프트에 조명·무드 절 부재
    (TIME OF DAY 1줄뿐) + 오브젝트 재질·질감 서술 부재 → 평면 낙서화
    (S12sh18/S18sh10/S25sh5). 조명 SOT=shot_staging.lighting_mood —
    scene_still.lighting_json 은 v4 파이프라인 미저작(E2E10 192행 전부
    '{}' 실측)이라 소비 대상이 아니다.

    결정론 조립만 수행(저작된 NL 문장을 절로 감쌈 — 글자 의미 판단
    없음). 절 헤더·재질 사실감 계약 문구=팩 v8 lighting_mood_clause 스템
    SOT. staging 부재/빈값 = "" (보강 절이라 fail-safe — 스틸 자체를
    막지 않는다, camera_frame 관례 동일).
    """
    if not isinstance(staging, dict):
        return ""
    mood = str(staging.get("lighting_mood") or "").strip()
    if not mood:
        return ""
    return load_prompt(
        _MODULE, "lighting_mood_clause",
        version=resolve_prompt_version(LIGHTING_MOOD_PROMPT_VERSION),
        body=f"- LIGHTING & MOOD: {mood}",
    ).strip()


def build_identity_ref_role_clause(
    has_char_refs: bool, version_selector: str = "",
) -> str:
    """캐릭터 identity 참조 역할 한정 절 (2026-08-12 차렷/증명사진 대응).

    인물 시트(정면 응시 참조)의 포즈·시선·구도가 스틸로 전이되는 경로를
    역할 한정 문장으로 차단한다 — 참조는 동일성(얼굴·헤어·체격·복장) 전용,
    포즈·시선의 권위는 본 프롬프트의 CAMERA/동작 서술. 절 문구=팩 v16
    identity_ref_role_clause 스템 SOT.

    has_char_refs=False(캐릭터 참조 미첨부 샷)면 "" — 참조가 없는 샷에
    이 절이 나가면 존재하지 않는 이미지를 가리키는 거짓 문장이 된다.
    호출자 계약(unit 테스트 test_still_recipe 가 잠근다): 절은 **본문
    캐릭터 참조(char_label "CHARACTER REFERENCE — …")가 실리는 조건**
    (char_refs 비어 있지 않음 AND not bg_only)에서만 참을 전달한다.
    ★의도된 예외(Codex HIGH-4 명문화) — bg_only+handled_by 샷이 싣는
    "HAND OWNER REFERENCE" 1장은 이 절의 대상이 아니다: 절의 지시 대상
    (CHARACTER REFERENCE images)이 그 샷에 존재하지 않아 절이 나가면
    거짓 문장이고, 손 라벨 스템은 자체 역할 한정문(얼굴·몸·복장 반입
    금지, 손목·전완 너머 금지)을 이미 내장한다.
    """
    if not has_char_refs:
        return ""
    return load_prompt(
        _MODULE, "identity_ref_role_clause",
        version=resolve_prompt_version(
            grok_stem_version(version_selector, IDENTITY_ROLE_PROMPT_VERSION)),
    ).strip()


def build_cinematic_finish_clause(
    version_selector: str = STILL_COMPACT_PROMPT_VERSION,
) -> str:
    """시네마틱 마감 절 (2026-08-13 사용자 지시) — 전 스틸 프롬프트 공통.

    CAMERA 계약(스테이징 권위)은 불변 — 미지정 선택지의 영화적 실현만
    지시한다. grok 백엔드 조립에서만 호출된다."""
    return load_prompt(
        _MODULE, "cinematic_finish",
        version=resolve_prompt_version(version_selector),
    ).strip()


def build_cine_transform_prompt(version_selector: str = "") -> str:
    """i2i 시네마틱 변환 문안 (2026-08-13 #108) — 팩 스템 단일 SOT.

    sel 확정 후 grok 2.0 이 최종 스틸을 영화 키프레임으로 재구성하는
    짧은 범용 지시 하나 — 원본 조립 프롬프트·시나리오 고유명사를 쓰지
    않는다(파일럿 grok_cine_batch 계약 승격). 장소·인물·순간은 원본
    스틸이 고정하고 프레이밍·조명만 재구성한다."""
    return load_prompt(
        _MODULE, "cine_transform",
        version=resolve_prompt_version(
            version_selector or CINE_TRANSFORM_PROMPT_VERSION),
    ).strip()


def build_broll_variation_clause(
    version_selector: str = STILL_COMPACT_PROMPT_VERSION,
) -> str:
    """b 롤 구도 변주 절 (2026-08-13 사용자 결정: 2롤 ab, b=영화적 구도
    변주). 이 절이 실린 후보만 CAMERA 계약을 기준선으로 삼아 이탈할 수
    있다 — 장소·인물·순간·SHOT TEXT 직설 프레이밍은 불변."""
    return load_prompt(
        _MODULE, "broll_composition_variation",
        version=resolve_prompt_version(version_selector),
    ).strip()


def build_ab_roll_prompt_map(
    base_prompt: str,
    labels: Sequence[str],
    version_selector: str = "",
) -> Dict[str, str]:
    """2롤 ab 변주 프롬프트 맵 (2026-08-13 사용자 확정 — 전 백엔드·전 경로).

    첫 롤=기준(base 그대로), 나머지 롤=구도 변주 절 동반 — a 와 b 가 같은
    구도·앵글로 수렴하지 않게 하는 구조 재료. 변주 절 스템은 그것이 처음
    실린 팩(v17)을 단일 SOT 로 로드한다(빈 selector=v17). labels 1개면
    base 그대로."""
    if len(labels) < 2:
        return {ln: base_prompt for ln in labels}
    var = base_prompt + "\n\n" + build_broll_variation_clause(
        grok_stem_version(version_selector, STILL_COMPACT_PROMPT_VERSION))
    return {
        ln: (base_prompt if i == 0 else var)
        for i, ln in enumerate(labels)
    }


def build_prev_people_rule(
    shared_names: Sequence[str],
    cast_known: bool,
) -> str:
    """prev 스틸 인물 계승 스코프 절 (#119③) — 렌더 완문을 돌려준다.

    cast_known=False(앵커 인물 명단을 모름) = 빈 문자열 → 레거시 무조건
    잠금 스템 그대로(조립 byte-identical). 겹침이 있으면 그 인물들만
    의상 잠금, 겹침 0 이면 "그 사진의 인물은 이 샷에 없다" 명문 —
    S64sh4 실측(앵커 인물 외모·의상이 다른 인물에게 복제) 대응."""
    if not cast_known:
        return ""
    v = resolve_prompt_version(PREV_CAST_SCOPE_PROMPT_VERSION)
    if shared_names:
        return load_prompt(
            _MODULE, "prev_people_rule_shared", version=v,
            shared_names=", ".join(shared_names)).strip()
    return load_prompt(_MODULE, "prev_people_rule_none", version=v).strip()


def build_signage_section(inscriptions: Sequence[Dict[str, Any]]) -> str:
    """저작 공급된 원어 표기 문안 렌더 (#119②) — 빈 목록=빈 문자열.

    머리말 스템(팩 v21) + 한 줄에 하나: `- {표면}: "{문안}"`. 문안 저작은
    signage_author 모듈 소유 — 여기는 렌더만."""
    lines = [
        f"- {str(x.get('surface_native') or '').strip()}: "
        f"\"{str(x.get('text_native') or '').strip()}\""
        for x in inscriptions
        if str(x.get('surface_native') or '').strip()
        and str(x.get('text_native') or '').strip()
    ]
    if not lines:
        return ""
    head = load_prompt(
        _MODULE, "signage_section",
        version=resolve_prompt_version(
            PREV_CAST_SCOPE_PROMPT_VERSION)).strip()
    return head + "\n" + "\n".join(lines)


def _prev_label_stem(resolved: str, people_rule: str) -> str:
    """prev 스틸 라벨 — people_rule 이 있으면 인물 스코프 명시 v21 스템,
    없으면 레거시 스템 그대로(byte-identical)."""
    if people_rule:
        return load_prompt(
            _MODULE, "prev_label_cast",
            version=resolve_prompt_version(PREV_CAST_SCOPE_PROMPT_VERSION),
            people_rule=people_rule).strip()
    return load_prompt(_MODULE, "prev_label", version=resolved).strip()


def build_still_prompt(
    *,
    shot_desc: str,
    place_text: str,
    time_of_day_en: str,
    world_anchor: str = "",
    bg_only: bool,
    prev_used: bool,
    prev_usage_en: str = "",
    pose_clauses: Sequence[str] = (),
    movement_en: str = "",
    figures_en: str = "",
    carried_en: str = "",
    # 그 순간 물건을 다루고 있는 사람 (분류 팩 v4 `handled_by`). 빈 문자열=
    # 아무도 다루지 않음 → 조립 byte-identical.
    handled_by: str = "",
    char_names: Sequence[str] = (),
    # False = char_names 가 샷 확정 배정이 아니라 씬 단위 fallback 추측이다.
    # 기본 True — 기존 호출자는 동작 불변(byte-identical).
    char_names_exact: bool = True,
    lane_ref_mode: str = "",
    structure_seed_attached: bool = False,
    seed_bg_mode: bool = False,
    chain_bg_mode: bool = False,
    camera_frame_en: str = "",
    lighting_mood_en: str = "",
    conduct_version: str = "",
    # 캐릭터 identity 참조 역할 한정 절 완문 (2026-08-12) —
    # build_identity_ref_role_clause 렌더 결과. 비면 조립 byte-identical.
    identity_role_en: str = "",
    # prev 스틸 인물 계승 스코프 절 완문 (#119③ build_prev_people_rule
    # 렌더 결과) — 빈 문자열 = 레거시 무조건 잠금 스템(byte-identical).
    prev_people_rule: str = "",
    # 저작 공급 표기 문안 섹션 완문 (#119② build_signage_section 렌더
    # 결과) — 빈 문자열 = 절 없음(byte-identical).
    signage_en: str = "",
    prompt_version: str = "1",
    # grok 컴팩트(2026-08-13): 지도 절 로드를 이 selector 로 오버라이드 —
    # 구조 분기(gates)는 prompt_version 이 그대로 결정하고, _p()/상수
    # selector 로드만 바뀐다. 빈 문자열(기본)=조립 byte-identical.
    guidance_version: str = "",
) -> str:
    """s39 _build_prompt ①~⑭ 조립 순서 이식.

    char_names = ["이름 (traits...)"] — entity_canon.stable_traits 병기는
    호출자가 수행. world_anchor 예: " — contemporary ..., 2026" (프로젝트
    데이터 파생, 하드코딩 금지).

    lane_ref_mode(Stage D HIGH-3, v3+ 팩 전용): ""=기존(LOCATION
    PHOTOGRAPH), "sketch"=레인1(사진 참조 0 — 스케치=배치 SOT+텍스트
    저작), "seed"=레인2(STRUCTURE LOOK 사진=구조물 SOT). prev_used 샷은
    prev 문장이 그대로 location authority (lane 여부 무관).

    structure_seed_attached(2026-07-16 복잡 구조물=A/B, v4 팩 전용):
    플레이트와 공존하는 STRUCTURE LOOK 관할·tie-break 절 주입
    (structure_look_clause — 구조물=seed 우선, 주변·시간·조명=플레이트
    우선). lane_ref_mode 와 상호 배타.

    camera_frame_en(fix1, 2026-07-19): build_camera_frame_clause 렌더 완문 —
    비면 조립 byte-identical(기존 팩 무변경). lane 샷은 마커 스케치가
    배치 SOT 라 상호 배타(이중 권위 금지).

    chain_bg_mode(2026-07-27 리뷰 F1, BGFIRST2 체인 저작 전용): Step2 는
    재투영/채색 배경본을 **첫 참조**로 받고 LOCATION PHOTOGRAPH 는 어디에도
    첨부되지 않는다 — 체인 저작 base 는 prev_used=False·lane_ref_mode=""
    이라 기본 fallback 문구("the attached LOCATION PHOTOGRAPH shows the
    exact spot.")가 그대로 나가 존재하지 않는 이미지를 가리켰다(v3 에서
    lane 샷에 대해 한 번 제거된 모순의 체인 경로 재발). 배경본을 장소
    권위로 명시하는 스템으로 교체한다. 다른 권위 모드와 상호 배타이며,
    False(기본)면 조립 byte-identical.
    """
    if lane_ref_mode and lane_ref_mode not in ("sketch", "seed"):
        raise ValueError(f"unknown lane_ref_mode {lane_ref_mode!r}")
    if lane_ref_mode and prompt_version not in _LANE_LOCATION_PACKS:
        raise ValueError(
            f"still_recipe v{prompt_version} 팩에는 lane location "
            "authority 가 없음 — lane_ref_mode 는 v3+ 전용"
        )
    if structure_seed_attached:
        if prompt_version not in _STRUCTURE_LOOK_PACKS:
            raise ValueError(
                f"still_recipe v{prompt_version} 팩에는 structure_look "
                "계약이 없음 — structure_seed_attached 는 v4 전용"
            )
        if lane_ref_mode:
            raise ValueError(
                "structure_seed_attached 와 lane_ref_mode 는 상호 배타"
            )
    if seed_bg_mode:
        if prompt_version not in _SEED_BG_PACKS:
            raise ValueError(
                f"still_recipe v{prompt_version} 팩에는 seed-bg 단일 권위 "
                "계약이 없음 — seed_bg_mode 는 v5 전용"
            )
        if lane_ref_mode or structure_seed_attached:
            raise ValueError(
                "seed_bg_mode 는 단일 권위 — lane_ref_mode/"
                "structure_seed_attached 와 상호 배타 (Codex 조건 3)"
            )
    if camera_frame_en and lane_ref_mode:
        raise ValueError(
            "camera_frame_en 과 lane_ref_mode 는 상호 배타 — lane 샷은 "
            "마커 스케치가 배치 SOT (구도 권위 이중화 금지)"
        )
    if chain_bg_mode and (
        prev_used or lane_ref_mode or seed_bg_mode or structure_seed_attached
    ):
        raise ValueError(
            "chain_bg_mode 는 단일 권위(첫 참조=배경본) — prev_used/"
            "lane_ref_mode/seed_bg_mode/structure_seed_attached 와 상호 배타"
        )
    resolved = resolve_prompt_version(guidance_version or prompt_version)

    def _p(name: str, **kw) -> str:
        return load_prompt(_MODULE, name, version=resolved, **kw).strip()

    if prev_used:
        loc_tail = (
            "the attached PREVIOUS SHOT STILL shows this exact place.")
    elif lane_ref_mode:
        loc_tail = _p(f"location_lock_lane_{lane_ref_mode}")
    elif chain_bg_mode:
        # F1: 첫 참조(SHOT BACKGROUND)=이 장소 자체. 샷 팩(체인 저작은
        # "1")이 아니라 전용 selector 로 이 스템만 로드 — 단일 스템 팩
        # 선례(camera_frame/lighting_mood) 동형.
        loc_tail = load_prompt(
            _MODULE, "location_lock_chain_bg",
            version=resolve_prompt_version(
                CHAIN_BG_LOCATION_PROMPT_VERSION),
        ).strip()
    elif seed_bg_mode:
        # v5: seed-bg 단일 권위 — 플레이트 부재 그룹의 location lock
        loc_tail = _p("location_lock_seed_bg")
    else:
        loc_tail = (
            "the attached LOCATION PHOTOGRAPH shows the exact spot.")
    parts = [
        _p("still_head", world_anchor=world_anchor,
           time_of_day=time_of_day_en or "as the scene text implies"),
        f"SHOT TEXT (authoritative, Korean): {shot_desc}",
        f"LOCATION (lock): {place_text} The shot takes place here — "
        + loc_tail,
    ]
    if structure_seed_attached:
        # 복잡 구조물: 구조물=seed / 주변·시간·조명=플레이트 관할 명시
        parts.append(_p("structure_look_clause"))
    if camera_frame_en:
        # fix1: staging 구도·스케일 계약 — LOCATION 다음, 연속성/자세 앞
        parts.append(camera_frame_en)
    if lighting_mood_en:
        # fix⑤: 조명·무드+재질 사실감 — 구도 절 다음(구도→조명 순),
        # 비면 조립 byte-identical (기존 팩 무변경)
        parts.append(lighting_mood_en)
    if prev_used:
        if prev_people_rule:
            # #119③: 앵커 인물 명단을 아는 호출이면 인물 계승 스코프를
            # 명시한 v21 스템 — 겹친 인물만 잠그고, 겹침 0 이면 계승 금지.
            parts.append(load_prompt(
                _MODULE, "continues_clause_cast",
                version=resolve_prompt_version(
                    PREV_CAST_SCOPE_PROMPT_VERSION),
                people_rule=prev_people_rule).strip())
        else:
            parts.append(_p("continues_clause"))
        if prev_usage_en:
            parts.append(
                "PREVIOUS STILL USAGE (follow exactly — what to take"
                " from the attached still and what to exclude): "
                + prev_usage_en
            )
    # 배경 전용 샷엔 인물 관련 절(자세 정본) 주입 금지 — 인물 유도 방지
    if not bg_only and pose_clauses:
        parts.append("\n".join(pose_clauses))
        # E2E 육안 피드백(2026-07-14): 사후 신체가 팔을 들어 물체를 '보여주는'
        # 연출 발명 — 정본 미특정 부위의 중력 순응 계약을 정본 샷에 동반 주입
        parts.append(_p("immobile_physics"))
    parts.append(_p("realize_still"))
    parts.append(_p("expression_realism"))
    parts.append(_p("prop_orientation"))
    if conduct_version:
        # E2E11 fix④⑤ — 정적 계약 절 2종(팩 v10 스템 SOT). 비면 조립
        # byte-identical. naturalism 은 인물 절 — bg_only 샷엔 인물 유도
        # 방지 원칙(자세 정본 생략과 동일)으로 미주입, drawn_mark 는 벽의
        # 그려진 표식이 bgonly 샷 주 대상이라 전 샷 주입.
        _cv = resolve_prompt_version(guidance_version or conduct_version)
        if not bg_only:
            parts.append(
                load_prompt(
                    _MODULE, "naturalism_clause", version=_cv).strip())
        parts.append(
            load_prompt(_MODULE, "drawn_mark_clause", version=_cv).strip())
    if not bg_only:
        # 2026-08-13 육안 8건 wave: 몸-지지 정합(착석=가구 설계 방향)과
        # 정지=행동 중단 재료 — guidance 컴팩트와 무관한 단일 스템 SOT
        # (camera_frame v6 선례). 인물 절이라 bg_only 샷엔 미주입.
        parts.append(
            load_prompt(
                _MODULE, "support_stillness_clause",
                version=resolve_prompt_version(
                    grok_stem_version(
                        guidance_version,
                        SUPPORT_STILLNESS_PROMPT_VERSION))).strip())
    if movement_en:
        parts.append("MOVEMENT (follow exactly): " + movement_en)
    if figures_en:
        parts.append(
            "FIGURES — size & depth (follow exactly): " + figures_en
        )
    if carried_en:
        parts.append(
            "CARRIED STATE (persist exactly — must match the neighbouring"
            " shots of this scene): " + carried_en
        )
    # ★물건을 다루는 손 (2026-08-07 사용자 지적, 분류 팩 v4 `handled_by`).
    #
    # 무인 조항과 소지품 조항이 한 프롬프트에 함께 나간 샷이 33개 중 27개
    # 였다 — "신체 일부도 나오지 않는다"와 "그가 지닌 물건은 유지하라"가
    # 동시에. 완성본에서 휴대폰이 손 없이 떴다. 팩 v13 이 무인 조항에 손
    # 예외를 넣어 "손을 그려라"까지는 갔지만, **그 손이 누구 손인지**는
    # 여전히 아무 데도 없었다. bg_only 샷은 캐릭터 참조도 자세 계약도
    # 떼어내기 때문이다. 이 절이 그 자리를 메운다 — 참조 주입은
    # `build_still_refs(handled_by=...)` 가 함께 한다.
    if handled_by:
        parts.append(load_prompt(
            _MODULE, "handled_object_clause",
            version=resolve_prompt_version(
                guidance_version or GEOM_AUTHORITY_PROMPT_VERSION),
            handler=handled_by).strip())
    # ★인물 배정이 있으면 bg_only 가 그것을 이기지 못한다 (2026-08-06).
    #
    # 실측: S3sh6 은 샷 텍스트가 "창이 **그분의 가슴팍**을 꿰뚫고"이고
    # visible_entities 에 `C16 그분(검은 연기 형체)` 이 실제로 배정돼 있었는데,
    # `shot_ref_classify` 가 person_visible=false 로 분류해 bg_only 가 되면서
    # 그 배정이 조용히 버려지고 "NO PEOPLE IN THIS SHOT" 이 나갔다. 세 후보
    # 모두 사람 없이 그려졌고, 뒤이은 수정이 빈자리를 서양 남자로 채웠다.
    #
    # 두 신호가 모순일 때 어느 쪽도 강제하지 않는 것이 옳다. `people_clause`
    # 는 "샷 텍스트만이 인물의 등장 여부를 정한다 — 등장한다면 이 사람이어야
    # 한다"이므로, 인물을 밀어 넣지도 지우지도 않고 **누구인지만** 못 박는다.
    # 반면 `no_people_clause` 는 삭제를 강제한다. 모순일 때 강제하는 쪽을
    # 고르면 배정된 인물이 사라진다.
    if char_names:
        if bg_only:
            logger.info(
                "still_recipe: bg_only 와 인물 배정이 충돌 — 배정을 따른다 "
                "(char_names=%s)", char_names)
        if char_names_exact:
            parts.append(_p("people_clause", char_names="; ".join(char_names)))
        else:
            # 씬 단위 추측 — 배타 조항 없는 스템. 목록에 없는 인물을 샷 텍스트가
            # 지명하면 그쪽을 따르게 한다.
            parts.append(load_prompt(
                _MODULE, "people_clause_scene",
                version=resolve_prompt_version(
                    guidance_version or PEOPLE_SCENE_PROMPT_VERSION),
                char_names="; ".join(char_names)).strip())
    elif bg_only:
        parts.append(load_prompt(
            _MODULE, "no_people_clause",
            version=resolve_prompt_version(
                guidance_version or NO_PEOPLE_PROMPT_VERSION)).strip())
    else:
        parts.append(_p("no_people_fallback"))
    if identity_role_en:
        # 2026-08-12 (차렷/증명사진 대응): 인물 절 바로 뒤 — 참조의 관할을
        # 사람 규정과 붙여 둔다. 호출자가 캐릭터 참조 실첨부 샷에서만 절을
        # 만든다(미첨부 샷에 나가면 거짓 문장). 비면 byte-identical.
        parts.append(identity_role_en)
    # #119②: 저작 공급된 표기 문안 — 표기 정책 절 바로 앞(함께 읽힌다).
    # "장면이 부르는 것만 허용·발명 금지" 정책에 대한 값 공급.
    if signage_en:
        parts.append(signage_en)
    # 표기 정책(v20)은 전 경로 전역 — 샷별 _pack/guidance selector 를
    # 따르지 않는다 (2026-08-14 "원어 텍스트 허용, 꼭 필요한 경우만").
    parts.append(load_prompt(
        _MODULE, "no_text",
        version=resolve_prompt_version(
            grok_stem_version(
                guidance_version, TEXT_POLICY_PROMPT_VERSION))).strip())
    return "\n\n".join(parts)


def apply_share_plan_prev(
    classify_prev_tag: Optional[str],
    shot_plan: Optional[Dict[str, Any]],
) -> Optional[str]:
    """배경 공유 계획(B-3, 2026-07-19)의 prev/배경 결정 — 상위 권위.

    계획 없음=classify 판정 유지(fail-safe). ref_plan=="background"=
    prev 미사용(계획이 '이 샷은 배경 기준' 확정). ref_plan=="prev"=계획
    앵커로 override — 앵커 결손=ValueError(조용한 강등 금지, 샷 격리).
    """
    if not isinstance(shot_plan, dict):
        return classify_prev_tag
    rp = shot_plan.get("ref_plan")
    if rp == "background":
        return None
    if rp == "prev":
        anchor = shot_plan.get("prev_anchor_tag")
        if not anchor:
            raise ValueError("share_plan prev 앵커 결손 — fail-closed")
        return str(anchor)
    return classify_prev_tag


# ── BGFIRST2 2단 체인 (2026-07-20 이식 ②③) ──────────────────────────
# 확정 레시피: GPT 원근 가이드 콘티(shot_conti_light v2) → Step1=기존
# 플레이트를 콘티 카메라로 재투영한 인물 0 빈 배경(gpt-image-2) →
# Step2=그 배경+콘티(인물 배치만)+캐릭터 참조로 nb2 인물 삽입.
# 롤 구조=콘티 체인 1장 vs 무콘티(기존 조립) 1장 → VLM 2택1.
# 정본=exp_bgfirst_v2_gpt.py/exp_chainA_true_bgfirst.py (실증 S19sh4 외벽·
# S22sh2 야외 동작·S8sh4 실내 대면 — 콘티 구도 충실+플레이트 정합).


def bgfirst_eligible(
    *,
    conti_present: bool,
    bg_only: bool,
    prev_used: bool,
    lane_used: bool,
    complex_ab: bool,
    structure_seed_attached: bool,
    seed_bg_attached: bool,
) -> bool:
    """BGFIRST2 체인 대상 판정 — **일반 콘티 샷만** (순수 로직).

    사용자 확정(2026-07-20): 콘티 사용 샷만 교체, 비콘티 샷(prev/bgonly/
    lane)은 기존 유지. 복잡 구조물(structure seed·seed-bg·complex A/B)
    파이프는 2026-07-16 사용자 확정 별도 계약이라 제외 — BGFIRST2 실증
    (S19sh4/S22sh2/S8sh4)도 전부 일반 콘티 샷.
    """
    return (
        conti_present
        and not bg_only
        and not prev_used
        and not lane_used
        and not complex_ab
        and not structure_seed_attached
        and not seed_bg_attached
    )


def build_place_facts_block(spec: Any) -> str:
    """장소 사실 — 절제 블록 (2026-07-26 사용자: "너무 디테일하게 넣으면
    안 돼").

    싣는 것: layout_narration_en 1단락 + items 의 kind/name_en.
    빼는 것:
      - placement_en — 배치 권위는 첨부 콘티의 선이다. 텍스트로 또 주면
        이중 권위가 되어 구도가 흔들린다.
      - code/evidence/inferred/zone_labels_en — 감사 필드이거나 narration
        과 중복.
      - excluded_transient_elements — 금지 대상 열거는 오히려 프라이밍
        이다(outdoor_marker_map.build_geometry_text_lines 의 구조 토큰
        비노출과 같은 계열).

    코드는 데이터 직렬화만 한다 — 의미 판단 없음.

    spec 은 `Any` 다 — 실호출은 체크포인트 원본(무타입)이라 dict 가 아닌
    값([] / None)이 실제로 들어온다. Dict 로 좁히면 아래 isinstance 가정이
    항상 참이 되어 정적 검사기가 가드를 죽은 코드로 읽는다(Optional 로는
    None 만 덮여 [] 를 못 담는다).
    """
    if not isinstance(spec, dict):
        return ""
    lines: List[str] = []
    narration = str(spec.get("layout_narration_en") or "").strip()
    if narration:
        lines.append(narration)
    seen: Set[str] = set()
    for it in spec.get("items") or []:
        if not isinstance(it, dict):
            continue
        kind = str(it.get("kind") or "").strip()
        name = str(it.get("name_en") or "").strip()
        if not name or name in seen:
            continue
        seen.add(name)
        lines.append(f"- {kind}: {name}" if kind else f"- {name}")
    return "\n".join(lines)


def build_bgfirst_bg_prompt(
    *,
    shot_desc: str,
    place_text: str,
    time_of_day_en: str,
    camera_frame_en: str = "",
    lighting_mood_en: str = "",
    place_facts_block: str = "",
    world_facts_block: str = "",
    lane_fill: bool = False,
    prompt_version: str = BGFIRST_PROMPT_VERSION,
) -> str:
    """Step1 재투영 프롬프트 — 정본 bg_prompt 조립 순서.

    head(무인+콘티 카메라 권위·플레이트=재질/간판 소스·재투영 계약) →
    SHOT TEXT → LOCATION(lock) → TIME OF DAY(lock) → CAMERA/FRAME 절
    (fix1 렌더 완문, 부재=생략) → LIGHTING & MOOD 절(fix⑤, 부재=생략)
    → tail(16:9 빈 실사 배경).

    lane_fill (2026-07-26 확정 흐름): 참조 이미지 0 으로 콘티 자체를
    편집하는 lane 경로. head/tail 스템이 bg_fill_* 로 바뀌고 장소·world
    사실 블록이 **필수**가 된다 — 둘 중 하나라도 비면 무국적 배경을
    조용히 만들게 되므로 ValueError(fail-closed). 기본값 False 는 기존
    조립과 byte-identical.
    """
    resolved = resolve_prompt_version(prompt_version)
    if lane_fill:
        if not place_facts_block.strip():
            raise ValueError(
                "lane_fill 인데 place_facts_block 이 비어 있음 — 장소 "
                "사실 없이 배경을 만들지 않는다 (fail-closed)"
            )
        if not world_facts_block.strip():
            raise ValueError(
                "lane_fill 인데 world_facts_block 이 비어 있음 — 지역·"
                "시대 없이 배경을 만들지 않는다 (fail-closed)"
            )
        parts = [
            load_prompt(_MODULE, "bg_fill_head", version=resolved).strip(),
            f"SHOT TEXT this background must serve (Korean): {shot_desc}",
            f"LOCATION (lock): {place_text}",
            f"TIME OF DAY (lock): {time_of_day_en}.",
        ]
    else:
        parts = [
            load_prompt(
                _MODULE, "bg_reproject_head", version=resolved).strip(),
            f"SHOT TEXT this background must serve (Korean): {shot_desc}",
            f"LOCATION (lock): {place_text}",
            f"TIME OF DAY (lock): {time_of_day_en}.",
        ]
    if camera_frame_en:
        parts.append(camera_frame_en)
    if lighting_mood_en:
        # fix⑤: 배경 자체의 조명·재질이 체인 최종 무드를 지배 — Step1 도
        # 동일 절 소비 (비면 byte-identical)
        parts.append(lighting_mood_en)
    if lane_fill:
        # 사실 블록은 CAMERA/LIGHTING 뒤 — tail 의 "the list above" 가
        # 바로 앞 두 절을 가리켜야 배치 무권한 선언이 붙는다.
        parts.append("THINGS AT THIS PLACE:\n" + place_facts_block)
        parts.append(
            "WORLD FACTS (creator-confirmed — always true):\n"
            + world_facts_block)
        parts.append(load_prompt(
            _MODULE, "bg_fill_tail",
            version=resolve_prompt_version(
                TEXT_POLICY_PROMPT_VERSION)).strip())
    else:
        parts.append(load_prompt(
            _MODULE, "bg_reproject_tail",
            version=resolve_prompt_version(
                TEXT_POLICY_PROMPT_VERSION)).strip())
    return "\n\n".join(parts)


def build_bgfirst_final_prompt(
    base_prompt: str,
    prompt_version: str = BGFIRST_PROMPT_VERSION,
    mannequin: bool = False,
    geom_authority: bool = False,
) -> str:
    """Step2 인물 삽입 프롬프트 — 정본 final_prompt: stage_head(배경
    EXACTLY 유지·스케치=인물 배치만·선 잔류 금지) + base 스틸 전문.

    mannequin=True (2026-07-26 확정 흐름): 배경본의 회색 마네킹을 실제
    인물로 교체하는 계약 스템(stage_head_mannequin)을 쓴다 — 위치·크기·
    자세·방향 유지, 미러 금지, 마네킹/스케치 선 잔류 0. lane 체인의
    Step1 은 마네킹을 **보존한 채** 배경만 실사화하므로(bg_fill_head
    "do not turn them into people"), 이 교체 계약이 없으면 회색 마네킹이
    최종 스틸까지 살아남는다.

    스템 교체이지 덧붙임이 아닌 이유: v7 stage_head 는 ①"배경을 EXACTLY
    유지"(=마네킹 유지로 읽힌다)와 ②"SECOND attached image (LAYOUT
    SKETCH)"(lane 은 conti=None 이라 그 참조 자체가 없다)를 품고 있어,
    앞에 절을 덧대면 두 문장이 그대로 남아 서로 싸운다.

    geom_authority=True (2026-08-07, 팩 v15): 좁고 복잡한 실내 —
    `stage_head_geom` 으로 교체한다. v7 stage_head 는 "Ignore the sketch's
    background lines" 를 품고 있어 덧대면 두 지시가 싸운다. 교체본은 배경의
    조작 장치·좌석·거울을 세고 같은 수·같은 자리로 유지하게 하며, 스케치의
    배경선을 인물과 구조의 관계를 읽는 데 쓰게 한다. 실물 대조가 근거다 —
    배경본은 핸들 1개인데 인물 삽입 뒤 림이 이중이 됐다.

    mannequin 과 겹치면 mannequin 이 이긴다: 마네킹 교체는 그 자체가 배경을
    고쳐 쓰는 계약이라 "배경을 그대로 두라"와 양립하지 않는다. 마네킹 경로는
    lane 체인 전용이고 lane 은 이 판별 대상이 아니다.

    base 스틸 프롬프트 전문은 세 경로 모두 그대로 유지한다 — 자세 정본·
    조명·표정 사실감·소품 계약을 잃지 않는다. 기본값(False)은 기존
    산출과 byte-identical.
    """
    if mannequin:
        stem, resolved = (
            "stage_head_mannequin", resolve_prompt_version(prompt_version))
    elif geom_authority:
        stem, resolved = (
            "stage_head_geom",
            resolve_prompt_version(GEOM_AUTHORITY_PROMPT_VERSION))
    else:
        stem, resolved = "stage_head", resolve_prompt_version(prompt_version)
    return (
        load_prompt(_MODULE, stem, version=resolved).strip()
        + "\n\n"
        + base_prompt
    )


def build_bgfirst_refs(
    *,
    bg: Path,
    conti: Optional[Path],
    char_refs: Sequence[Tuple[str, Any]],
    prop_refs: Sequence[Tuple[str, Any]],
    prompt_version: str = BGFIRST_PROMPT_VERSION,
    entity_label_version: str = "1",
    geom_authority: bool = False,
) -> List[Tuple[str, Any]]:
    """Step2 참조 조립 — [SHOT BACKGROUND, LAYOUT SKETCH, 엔티티].

    bg/sketch 라벨=팩 v7 정본 스템, 엔티티 라벨=샷의 기본 팩(v1 —
    eligibility 가 일반 콘티 샷만 허용하므로 다른 selector 불가)과 동일
    포맷 — 무콘티 후보(B)와 엔티티 라벨이 정확히 일치해야 비교가 순수.

    conti=None (2026-07-26 확정 흐름): LAYOUT SKETCH 슬롯을 생략한다 —
    lane 체인의 배경본은 이미 마네킹 배치와 장소를 담고 있어 콘티가
    중복이고, 선 그림을 참조로 넣으면 스케치 선 잔류 위험이 있다.
    비-lane 호출(conti 실재)의 참조 순서·라벨은 불변이다.
    """
    resolved = resolve_prompt_version(prompt_version)
    ent_resolved = resolve_prompt_version(entity_label_version)
    refs: List[Tuple[str, Any]] = [
        (load_prompt(_MODULE, "bg_label", version=resolved).strip(), bg),
    ]
    if conti is not None:
        # 좁고 복잡한 실내는 스케치가 인물 배치**와** 구조 둘 다의 권위다
        # (팩 v15). 그 외에는 기존 "people placement only" 라벨 그대로.
        sk_stem, sk_ver = (
            ("sketch_label_geom",
             resolve_prompt_version(GEOM_AUTHORITY_PROMPT_VERSION))
            if geom_authority else ("sketch_label", resolved)
        )
        refs.append(
            (load_prompt(_MODULE, sk_stem, version=sk_ver).strip(), conti)
        )
    char_label = load_prompt(
        _MODULE, "char_label", version=ent_resolved).strip()
    prop_label = load_prompt(
        _MODULE, "prop_label", version=ent_resolved).strip()
    for name, src in char_refs:
        refs.append((char_label.format(name=name), src))
    for name, src in prop_refs:
        refs.append((prop_label.format(name=name), src))
    return refs


def load_bgfirst_judge_header(
    prompt_version: str = BGFIRST_PROMPT_VERSION,
) -> str:
    """2택1 중립 판정 헤더 — 기본 헤더('generated from this')는 체인/무콘티
    후보가 서로 다른 참조·프롬프트로 생성되므로 거짓(HIGH-6 계약 연속)."""
    resolved = resolve_prompt_version(prompt_version)
    return load_prompt(_MODULE, "judge_header", version=resolved).strip()


BGFIRST_STRUCTURAL_SKIPS = frozenset({"no_plate", "prev", "bgonly"})


def bgfirst_structural_skip(conti_entry: Optional[Dict[str, Any]]) -> bool:
    """콘티 스텝이 구조적 비대상으로 선언한 샷 여부 (E2E10 결함 1호 fix).

    shot_conti_light 모듈 계약: "콘티 부재가 스틸을 막지 않는다 — 플레이트
    없는 샷은 skipped_reason='no_plate'". BGFIRST2 정책 대상은 '콘티 사용
    샷'만(사용자 확정 2026-07-20) — 콘티 스텝이 스스로 대상 외로 선언한 샷
    (no_plate/prev/bgonly)은 fail-closed 가 아니라 기존 경로 유지. E2E10
    실측: 야외 prev_shot_ref 그룹·lane none 의 no_plate 10샷 fail-closed +
    prev 앵커 부재 연쇄 4샷(skipped=prev 인데 prev_sel=None → prev_used=
    False 로 정책 대상 오판정). entry 부재/비정형(non-dict)/생성 error/
    미지 사유는 여전히 정책 대상 → bgfirst_conti_defect fail-closed
    (Codex BLOCKING 계약). error 는 skipped_reason 보다 우선 — 손상·stale
    CP 에서 구조적 skip 에 가려 legacy 로 무음 하강하지 않는다 (Codex
    NARROW 재리뷰).
    """
    if not isinstance(conti_entry, dict) or not conti_entry:
        return False
    if conti_entry.get("error"):
        return False
    return conti_entry.get("skipped_reason") in BGFIRST_STRUCTURAL_SKIPS


def bgfirst_eligible_full(
    *,
    conti_present: bool,
    bg_only: bool,
    prev_used: bool,
    lane_used: bool,
    chain_lane_prev: bool = False,
) -> bool:
    """BGFIRST full 대상 판정 (E2E10 fix④ — 사용자 확정 "콘티=무조건 배경
    우선 전면화", 순수 로직).

    콘티가 실재하는 전 샷 = 체인 대상: complex(structure seed)·seed-bg·
    no_plate(콘티 팩 v3 저작) 전부 포함 — bgfirst_eligible(v2 계약)과
    달리 구조물 파이프도 체인으로 편입한다(Step1 에 STRUCTURE LOOK 3번째
    참조·위치 권위 해석은 서비스 담당). 비콘티 샷(prev/bgonly/lane)은
    확정 레시피대로 기존 유지.

    chain_lane_prev (2026-07-25 사용자 확정 — 야외 케이스1 스펙 E·F):
    lane(마커 스케치 콘티) 샷과 prev 지휘 샷도 체인 편입. 실측 근거:
    lane 샷은 배경 권위가 아예 없어 같은 장소 일반 샷과 다른 장소로
    그려졌고(S15sh1 vs S15sh5), prev 지휘 샷은 구도가 다른 직전 스틸을
    재투영 없이 참조만 해 장소가 재현되지 않았다. 배경 권위 해석(prev
    스틸/groupbg)과 조립 통일은 서비스 담당. bg_only 는 계속 제외
    (콘티 자체가 없다). default False = 기존 경로 byte-identical.
    """
    if bg_only or not conti_present:
        return False
    if chain_lane_prev:
        return True
    return not prev_used and not lane_used


# groupbg 장소 근거 절(detail/evidence 헤더 스템)이 실리는 팩 — v11+.
# v10 이하 = 기존 조립 byte-identical (E2E11 ③).
_GROUPBG_DETAIL_PACKS = {"11"}


def build_groupbg_prompt(
    *,
    place_text: str,
    time_of_day_en: str,
    world_anchor: str = "",
    prompt_version: str = BGFIRST_FULL_PROMPT_VERSION,
    location_detail_en: str = "",
    scene_evidence: Sequence[str] = (),
) -> str:
    """no_plate 그룹의 장소 단위 배경(groupbg) 프롬프트 (fix③④).

    head(무인+콘티=공간 근거·사진 신조) → THE LOCATION(장소 lock+world
    anchor) → [v11+ LOCATION DETAIL(loc 서술/traits) → SCENE EVIDENCE
    (share 그룹 원문 인용 — 장소 사실만)] → TIME OF DAY → tail(16:9 샷
    중립 실사 배경). **샷 특정 정보(SHOT TEXT/카메라) 불포함** — 같은
    장소의 모든 샷이 재사용하는 그룹 공용 배경이라 특정 샷 카메라에
    묶지 않는다 (샷별 카메라는 Step1 재투영이 담당).

    E2E11 ③ 실측: 장소 근거가 place_en 1문장+콘티뿐이라 감식 현장
    groupbg 가 허허벌판 시멘트 바닥으로 렌더 — 가용 근거(loc description/
    visual_traits·share 그룹 evidence 인용)를 코드 조립으로 주입한다.
    빈 입력=절 생략(dangling 금지), v10 이하=kwargs 무시(byte-identical).
    """
    resolved = resolve_prompt_version(prompt_version)
    parts = [
        load_prompt(_MODULE, "groupbg_head", version=resolved).strip(),
        f"THE LOCATION{world_anchor}: {place_text}",
    ]
    if prompt_version in _GROUPBG_DETAIL_PACKS:
        if (location_detail_en or "").strip():
            parts.append(
                load_prompt(
                    _MODULE, "groupbg_detail_head", version=resolved
                ).strip()
                + f"\n{location_detail_en.strip()}"
            )
        quotes = [q.strip() for q in scene_evidence if (q or "").strip()]
        if quotes:
            parts.append(
                load_prompt(
                    _MODULE, "groupbg_evidence_head", version=resolved
                ).strip()
                + "\n" + "\n".join(f"- {q}" for q in quotes)
            )
    parts.extend([
        f"TIME OF DAY (lock): {time_of_day_en}.",
        load_prompt(
            _MODULE, "groupbg_tail",
            version=resolve_prompt_version(
                TEXT_POLICY_PROMPT_VERSION)).strip(),
    ])
    return "\n\n".join(parts)


def groupbg_context_sig(
    *,
    location_detail_en: str,
    scene_evidence: Sequence[str],
) -> str:
    """groupbg 장소 근거의 그룹 단위 지문 (Codex 리뷰 NARROW-4).

    meta 에 실려 **모든 멤버**(origin 아닌 후속 샷 포함)가 상류 근거
    (loc 서술/traits·share 그룹 evidence) drift 를 감지하게 한다 —
    기존 meta(model/size/pack/contract/group_sig)만으로는 origin 완료
    후 partial resume 시 stale groupbg 가 조용히 재사용되던 창 봉합.
    입력은 그룹 안정 파생값이어야 한다(샷별 값 금지 — 멤버 간 지문
    불일치로 공유 배경이 흔들리는 역결함 차단).
    """
    import hashlib as _hashlib
    import json as _json

    payload = _json.dumps(
        [
            (location_detail_en or "").strip(),
            [q.strip() for q in scene_evidence if (q or "").strip()],
        ],
        ensure_ascii=False, sort_keys=True,
    )
    return _hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]


def era_preserve_applies(
    *,
    era_on: bool,
    era_meta: Optional[Dict[str, Any]],
    outcome: Optional[Dict[str, Any]],
    file_ok: bool,
    prev_meta: Any,
) -> bool:
    """(era R2 BLOCK-1) 조사 미성립 시 기존 groupbg 보존 여부 — 순수 판정.

    True 조건 전부: era ON · 이번 방문 조사 산출 없음 · outcome 이
    "실패"(비대상 아님) · 기존 파일 정상 · 이전 meta 에 era_research_sha
    존재(=원래 era 로 만든 배경). 보존은 현재 계약 검증을 미루는 것이라
    호출측은 걷기 끝 미봉인(StillEraPreserveIncomplete)과 반드시 짝지어
    쓴다 — 보존만 하고 봉인하면 진짜 drift 가 낡은 배경 아래 영구 동결.
    """
    return bool(
        era_on and era_meta is None
        and isinstance(outcome, dict) and outcome.get("failed")
        and file_ok and isinstance(prev_meta, dict)
        and "era_research_sha" in prev_meta)


def decide_groupbg_reuse(
    *,
    prev_rec: Dict[str, Any],
    meta: Dict[str, Any],
    file_ok: bool,
    fingerprint_fn,
) -> Tuple[bool, Optional[str]]:
    """groupbg 재사용 결정 — (regenerate, fp).

    계약(서비스 인라인 블록을 순수 함수로 추출):
    - 파일 결손 or meta(팩·모델·계약·group_sig·context_sig) 불일치 =
      멤버 무관 재생성
    - meta 일치 = **canonical origin 입력(현재 상류 SOT 기준) 지문 대조**
      — Codex 3차 리뷰: origin 이 already_done 으로 skip 되는 partial
      resume 에서 follower 가 origin 의 현재 place/time/콘티 bytes drift
      를 감지하지 못하던 창 봉합. fingerprint_fn 은 호출측이 canonical
      origin 기준(자기 자신 or record.origin_tag 의 현재 입력)으로
      구성한다 — follower 자기 값 지문 금지(멤버별 진동 차단).
    """
    if not (file_ok and prev_rec.get("meta") == meta):
        return True, None
    fp = fingerprint_fn()
    return prev_rec.get("input_fingerprint") != fp, fp


def resolve_groupbg_canonical_origin(
    *,
    prev_rec: Dict[str, Any],
    tag: str,
    current_inputs: Dict[str, Any],
    origin_lookup_fn,
    current_group_sig: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """canonical origin 입력 해석 (Codex 재리뷰 NARROW-1 + 3·4차 NARROW).

    fresh run 은 그룹의 story-순 최초 샷(origin) 콘티·장소로 생성한다.
    partial resume 에서도 지문 대조·재생성이 전부 **origin 의 현재 상류
    SOT 입력** 기준이어야 실행 형태와 무관하게 픽셀/lineage/감사값이
    동일하다(3차 리뷰: 과거 record 복원만으로는 origin 의 현재
    place/time/콘티 bytes drift 를 follower resume 이 감지 못함). 계약:

    - **group_sig 변경 = fail-closed** (4차 리뷰): share 그룹이 재저작돼
      멤버 구성이 바뀌면(old origin 이탈·더 이른 멤버 추가 포함) 과거
      origin 을 보존해 재생성하는 것도, 트리거 샷을 조용히 승격하는
      것도 fresh-equivalence 를 깨므로 record 리셋 재실행을 명시 요구.
      origin_tag ∉ 현재 그룹 tags(레코드 손상)도 동일.
    - origin_tag = prev_rec.origin_tag(있으면 보존) else 현재 tag —
      follower 를 새 origin 으로 조용히 승격하지 않는다.
    - 자기 자신이 origin = current_inputs 가 canonical.
    - 타 origin = origin_lookup_fn(origin_tag) 이 **현재** authoritative
      maps(classify/콘티)에서 해석 — 해석 실패는 lookup 이 ValueError
      (fail-closed, 원 origin 재실행 요구). follower 자기 값은 절대
      사용하지 않는다.

    반환: {place_text, time_of_day_en, conti_path, conti_asset_id,
    origin_tag}
    """
    origin_tag = str(prev_rec.get("origin_tag") or "") or tag
    if prev_rec and current_group_sig is not None:
        prev_sig = (prev_rec.get("meta") or {}).get("group_sig")
        if prev_sig is not None and prev_sig != current_group_sig:
            raise ValueError(
                "groupbg share 그룹 재구성 감지(group_sig 변경) — 과거 "
                f"origin({origin_tag}) 보존/승격 모두 fresh-equivalence "
                "위반이라 fail-closed. groupbg record 리셋 후 재실행 필요"
            )
        tags = (current_group_sig or {}).get("tags") or []
        if origin_tag != tag and tags and origin_tag not in tags:
            raise ValueError(
                f"groupbg origin({origin_tag})이 현재 그룹 tags 에 없음 — "
                "record 손상/드리프트, fail-closed"
            )
    if origin_tag == tag:
        return {**current_inputs, "origin_tag": tag}
    resolved = origin_lookup_fn(origin_tag)
    return {**resolved, "origin_tag": origin_tag}


def validate_groupbg_conti_source(
    *,
    conti_path: Any,
    conti_asset_id: Any,
    origin_tag: str,
) -> Path:
    """canonical 콘티 소스 검증 (Codex 4차 NARROW-2) — 생성 전 fail-closed.

    파일 실재+비어있지 않음+asset UUID(SOT) 필수 — Path.exists() 만으로
    디렉터리/0-byte/asset 결손이 통과해 감사 edge 가 과거 UUID 로 남던
    창 봉합. 반환=검증된 Path.
    """
    p = Path(conti_path) if conti_path else None
    if p is None or not p.is_file() or p.stat().st_size <= 0:
        raise ValueError(
            f"groupbg origin({origin_tag}) 콘티 파일 무효(부재/디렉터리/"
            f"0-byte): {conti_path!r} — fail-closed"
        )
    if not (isinstance(conti_asset_id, str) and conti_asset_id.strip()):
        raise ValueError(
            f"groupbg origin({origin_tag}) 콘티 asset UUID 결손 — "
            "lineage SOT 미해결, fail-closed"
        )
    return p


def groupbg_require_input_ids(*, conti_asset_id: Optional[str]) -> List[str]:
    """groupbg lineage 입력 검증 — 근거 콘티 UUID 실재 필수 (fail-closed,
    bgfirst_require_input_ids 관례)."""
    if not conti_asset_id:
        raise ValueError("groupbg 근거 콘티 asset 미해결 — fail-closed")
    return [conti_asset_id]


# lane(map_marker) 확정 흐름의 위치 권위 종류 — 외부 사진 0, 콘티 자체가
# 유일한 Step1 입력이다. generic "none" 대신 typed 로 두어 "권위 없음"이
# 다른 경로로 번지지 않게 한다.
LANE_CONTI_ONLY = "lane_conti_only"


def bgfirst_winner_lineage(
    *,
    chain_won: bool,
    authority_kind: str,
    structure_seed_attached: bool,
    conti_attached: bool = True,
) -> List[str]:
    """final still 의 직접 첨부(actual_attached) lineage 역할 목록
    (Codex HIGH-3 — false direct edge 방지, 순수 로직).

    체인 승: final 의 실제 refs=[bgfirst_bg, conti, entities] — conti/
    bgfirst_bg 만. 위치 권위·STRUCTURE LOOK 은 Step1 중간 bg 의 input
    edge 가 소유한다(직접 첨부 아님).
    무콘티 승: B 후보 refs=[위치 권위(+STRUCTURE LOOK), entities] —
    권위 종류별 role + seed(실재 시).

    authority_kind="prev" (2026-07-25, 케이스1 스펙 E·F): prev 지휘 샷
    체인. 무콘티 후보가 없어 B=현행 prev 경로 그대로이므로, 무콘티 승
    시 role 은 prev_still(+콘티 실재 시 conti) — 서비스가 현행 조립과
    동일한 첨부를 재현한다.
    authority_kind="canon_master" (동): lane 샷 체인 — 콘티(마커 스케치)
    가 본 장소 실사가 곧 위치 권위다. 무콘티 승 시 그 사진이 직접 참조.

    conti_attached (2026-07-26): 체인 승 final 이 콘티를 **실제로** 참조
    하는지. 확정 흐름의 lane 체인은 Step2 참조에서 콘티를 빼므로
    False — 이때 direct role 은 bgfirst_bg 뿐이고 콘티 UUID 는 중간
    bgfirst_bg 의 input edge 에만 남는다(conti → bgfirst_bg → final).
    True 로 두면 존재하지 않는 direct edge 가 생긴다.
    """
    if authority_kind not in (
        "plate", "groupbg", "seed_bg", "prev", "canon_master",
        LANE_CONTI_ONLY,
    ):
        raise ValueError(f"unknown authority_kind {authority_kind!r}")
    if authority_kind == LANE_CONTI_ONLY and not chain_won:
        raise ValueError(
            f"{LANE_CONTI_ONLY} 는 체인 단독 경로 — chain_won=False 는 "
            "도달 불가 조합 (거짓 lineage 대신 fail-closed)"
        )
    if chain_won:
        return ["conti", "bgfirst_bg"] if conti_attached else ["bgfirst_bg"]
    out = [authority_kind]
    if structure_seed_attached:
        out.append("structure_seed")
    return out


def build_bgfirst_seed_clause(
    prompt_version: str = BGFIRST_FULL_PROMPT_VERSION,
) -> str:
    """Step1 3번째 참조(STRUCTURE LOOK) 관할 절 — complex 샷 체인 편입
    (fix④). 팩 v9 스템 SOT."""
    return load_prompt(
        _MODULE, "bg_reproject_seed_clause",
        version=resolve_prompt_version(prompt_version),
    ).strip()


def bgfirst_conti_defect(
    conti_entry: Optional[Dict[str, Any]],
    conti_path: Optional[Path],
) -> Optional[str]:
    """BGFIRST 정책 대상 샷의 콘티 산출 결함 사유 (없으면 None).

    Codex 리뷰 1 (BLOCKING): flag ON 에서 정책 대상(일반 콘티 샷)이 콘티
    실패/파일 결손/asset 결손이면 legacy 무콘티 생성으로 조용히 하강하지
    않는다 — 호출자는 사유로 mark_failed (fail-closed). 비대상(prev/
    bgonly/lane/complex)은 이 판정을 타지 않는다.
    """
    entry = conti_entry or {}
    if entry.get("error"):
        return f"콘티 생성 실패: {entry['error']}"
    if entry.get("skipped_reason"):
        return f"콘티 미생성(skipped={entry['skipped_reason']})"
    if conti_path is None or not conti_path.is_file():
        return f"콘티 파일 결손({entry.get('image_path')!r})"
    if not entry.get("asset_id"):
        return "콘티 asset_id 결손 — lineage 없이 진행 금지"
    return None


def bgfirst_require_input_ids(
    *,
    conti_asset_id: Optional[str],
    plate_asset_id: Optional[str],
    plate_path: Any,
    seed_asset_id: Optional[str] = None,
    seed_attached: bool = False,
    authority_kind: str = "plate",
) -> List[str]:
    """Step1 lineage 입력 검증 — conti/plate UUID **둘 다** 실재 필수.

    Codex 재리뷰 1 (HIGH): plate UUID 미해결(row 부재·조회 예외로 None)
    상태에서 bg asset·성공 record 를 영속하면 불완전 lineage 가 성공으로
    고착된다 — 등록 **전** ValueError 로 샷 fail-closed (warning-only
    fail-open 금지). 반환=[conti, plate] 고정 순서.

    seed_attached (fix④ full — complex 샷 체인 편입): STRUCTURE LOOK 을
    3번째 참조로 첨부한 경우 seed UUID 도 실재 필수 → [conti, plate,
    seed]. default False=기존 2 UUID byte-identical.

    authority_kind=LANE_CONTI_ONLY (2026-07-26 확정 흐름): 외부 사진이
    아예 없는 lane 체인 — 플레이트 UUID 요구를 **이 모드에서만** 면제
    하고 [conti] 만 반환한다. 콘티 UUID 는 여전히 필수(불완전 lineage 를
    성공 record 로 영속 금지 원칙 유지). 다른 kind 는 기존 fail-closed.
    """
    if not conti_asset_id:
        raise ValueError("bgfirst 콘티 asset 미해결 — fail-closed")
    if authority_kind == LANE_CONTI_ONLY:
        if plate_asset_id or plate_path is not None:
            raise ValueError(
                f"{LANE_CONTI_ONLY} 인데 플레이트 입력이 공급됨 "
                f"({plate_path}) — 참조 0 계약 위반 (fail-closed)"
            )
        out = [conti_asset_id]
    else:
        if not plate_asset_id:
            raise ValueError(
                f"bgfirst 플레이트 asset 미해결({plate_path}) — 불완전 "
                "lineage 영속 금지 (fail-closed)"
            )
        out = [conti_asset_id, plate_asset_id]
    if seed_attached:
        if not seed_asset_id:
            raise ValueError(
                "bgfirst STRUCTURE LOOK asset 미해결 — 불완전 lineage "
                "영속 금지 (fail-closed)"
            )
        out.append(seed_asset_id)
    return out


def register_bgfirst_bg_asset(
    *,
    bg_path: Path,
    prompt: str,
    input_ids: Sequence[str],
    rel_fn: Any,
    find_asset_by_rel: Any,
    new_asset: Any,
    annotate_fn: Any,
    model: str = BGFIRST_BG_IMAGE_MODEL,
    asset_type: str = "bgfirst_bg",
    role: str = "bgfirst_bg",
    chain: str = "bgfirst_step1",
) -> Any:
    """Step1 재투영 배경을 intermediate ImageAsset 으로 idempotent 등록.

    asset_type/role/chain (fix③④ full): groupbg(장소 단위 공유 배경)도
    동일 upsert 계약으로 등록 — default=기존 bgfirst_bg byte-identical.

    Codex 리뷰 2 (BLOCKING): 캡처 큐는 이 서비스에 generation_context 가
    없어 no-op 이고, 있어도 UUID generated 경로로 등록해 recipe 경로
    조회와 어긋난다 — recipe 경로 기준 **명시 upsert** 가 lineage SOT.
    register_intermediate_assets 와 동일 주입 계약: 기존 row 는 lineage/
    prompt/model 을 현재 값으로 갱신(재생성 후 stale edge 잔존 차단),
    신규는 생성. input_ids=bgfirst_require_input_ids 산출([conti, plate]
    — 호출 전 검증 완료 계약). 재리뷰 2 (NARROW): fresh/existing 모두
    is_intermediate=True 정규화(캔버스 필터/접기 — 이 전용 recipe 경로는
    bgfirst_bg 중간물 단일 용도). 반환=asset.
    """
    rel = rel_fn(str(bg_path))
    meta: Dict[str, Any] = {"chain": chain}
    existing = find_asset_by_rel(rel)
    if existing is not None:
        annotate_fn(
            existing, role=role, input_ids=list(input_ids),
            meta=meta,
        )
        existing.generation_model = model
        existing.prompt_used = prompt[:65535]
        existing.is_intermediate = True
        return existing
    asset = new_asset(
        rel=rel, asset_type=asset_type, model=model,
        prompt=prompt[:65535],
    )
    asset.is_intermediate = True
    annotate_fn(
        asset, role=role, input_ids=list(input_ids), meta=meta,
    )
    return asset
