# Capture Policy Matrix — 전체 이미지 영속화 SOT (2026-06-30)

목표: 파이프라인이 생성하는 **모든 이미지(중간+최종)** 를 `image_asset`에 영속화하고 lineage(연결)를 채운다. 단 **최종물 중복 insert 0**.

캡처 액션 3종 (Codex 합의 — 출력물 생애주기별):
- **INSERT**: capture sink로 `is_intermediate=true, asset_type=generated` 신규 insert. true intermediate(최종 등록 안 되는 산출물). `disposition` + `attempt_index` 메타.
- **ANNOTATE**: 이미 전용 경로로 등록되는 최종물 → 중복 금지, 기존 row에 `stage/pipeline_role/generation_call_id/input_image_ids/pipeline_metadata_json` 보강. `annotate_generated_asset()`.
- **SKIP**: 복사/썸네일/export/png_metadata 재저장 → 캡처 안 함.

`disposition` 값: `accepted | rejected | diagnostic | cache_hit_source` (신규 컬럼, 마이그 009 — `attempt_index INT` 함께).

---

## A. 최종물 — ANNOTATE (중복 insert 금지, 기존 row 보강)

| # | 산출물 | 등록 사이트(file:line) | asset_type | 신규 pipeline_role | input_image_ids (lineage source) | 비고 |
|---|---|---|---|---|---|---|
| A1 | 인물 얼굴(phase1) | reference_phase1_service.py:202 | reference | `reference_face` | [] (T2I, 입력이미지 없음) | entity_id=char. 루트 노드 |
| A2 | 아웃룩(phase2) | reference_phase2_service.py:201 | reference | `reference_outlook` | **[] (T2I, extra_references=None — Codex 확인)** | entity_id=outlook_id |
| A3 | 합성(composite) | reference_phase3_service.py:193(batch) / reference_composite_service.py:137(single) | reference | `reference_composite` | **[face_asset.id, outfit_asset.id]** (서비스가 이미 두 row 쿼리 보유: composite_service:72/85) | ★핵심 연결 |
| A4 | 상태변형 | image_steps.py:854 | reference | `character_state_variant` | [composite_or_face_asset.id] (extra_ref로 전달되는 입력 asset id) | source 채우기 |
| A5 | 씬 스틸 | scene_persistence_service.py:88 / scene_variation_service.py:312,475 | scene | `scene_still` | 기존 reference_image_ids(char/prop) 유지 + i2i면 source_image_id 이미 있음 | stage/role/call_id 보강 |
| A6 | 씬 fal 앵글 | scene_persistence_service.py:412 / scene_variation_service.py:686 | scene(variant_type=angle_fal) | `scene_angle_fal` | source_image_id 이미 있음 | role만 보강 |
| A7 | 도면 | floor_plan_render_step.py:614 / location_floor_plan_step.py:689 | floor_plan | `floor_plan` | [] (T2I) | UPSERT 경로 |
| A8 | 배경 plate | background_render_step.py:1613 | chain_bg | `background_render` | FP→bg: [floor_plan_asset.id] (entity_id=location canon 매칭) | 가능하면 FP 연결 |
| A9 | 배경 체인 노드 | background_chain_render_step.py:408,492 | chain_bg/background_chain_node | `background_chain` | 직전 노드/FP | 체인 lineage |

## B. 중간물 — INSERT (capture sink, disposition 포함)

| # | 산출물 | 생성 사이트(file:line) | 신규 pipeline_role | disposition | 현재 영속? | scope 필요 |
|---|---|---|---|---|---|---|
| B1 | gemini 생성 거부/재생성 후보 | gemini_image_client.py:313 (winner 아닌 img_bytes_N) | `{op}_candidate` | rejected/accepted | ❌ | ref/scene/state 생성부에 scope + 후보 enqueue |
| B2 | gpt-image 거부/retry 후보 | gpt_image_primitive.py:80 (sanitizer/retry 중간본) | `{role}_candidate` | rejected | ❌ | primitive에서 retry 산출 enqueue |
| B3 | i2i 변형 거부본 | gemini_i2i_editor.py:214 | `i2i_candidate` | rejected | ❌ | scene 변형부 scope |
| B4 | 카메라 다이어그램(i2i 입력) | gemini_i2i_editor.py:102 (_create_camera_diagram) | `angle_camera_diagram` | diagnostic | ❌ | i2i 경로 scope |
| B5 | 마킹된 FP(빨간원+번호) | space_set_bg.py:378 (mark_fp) | `space_set_marked_fp` | diagnostic | ❌ | space_set 경로 scope |
| B6 | dwelling zone 주석 FP | dwelling_zone_map_step.py:304 | `dwelling_zone_annotated_fp` | diagnostic | ❌ | step scope |
| B7 | registered_pose_underlay | registered_pose_guide_service.py:106 | `registered_pose_underlay` | accepted | ✅(scope됨, flag ON시) | 완료(Phase C) |
| B8 | registered_pose_guide | registered_pose_guide_service.py:319 | `registered_pose_guide` | accepted | ✅(scope됨) | 완료(Phase C) |
| B9 | zoom_continuity_crop | zoom_continuity_render_service.py:353 | `zoom_continuity_crop` | accepted | ✅(항상) | 완료(Phase C) |
| B10 | outdoor aerial/blocking/sketch | outdoor_site_layout_provider.py:497,531,718 | outdoor_* | accepted | ✅(scope됨, dormant) | 완료(Phase C) |

## C. SKIP (캡처 안 함)

- 썸네일/export(zip) 생성, png_metadata in-place 재저장, consumer write_bytes 복사본, raw 입력 이미지 복사.
- LEGACY 미호출: outdoor_birdseye_setmap(provider:640).

---

## 검증 (acceptance SQL, 테스트 SOT)

1. **중복0**: 같은 file_path/PNG가 (is_intermediate=false) AND (is_intermediate=true) 양쪽에 존재 = 0행.
2. **최종물 분리 유지**: asset_type IN (scene,reference,floor_plan,chain_bg) → is_intermediate=false 100%.
3. **lineage 채움**: reference_composite row의 input_image_ids 2개가 각각 reference_face/reference_outlook asset로 resolve.
4. **input_image_ids UUID-only**: 모든 input_image_ids 원소가 image_asset.id로 resolve(구조키 누출 0).
5. **거부본 가시화**: disposition=rejected 행이 candidate role로 존재(있을 때).
6. **(Codex) annotate/insert 경계**: `pipeline_role IS NOT NULL AND asset_type<>'generated'` 행은 전부 `is_intermediate=false` (최종물 annotate); `is_intermediate=true` 행은 전부 `asset_type='generated'` (intermediate insert). 경계 깨지면 즉시 fail.

## ★Codex Wave1 정정/threading 노트 (구현 SOT)

- **A3 composite batch** (`reference_phase3_service.py`): 현재 task에 `face_bytes/outfit_bytes`만 싣고 asset id는 버림 → **task에 `face_asset_id`/`outfit_asset_id` 추가** 후 row 생성 직후 annotate. (single은 완료)
- **A4 state_variant** (`image_steps.py`): `source_asset_id = face_asset.id if face_asset else None` 보존 → `[source_asset_id]`. ★prompt 문자열 추론 금지, 선택된 asset 객체 id만.
- **A5 scene**: i2i `source_image_id`/`dependent_scene_id` 구조 FK만 input_image_ids. char/prop refs UUID resolve 어려우면 `metadata.unresolved_inputs`에 남기고 Phase D structural edge 병행. reference_image_ids 미접촉.
- **A7/A8/A9 UPSERT**: 최종 row id 확정 직후 annotate. resume/cache-hit은 누락 컬럼만 backfill. bg FP lineage=location canon→floor_plan ImageAsset 구조키 resolve, 못 찾으면 `None`(미상)이지 `[]`(입력없음) 아님 → `metadata.unresolved_inputs`.
- helper는 non-overwrite 성격(같은 run 신규 row는 빈 컬럼이라 그대로 채움). reference_image_ids 절대 미접촉.

## Wave2a 구현 결과 (2026-06-30, Codex 합의)

토대: alembic 009 `disposition`/`attempt_index` (+idx_image_asset_disposition), sink/queue threading, model 컬럼, conftest test-DB ALTER. idempotent up/down 검증.

| # | 산출물 | 처리 | disposition | scope 위치 | 비고 |
|---|---|---|---|---|---|
| B1 | severe-regeneration 탈락 후보 | INSERT | rejected | scene_image_pipeline `_capture_regeneration_loser`(비교 결정점, loser만) | role=`scene_regeneration_candidate`, candidate_index=1/attempt_index=1. winner는 caller 최종등록(중복0) |
| B4 | 카메라 다이어그램 | INSERT | diagnostic | scene_variation_service `_generate_variation_i2i`(diagram-only narrow scope) | edit_angle 내부 재생성분은 scope 밖→i2i_edit 출력 미캡처(중복0). ★diagram-only capture 는 second deterministic PIL render 사용 — 향후 최적화로 edit_angle 에 precomputed diagram 주입 가능(현재는 scope 좁힘 우선, byte-identical 리스크 회피) |
| B5 | 마킹된 FP | INSERT | diagnostic | space_set_bg_step:335 (mark_fp 한 줄만) | 바로 아래 i2i(339) 최종 plate는 scope 밖 |
| B6 | dwelling zone 주석 FP | INSERT | diagnostic | dwelling_zone_map_step:400 (_annotate_fp 한 줄만) | verification-only |
| B3 | i2i 변형 거부본 | **moot/dead** | — | — | generate_i2i_variants 호출자 0 (v4 dead path) — 구현 안 함 |
| B2 | gpt-image retry 중간본 | defer | — | — | moderation-block=바이트無→skip(Codex 합의). 실패는 image_asset row 아님 |

가드(Codex): scope 열기 전 항상 "이 scope 안에서 최종 등록 output 이 나오지 않는가" 확인. moderation/refusal/block(바이트無)은 image_asset insert 안 함(추후 log/event node).

## 구현 순서 (Wave)

- Wave1 = A1~A9 ANNOTATE (최종물 lineage, 캔버스 체감 최대, 마이그 불필요 — 컬럼 존재). ✅
- Wave2a = B1/B4/B5/B6 INSERT + 토대(마이그 009). ✅ (B3 moot, B2 defer)
- Wave3 = 캔버스(graph service)가 input_image_ids 엣지 + pipeline_role 색/필터 + disposition 표시.
- Wave4 = 재실행 E2E + 검증 SQL + 갤러리 육안 + Codex + 커밋.
- Wave5 = 실내 일반 마네킹 신규 빌드(별도 feature, default-deny 게이트).
