# Review package: c05315f5..b07692bf ## Commits b07692bf feat(lane): lane_conti_only 권위 — Step1 참조 0 + direct lineage 정정 ## Files changed backend/app/modules/pipeline/still_recipe.py | 61 +++++++++-- backend/app/services/still_recipe_service.py | 139 +++++++++++++++--------- backend/tests/unit/test_still_recipe_bgfirst.py | 92 ++++++++++++++++ 3 files changed, 230 insertions(+), 62 deletions(-) ## Diff diff --git a/backend/app/modules/pipeline/still_recipe.py b/backend/app/modules/pipeline/still_recipe.py index 927ccfe1..61812dcf 100644 --- a/backend/app/modules/pipeline/still_recipe.py +++ b/backend/app/modules/pipeline/still_recipe.py @@ -1136,39 +1136,47 @@ def build_bgfirst_final_prompt( return ( load_prompt(_MODULE, "stage_head", version=resolved).strip() + "\n\n" + base_prompt ) def build_bgfirst_refs( *, bg: Path, - conti: 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", ) -> 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), - (load_prompt(_MODULE, "sketch_label", version=resolved).strip(), - conti), ] + if conti is not None: + refs.append( + (load_prompt(_MODULE, "sketch_label", version=resolved).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 @@ -1427,48 +1435,67 @@ def validate_groupbg_conti_source( 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"] + 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 샷 체인 편입 @@ -1502,40 +1529,54 @@ def bgfirst_conti_defect( 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 not plate_asset_id: - raise ValueError( - f"bgfirst 플레이트 asset 미해결({plate_path}) — 불완전 " - "lineage 영속 금지 (fail-closed)" - ) - out = [conti_asset_id, plate_asset_id] + 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 diff --git a/backend/app/services/still_recipe_service.py b/backend/app/services/still_recipe_service.py index 0ae0eab6..0ce1bddb 100644 --- a/backend/app/services/still_recipe_service.py +++ b/backend/app/services/still_recipe_service.py @@ -736,21 +736,21 @@ def run_still_recipe_generation( # ── 순차 생성 (스토리 순서 — prev 앵커 체인) ──────────────────── recipe_dir = scene_dir / "recipe" recipe_dir.mkdir(parents=True, exist_ok=True) records = _Records(recipe_dir / "records.json") def _run_bgfirst_bg( tag: str, still_id: str, conti_path: Path, - plate_path: Path, + plate_path: Optional[Path], bg_prompt: str, conti_asset_id: Optional[str], plate_asset_id_override: Optional[str] = None, seed_path: Optional[Path] = None, seed_asset_id: Optional[str] = None, authority_kind: str = "plate", ) -> Tuple[Path, str]: """BGFIRST2 Step1 — 플레이트를 콘티 카메라로 재투영한 인물 0 빈 배경 (gpt-image-2, 참조=[콘티, 플레이트] 순서 고정 — 프롬프트의 FIRST/SECOND 지칭과 동조). @@ -761,20 +761,24 @@ def run_still_recipe_generation( sanitizer 1회 재시도(effective_prompt 로 provenance 병록). 반환=(bg_path, bg_asset_id) — 등록(아래 _register)은 재사용 경로에서도 매번 idempotent upsert 라 file+row+fingerprint 가 함께 검증된다(Codex 리뷰 2). fix③④ full 확장: plate_path=위치 권위(플레이트/seed-bg/groupbg — plate_asset_id_override 로 UUID 직접 전달), seed_path=STRUCTURE LOOK 3번째 참조(complex 샷 — bg_prompt 에 seed 절은 호출자가 포함, input_ids 3 UUID). 신규 인자 default=기존 경로 지문·참조 byte-identical. + + 2026-07-26 확정 흐름: authority_kind=LANE_CONTI_ONLY 면 + plate_path=None 이고 참조는 [콘티] 1장이다 — 외부 사진 없이 + 콘티 자체를 편집해 배경을 입힌다(장소 사실은 텍스트 권위). """ import uuid as _uuid from app.core.file_paths import to_relative_image_path from app.models.project import ImageAsset from app.modules.llm.gpt_image_primitive import call_gpt_image_bytes from app.modules.pipeline.multiroll_gemini import atomic_write_bytes from app.modules.pipeline.multiroll_select import ( compute_input_fingerprint, ) @@ -786,21 +790,23 @@ def run_still_recipe_generation( BGFIRST_BG_SIZE, BGFIRST_CONTRACT_VERSION, register_bgfirst_bg_asset, ) from app.services.image_capture.annotate import ( annotate_generated_asset, ) out = recipe_dir / f"{tag}__bgfirst_bg.png" key = f"{tag}::bgfirst_bg" - _fp_refs = [("conti", conti_path), ("plate", plate_path)] + _fp_refs = [("conti", conti_path)] + if plate_path is not None: + _fp_refs.append(("plate", plate_path)) _fp_extra: Dict[str, Any] = { "model": BGFIRST_BG_IMAGE_MODEL, "size": BGFIRST_BG_SIZE, "pack": recipe_pack_version(_BGFIRST_PACK), # 리뷰 6: 체인 계약 전환도 Step1 산출 무효화 대상 "contract": BGFIRST_CONTRACT_VERSION, } if seed_path is not None: _fp_refs.append(("seed", seed_path)) if authority_kind != "plate" or seed_path is not None: @@ -828,22 +834,27 @@ def run_still_recipe_generation( "stale 아카이브 후 재생성", tag, ) archive_stale_output(out) current = bg_prompt for attempt in (1, 2): try: png = call_gpt_image_bytes( _bgfirst_gpt_client, mode="edit", prompt=current, - ref_paths=[conti_path, plate_path] - + ([seed_path] if seed_path is not None else []), + ref_paths=( + [conti_path] + + ([plate_path] if plate_path is not None + else []) + + ([seed_path] if seed_path is not None + else []) + ), call_kwargs={ "model": BGFIRST_BG_IMAGE_MODEL, "size": BGFIRST_BG_SIZE, "quality": "high", "n": 1, }, capture_role="still_recipe_bgfirst_bg", capture_metadata={ "operation_type": "still_recipe_bgfirst_bg", "still_id": still_id, @@ -880,31 +891,36 @@ def run_still_recipe_generation( # ── intermediate ImageAsset 명시 upsert (Codex 리뷰 2 BLOCKING — # capture 는 scope 부재 no-op + UUID 경로라 lineage SOT 불가. # 재사용 경로에서도 매번 호출 = row 실재·lineage 최신 보증) ── # 재리뷰 1 (HIGH): plate UUID 미해결/조회 예외(None)=등록 전 # ValueError → 샷 fail-closed (불완전 lineage 를 성공 record 로 # 영속 금지 — warning-only fail-open 제거) from app.modules.pipeline.still_recipe import ( bgfirst_require_input_ids, ) - _plate_aid = plate_asset_id_override or ( - (map_conti.get(tag) or {}).get("asset_id") - if (map_conti.get(tag) or {}).get("plate_path") - == str(plate_path) else None - ) or _plate_asset_id(plate_path) + # LANE_CONTI_ONLY(참조 0)는 플레이트 자체가 없다 — UUID 조회를 + # 아예 타지 않고, 검증도 그 모드에서만 면제된다(아래 kind 전달). + _plate_aid = None + if plate_path is not None: + _plate_aid = plate_asset_id_override or ( + (map_conti.get(tag) or {}).get("asset_id") + if (map_conti.get(tag) or {}).get("plate_path") + == str(plate_path) else None + ) or _plate_asset_id(plate_path) input_ids = bgfirst_require_input_ids( conti_asset_id=conti_asset_id, plate_asset_id=_plate_aid, plate_path=plate_path, seed_asset_id=seed_asset_id, seed_attached=seed_path is not None, + authority_kind=authority_kind, ) def _find_by_rel(rel: str): return ( db.query(ImageAsset) .filter( ImageAsset.project_id == project_id, ImageAsset.file_path == rel, ) .first() @@ -2258,20 +2274,21 @@ def run_still_recipe_generation( records.save() if bgfirst_used: # BGFIRST2 (2026-07-20 사용자 확정): Step1=플레이트를 콘티 # 카메라로 재투영한 인물 0 빈 배경(gpt-image-2 — nb2 는 # 플레이트 프레이밍 고수 실측·재투영 실패) → Step2=그 # 배경+콘티(인물 배치만)+엔티티로 nb2 인물 삽입 후보(A) # vs 무콘티 기존 조립 후보(B) → VLM 2택1(블라인드·정역순 # flip, judge 공유 refs=무콘티). 기존 4택1 을 대체. from app.modules.pipeline.still_recipe import ( + LANE_CONTI_ONLY, build_ab_branch_refs, build_bgfirst_bg_prompt, build_bgfirst_final_prompt, build_bgfirst_refs, build_bgfirst_seed_clause, ) # ── 위치 권위 해석 (fix③④ full): 플레이트 → seed-bg → # groupbg(장소 단위 생성·재사용 — ③ 정류장류 연속성의 # 근본 해결 지점). 비 full=기존 플레이트 필수 그대로 ── @@ -2284,36 +2301,35 @@ def run_still_recipe_generation( if bgfirst_full_on: if structure_seed_path is not None: # complex 샷 — STRUCTURE LOOK 을 Step1 3번째 참조로 _chain_seed_path = structure_seed_path _chain_seed_aid = structure_seed_asset_id if plate is None and seed_bg_path is not None: _authority_kind = "seed_bg" _authority_path = seed_bg_path _authority_aid = seed_bg_asset_id elif plate is None and lane_chain: - # lane 샷: 콘티(마커 스케치)가 이미 **canon - # master** 를 장소 외형 권위로 보고 그려졌다 — - # 재투영도 같은 사진을 써야 콘티와 배경이 같은 - # 장소가 된다(캔ary 실증 경로와 동일). prev/ - # groupbg 로 가면 콘티가 본 장소와 어긋난다. - _mp = lane_entry.get("master_png_path") or "" - _maid = lane_entry.get("master_asset_id") - if not _mp or not Path(_mp).is_file() or not _maid: + # 2026-07-26 사용자 확정: lane 콘티는 마커 맵 + # 1장만 보고 마네킹으로 그려진다. 배경은 그 콘티 + # 자체를 i2i 로 편집해 입히고 외부 사진은 쓰지 + # 않는다(사전 배경 플레이트 금지 — 미리 만든 빈 + # 배경과 콘티 구도가 어긋나 구조물이 겹쳐 보이던 + # 실측). 장소 사실은 텍스트 권위(§4.3). + if seed_bg_path is not None: raise ValueError( - "lane 체인 샷 canon master 결손 " - f"({_mp!r}, asset={_maid!r}) — 콘티가 본 " - "장소 권위 없이 재투영 금지 (fail-closed)" + f"{LANE_CONTI_ONLY} 샷에 seed_bg 가 함께 " + "공급됨 — 단일 권위 계약 위반 " + "(fail-closed)" ) - _authority_kind = "canon_master" - _authority_path = Path(_mp) - _authority_aid = _maid + _authority_kind = LANE_CONTI_ONLY + _authority_path = None + _authority_aid = None elif ( plate is None and prev_sel is not None and lane_prev_chain_on ): # 2026-07-25 사용자 확정: prev 지휘 샷 = 직전 # 스틸이 배경 권위 — 콘티 카메라로 **재투영**해 # 구도차를 흡수한다(참조만으로는 장소가 재현되지 # 않던 실측 교정, S15sh5). _authority_kind = "prev" _authority_path = prev_sel @@ -2345,21 +2361,27 @@ def run_still_recipe_generation( ), # E2E11 ③ (NARROW-4): 장소 근거 — 그룹 안정 # 파생값(멤버 씬들의 loc 상세+그룹 evidence) (groupbg_context.get(_groupbg_key) or {}) .get("detail", ""), tuple( (groupbg_context.get(_groupbg_key) or {}) .get("evidence", ()) ), ) - if _authority_path is None or not _authority_path.is_file(): + # LANE_CONTI_ONLY 는 위치 권위 **파일**이 존재하지 않는 + # 것이 정상 계약이다(외부 사진 0) — 그 모드만 면제하고 + # 나머지 권위는 기존 fail-closed 그대로. + if _authority_kind != LANE_CONTI_ONLY and ( + _authority_path is None + or not _authority_path.is_file() + ): raise ValueError( f"BGFIRST2 샷 LOCATION 권위 결손({_authority_path}," f" kind={_authority_kind}) — 재투영 불가 " "(fail-closed)" ) # Step1 카메라 계약 — fix1 flag 와 독립(체인은 콘티 v2 와 # 같은 staging 구도 계약을 항상 소비 — 정본 동조) _bg_cam = camera_frame_en if not _bg_cam: from app.modules.pipeline.still_recipe import ( @@ -2402,56 +2424,65 @@ def run_still_recipe_generation( ) bg_path, bg_asset_id = _run_bgfirst_bg( tag, still_id, conti, _authority_path, bg_prompt, conti_entry.get("asset_id"), plate_asset_id_override=_authority_aid, seed_path=_chain_seed_path, seed_asset_id=_chain_seed_aid, authority_kind=_authority_kind, ) refs_chain = build_bgfirst_refs( - bg=bg_path, conti=conti, + bg=bg_path, conti=None if lane_chain else conti, char_refs=char_refs, prop_refs=prop_refs, ) - # 무콘티 후보(B)=기존 조립 — 위치 권위·seed 를 그대로 반영 - # (groupbg 는 plate 슬롯: LOCATION PHOTOGRAPH 라벨) - if _authority_kind == "prev": - # 2026-07-25: prev 지휘 샷은 무콘티 후보를 만들 수 - # 없다 — LOCATION 권위 슬롯에 인물이 찍힌 prev 스틸을 - # 넣으면 그 인물이 복제된다(build_ab_branch_refs 도 - # prev 호출 금지 계약). B=현행 prev 경로 그대로 두어 - # 2택1 이 "체인 vs 현행" 비교가 되게 한다. - refs_b = refs - else: - _, refs_b = build_ab_branch_refs( - plate=( - _authority_path - if _authority_kind in ( - "plate", "groupbg", "canon_master") - else None - ), - conti=conti, - char_refs=char_refs, prop_refs=prop_refs, - structure_seed=structure_seed_path, - seed_bg=( - seed_bg_path if _authority_kind == "seed_bg" - else None - ), - prompt_version=_pack, - ) - _labels2 = roll_labels(2) # 2026-07-25 사용자 확정: lane 샷은 **체인 단독**. 무콘티 # 후보는 마커 스케치를 버리므로 카메라 위치·인물 배치 # 통제가 사라진다 — 맵→마커→콘티를 거친 이유 자체가 # 무색해지고, 판정이 그쪽을 고르면 마커 단계가 무력화된다. # 복잡 구조물(케이스2)의 2택1 은 그대로 유지. + # + # lane 체인은 2택1이 없다 — B 후보를 조립할 이유가 없고, + # LANE_CONTI_ONLY 는 plate·seed_bg 가 모두 None 이라 + # build_ab_branch_refs 가 "A/B 는 LOCATION 권위 필수" + # ValueError 로 샷을 죽인다(still_recipe.py:737). 그래서 + # 판정을 B 조립보다 **앞**에 둔다. _chain_only = lane_chain + # 무콘티 후보(B)=기존 조립 — 위치 권위·seed 를 그대로 반영 + # (groupbg 는 plate 슬롯: LOCATION PHOTOGRAPH 라벨) + refs_b: Any = None + if not _chain_only: + if _authority_kind == "prev": + # 2026-07-25: prev 지휘 샷은 무콘티 후보를 만들 수 + # 없다 — LOCATION 권위 슬롯에 인물이 찍힌 prev + # 스틸을 넣으면 그 인물이 복제된다 + # (build_ab_branch_refs 도 prev 호출 금지 계약). + # B=현행 prev 경로 그대로 두어 2택1 이 "체인 vs + # 현행" 비교가 되게 한다. + refs_b = refs + else: + _, refs_b = build_ab_branch_refs( + plate=( + _authority_path + if _authority_kind in ( + "plate", "groupbg", "canon_master") + else None + ), + conti=conti, + char_refs=char_refs, prop_refs=prop_refs, + structure_seed=structure_seed_path, + seed_bg=( + seed_bg_path if _authority_kind == "seed_bg" + else None + ), + prompt_version=_pack, + ) + _labels2 = roll_labels(2) _chain_prompt = build_bgfirst_final_prompt( prompt_chain or prompt) if _chain_only: # 표준 멀티롤 — 같은 체인 프롬프트/참조로 N롤 생성 후 # 기본 판정이 최선을 고른다(후보 비교가 아니라 품질 # 선택). bgfirst 2택1 전용 judge/texts 는 2라벨 스키마라 # 여기 쓰지 않는다. _labels_n = roll_labels(roll_count) sel_path, record = _run_branch( f"still_{tag}", refs_chain, tag, recipe_dir / tag, @@ -2506,20 +2537,24 @@ def run_still_recipe_generation( # asset UUID — Codex 리뷰 2), 무콘티 승=플레이트 (거짓 # edge 방지, 위에서 유예) from app.modules.pipeline.still_recipe import ( bgfirst_winner_lineage, ) for _role in bgfirst_winner_lineage( chain_won=_chain_won, authority_kind=_authority_kind, structure_seed_attached=_chain_seed_path is not None, + # lane 체인 final 은 Step2 참조에서 콘티를 뺐다 — + # 콘티 UUID 는 중간 bgfirst_bg 의 input edge 로만 + # 남는다(conti → bgfirst_bg → final) + conti_attached=not lane_chain, ): if _role == "conti": _attach( "conti_light", "LAYOUT SKETCH", conti_entry.get("asset_id"), file_path=str(conti), ) elif _role == "bgfirst_bg": _attach( "bgfirst_bg", "SHOT BACKGROUND", diff --git a/backend/tests/unit/test_still_recipe_bgfirst.py b/backend/tests/unit/test_still_recipe_bgfirst.py index 18be6fa0..0432877f 100644 --- a/backend/tests/unit/test_still_recipe_bgfirst.py +++ b/backend/tests/unit/test_still_recipe_bgfirst.py @@ -521,10 +521,102 @@ def test_run_still_recipe_raises_on_mannequin_cp_with_lane_prev_off( run_still_recipe_generation( db=None, project_id="SAMPLE_FIXTURE_PROJECT", episode_id="SAMPLE_FIXTURE_EPISODE", stills=[], stills_orm=[], entity_lookup={}, ref_image_map={}, reference_svc=None, scene_ref_image_map={}, scene_ref_asset_id_map={}, staging_map={}, scene_cp=None, persistence_svc=None, progress=None, project_config=None, scene_dir=tmp_path, already_done_stills=set(), ) assert ei.value.code == "still_recipe.lane_mannequin_chain_off" + + +# ── lane_conti_only 권위 (2026-07-26 확정 흐름) ─────────────────────── + + +def test_lane_conti_only_input_ids_exempts_plate_only_in_that_mode(): + """외부 사진 0 체인 — 플레이트 UUID 면제는 **이 모드에서만**. + + 콘티 UUID 는 여전히 필수이고(불완전 lineage 를 성공 record 로 영속 + 금지), 다른 권위 종류는 기존 fail-closed 그대로다. + """ + from app.modules.pipeline.still_recipe import ( + LANE_CONTI_ONLY, + bgfirst_require_input_ids, + ) + + ids = bgfirst_require_input_ids( + conti_asset_id="conti-uuid", plate_asset_id=None, + plate_path=None, authority_kind=LANE_CONTI_ONLY) + assert ids == ["conti-uuid"] + + # 콘티 UUID 는 여전히 필수 + with pytest.raises(ValueError): + bgfirst_require_input_ids( + conti_asset_id=None, plate_asset_id=None, plate_path=None, + authority_kind=LANE_CONTI_ONLY) + + # 다른 kind 에서는 면제 없음 (기존 fail-closed 유지) + with pytest.raises(ValueError): + bgfirst_require_input_ids( + conti_asset_id="conti-uuid", plate_asset_id=None, + plate_path=None, authority_kind="plate") + + +def test_lane_conti_only_rejects_supplied_plate_input(): + """참조 0 계약 위반(플레이트 입력 동반)은 조용히 통과시키지 않는다 — + 권위가 두 갈래로 갈리면 콘티와 배경이 다른 장소가 된다.""" + from app.modules.pipeline.still_recipe import ( + LANE_CONTI_ONLY, + bgfirst_require_input_ids, + ) + + with pytest.raises(ValueError, match="참조 0"): + bgfirst_require_input_ids( + conti_asset_id="conti-uuid", plate_asset_id="plate-uuid", + plate_path=None, authority_kind=LANE_CONTI_ONLY) + with pytest.raises(ValueError, match="참조 0"): + bgfirst_require_input_ids( + conti_asset_id="conti-uuid", plate_asset_id=None, + plate_path="SAMPLE_FIXTURE_plate.png", + authority_kind=LANE_CONTI_ONLY) + + +def test_lane_conti_only_final_lineage_has_no_conti_edge(): + from app.modules.pipeline.still_recipe import ( + LANE_CONTI_ONLY, + bgfirst_winner_lineage, + ) + + # 확정 흐름: Step2 참조에 콘티가 없다 → final direct edge 도 없다 + assert bgfirst_winner_lineage( + chain_won=True, authority_kind=LANE_CONTI_ONLY, + structure_seed_attached=False, + conti_attached=False) == ["bgfirst_bg"] + # 콘티를 실제로 첨부하는 기존 체인은 그대로 + assert bgfirst_winner_lineage( + chain_won=True, authority_kind="plate", + structure_seed_attached=False) == ["conti", "bgfirst_bg"] + # lane_conti_only + 무콘티 승은 도달 불가 조합 → fail-closed + with pytest.raises(ValueError): + bgfirst_winner_lineage( + chain_won=False, authority_kind=LANE_CONTI_ONLY, + structure_seed_attached=False, conti_attached=False) + + +def test_bgfirst_refs_omit_sketch_slot_when_conti_is_none(tmp_path): + """lane 체인 Step2 는 콘티 슬롯을 생략한다 — 배경본이 이미 배치· + 장소를 담고 있어 중복이고 선 그림 참조는 스케치 선 잔류 위험이다. + 비-lane 호출(콘티 실재)의 참조 순서·라벨은 불변.""" + bg = tmp_path / "SAMPLE_FIXTURE_bg.png" + conti = tmp_path / "SAMPLE_FIXTURE_conti.png" + char = [("SAMPLE_FIXTURE_A", tmp_path / "SAMPLE_FIXTURE_a.png")] + + with_conti = build_bgfirst_refs( + bg=bg, conti=conti, char_refs=char, prop_refs=[]) + without = build_bgfirst_refs( + bg=bg, conti=None, char_refs=char, prop_refs=[]) + + assert len(with_conti) - len(without) == 1 + assert [p for _lb, p in without] == [bg, char[0][1]] + assert [lb for lb, _p in with_conti][0] == [ + lb for lb, _p in without][0]