# 데이터 계약 (Data Contracts)

> 파이프라인 내 데이터 형식, ID 체계, 체크포인트 구조, DB 테이블 관계를 정의한다.

## Active vs Legacy 구분표

| 구분 | 대상 |
|------|------|
| **Active 체크포인트** | `STEP_MANIFEST` 중 `applicability: "always"` + `if_planning_doc` + `if_has_outlooks` (Active 40 / 총 **48**). 실측 baseline: `docs/architecture/_step_manifest.generated.md` |
| **신규 체크포인트 (v0.5.x)** | `scene_camera_flow`, `scene_consistency`, `character_state_variant` |
| **Legacy 체크포인트** | `applicability: "on_demand"` 또는 `"disabled"` step의 체크포인트 — `scene_split`, `shot_cinematography`, `scene_cinematography`, `scene_dependency`, `scene_verify` 등 (정상 실행 경로에 없음, 과거 데이터만 잔존 가능) |

### Step 유형 (step_type — 도입 완료)

`step_manifest.py`에 이미 반영됨. 현재 실측 분포: `transform` 41, `asset` 5, `editorial` 2 (총 48). `projection` 유형은 `asset`/`transform`으로 흡수됨 — 별도 category로 존재하지 않음.

| step_type | 실측 count | 의미 | 예시 |
|-----------|-----------:|------|------|
| `transform` | 41 | 체크포인트 → 체크포인트 (DB 쓰기 금지) | text_cleanup, entity_extract_character, scene_detail |
| `asset` | 5 | 파일 산출물 생성 (DB + FS) | ref_image_gen, composite_image_gen, scene_image_pipeline |
| `editorial` | 2 | 타 step 체크포인트 수정 | t2i_review, scene_verify |

정확한 실측: `docs/architecture/_step_manifest.generated.md`. 참조: `docs/architecture-refactor-final/01-principles-revised.md` §원칙 4.

### 실행 경로

- **공식**: `StepRunner` (`backend/app/core/step_runner.py`) — 내부 실행 단일 경로. 내부 dispatch는 `backend/app/services/analysis_dispatch_service.py` (기존 `AnalysisService`는 폐기됨).
- **Deprecated 공개 API** (shim 상태로 잔존, W1에서 프런트 호출자 단일화 예정):
  - `POST /api/v1/projects/{pid}/episodes/{eid}/analyze` → 내부적으로 `dispatch_category_run(category="analysis")` 호출
  - `POST /api/v1/projects/{pid}/episodes/{eid}/reanalyze-scenes` → 동일
  - **현재 호출자**: `frontend/src/pages/Episodes.tsx`가 `useAnalyzeEpisode` 훅을 통해 legacy `/analyze` 사용 중. `EpisodeDetail.tsx`는 이미 `/steps/run-all`로 전환됨.
- 전환 계획: `docs/review-codex-1/11-fix-plan.md` §W1 (공개 계약 수렴).

### Frontend 데이터 계약 (Phase 5 완결 — v0.6.0)

Frontend는 `@tanstack/react-query@5.x` 기반으로 모든 서버 상태를 관리한다. **17개 Query 훅 + 7개 Mutation 파일 (27개 mutation)** 이 `frontend/src/hooks/api/`에 분리되어 있고, 각 훅은 명시적 queryKey를 사용한다.

(숫자 확인: `ls frontend/src/hooks/api/*.ts | wc -l` + `ls frontend/src/hooks/api/mutations/*.ts | wc -l`)

#### Query 훅 (17개)

| queryKey 패턴 | 사용 훅 | 용도 |
|---------------|---------|------|
| `['projects']` | `useProjectList` | 대시보드 프로젝트 목록 |
| `['project', id]` | `useProject` | 프로젝트 상세 (exact match) |
| `['project', id, 'episodes']` | `useEpisodesList` | 에피소드 목록 (analyzing 시 3초 polling + `refetchOnMount: 'always'`) |
| `['project', id, 'entities']` | `useEntities` | 프로젝트 엔티티 |
| `['project', id, 'worldGuide']` | `useWorldGuide` | 월드가이드 |
| `['project', id, 'members']` | `useMembers` | 멤버 목록 (탭 활성 시) |
| `['project', id, 'activities', perPage]` | `useActivities(projectId, perPage?)` | 활동 로그 (overview=10 / activity=전체 분리 캐싱) |
| `['project', id, 'summary']` | `useProjectSummary` | style-rules world_summary |
| `['project', id, 'planningDoc']` | `usePlanningDoc` | 기획서 상태 (404만 null, 그 외 throw) |
| `['project', id, 'still', stillId, 'images']` | `useStillImages` (useQueries 병렬) | 씬 스틸 이미지 |
| `['episode', episodeId]` | `useEpisode` | 에피소드 상세 |
| `['episode', episodeId, 'stills']` | `useStills` | 씬 스틸 목록 |
| `['episode', episodeId, 'progress']` | `useProgress` / `useEpisodesProgress` | 파이프라인 진행률 (조건부 polling) |
| `['episode', episodeId, 'genStatus']` | `useGenStatus` | 이미지 생성 상태 |
| `['episode', episodeId, 'screenplay']` | `useScreenplay` | 시나리오 전문 |
| `['episode', episodeId, 'worldRules']` | `useWorldRules` | 시각적 세계관 규칙 |

#### Mutation 파일 (7개, 27개 mutation)

- `useShotToggle` — optimistic update + per-key reverse-patch rollback
- `useCreateProject` / `useAnalyzeEpisode` (W1에서 `useRunAllSteps`로 이관 예정) / `useCreateEpisode` (120초 `AbortController` timeout)
- `useMemberMutations` — `useAddMember` / `useRemoveMember` / `useChangeMemberRole` (onSettled로 rollback + 3개 캐시 동시 invalidate: members/project/projects)
- `usePlanningDocMutations` — `useUploadPlanningDoc` / `useDeletePlanningDoc` / `useAnalyzePlanningDoc`
- `useStillMutations` (15개) — recommendVariations / generateWithVariations / editAngle / editColor / applyAngleColor / regenerateWithPrompt / generateVariation / setRepresentative / selectVariant / selectOriginal / regenerateVariant / saveVariationPrompt / saveT2iVariation / saveT2i / patchStill
  - editAngle/Color/applyAngleColor는 `variables`에 `stillId` 포함 → 해당 still만 invalidate (N개 쿼리 전체 재조회 방지)
  - per-still pending 판정: `isPendingFor(mutation, stillId)` 헬퍼

#### Polling 원칙

- **조건부**: `refetchInterval: (query) => hasAnyRunning(query.state.data) ? 3000 : false` — terminal 상태 도달 시 polling 중단
- **`refetchOnMount: 'always'`**: 5분 staleTime 하에 stale 캐시로 polling 미작동 방지 (useProgress / useEpisodesList / useEpisodesProgress)
- **`refetchIntervalInBackground: true`**: 탭 비활성 시에도 진행 상태 추적

#### 페이지별 React Query 채용 (v0.6.0)

| 페이지 | LOC | useState | 전환 방식 |
|--------|-----|----------|-----------|
| `EpisodeDetail.tsx` | **148** | **1** | Shell + 4 sub-component (Header/WorldGuide/ActionBar/Stills) |
| `ProjectDetail.tsx` | 363 | 2 | 6 query 훅 + 3 mutation 그룹 |
| `Episodes.tsx` | 268 | 5 | `useEpisodesList` + `useEpisodesProgress` (useQueries) |
| `Dashboard.tsx` | 196 | 3 | `useProjectList` + `useCreateProject` |
| `Entities.tsx` | 1,258 | — | `handleSave` 후 `invalidateQueries(['project', id, 'entities'])` (세션 D invalidate 추가) |

## Short ID 형식

파이프라인 전체에서 엔티티를 참조하는 짧은 ID 체계:

| 접두어 | 의미 | 형식 | 예시 | 부여 시점 |
|--------|------|------|------|-----------|
| `C` | Character (인물) | `C##` | C01, C02, C15 | entity_all_character |
| `O` | Outlook (아웃룩/의상) | `O##` | O01, O02, O00 | outlook_phase1 |
| `L` | Location (배경) | `L##` | L01, L02, L08 | entity_all_location |
| `P` | Prop (소품) | `P##` | P01, P02, P05 | entity_all_prop |

### 복합 ID

- **C##O##**: 인물+아웃룩 복합 ID (예: `C01O03` = C01이 O03 의상 착용)
- LLM에게 복합 ID를 직접 생성하게 하지 않음 — `outfit_assignments`에서 코드가 조합
- 이미지 생성 시 composite 참조 키로 사용

### 특수 Short ID

- **O00 (Null Outlook)**: 비인간형 캐릭터 또는 `visual_similarity=false` 변형 캐릭터. composite 이미지 불필요, base ref 직접 사용.

---

## 체크포인트 구조

### 디렉토리 레이아웃

```
projects/{project_id}/
  checkpoints/
    episodes/{episode_id}/
      text_cleanup/manifest.json
      scene_segmentation/manifest.json
      scene_save/manifest.json
      episode_summary/manifest.json
      visual_world_rules/manifest.json
      entity_character_list/manifest.json
      scene_summary/manifest.json
      beat_extract/manifest.json
      shot_extract/manifest.json
      entity_all_character/manifest.json
      ...
      scene_camera_flow/manifest.json          # 신규
      shot_staging/manifest.json
      scene_consistency/manifest.json          # 신규
      scene_detail/
        manifest.json
        manifest_YYYYMMDD_HHMMSS_pre_review.json  # t2i_review 아카이브
      shot_dependency_t2i/manifest.json
      t2i_review/manifest.json
      character_state_variant/manifest.json    # 신규
      ...
```

### manifest.json 구조

```json
{
  "status": "completed",
  "completed_count": 30,
  "applicable_count": 30,
  "failed_count": 0,
  "data": {
    // 단계별 출력 데이터
  },
  "updated_at": "2026-04-15T12:00:00Z"
}
```

| 필드 | 설명 |
|------|------|
| `status` | `pending`, `running`, `completed`, `partial`, `failed` |
| `completed_count` | 성공 처리된 항목 수 |
| `applicable_count` | 처리 대상 전체 항목 수 |
| `failed_count` | 실패한 항목 수 |
| `data` | 단계별 출력 데이터 (아래 참조) |

### status 흐름

```mermaid
stateDiagram-v2
    [*] --> pending
    pending --> running : 실행 시작
    running --> completed : 모든 항목 성공
    running --> partial : 일부 성공
    running --> failed : 전부 실패
    partial --> running : resume
    failed --> running : force
    completed --> running : force
```

---

## 단계별 출력 데이터

### text_cleanup

```json
{
  "cleaned_text": "정리된 시나리오 전문",
  "original_length": 45000,
  "cleaned_length": 43500
}
```

### scene_segmentation / scene_save

```json
{
  "segments": [
    {
      "scene_index": 1,
      "heading": "씬 1. 실내 - 사무실 - 낮",
      "start_char": 0,
      "end_char": 1200,
      "length": 1200,
      "text": "씬 원문 텍스트..."
    }
  ],
  "total_scenes": 35
}
```

### visual_world_rules

```json
{
  "rules": [
    {"rule_type": "time_period", "description": "...", "visual_guideline": "..."},
    {"rule_type": "possession", "description": "...", "visual_guideline": "..."}
  ],
  "era": "작품 세계관의 시대 배경",
  "region": "지역/국가",
  "director_notes": ["물리적 존재 판단 규칙 1", "..."],
  "t2i_context": "시각적 스타일 요약 텍스트"
}
```

### beat_extract

```json
{
  "scenes": [
    {
      "scene_index": 1,
      "beats": [
        {
          "beat_index": 1,
          "change_type": "emotional",
          "before_state": "평온한 일상",
          "after_state": "긴장과 불안",
          "description": "..."
        }
      ]
    }
  ],
  "total_beats": 120
}
```

### shot_extract

```json
{
  "scenes": [
    {
      "scene_index": 1,
      "shots": [
        {
          "shot_index": 1,
          "description": "인물A가 창밖을 바라보며 편지를 읽는 순간",
          "characters": ["인물A"],
          "based_on_beat": 1
        }
      ]
    }
  ],
  "total_shots": 250
}
```

### shot_selection

```json
{
  "scenes": [
    {
      "scene_index": 1,
      "selected_shot_indices": [1, 3, 5],
      "total_shots": 7,
      "selected_count": 3
    }
  ],
  "total_selected": 89,
  "total_shots": 250,
  "max_per_scene": 5
}
```

### entity_all_character (location/prop 동일 구조)

```json
{
  "characters": [
    {
      "name": "인물A",
      "short_id": "C01",
      "appearance_count": 25,
      "scene_count": 15
    }
  ]
}
```

### entity_relation

```json
{
  "relations": [
    {
      "base_short_id": "C01",
      "variant_short_id": "C05",
      "base_name": "인물A",
      "variant_name": "인물A (변형 상태)",
      "visual_similarity": false,
      "relation_type": "possession_variant"
    }
  ],
  "candidates_checked": 5,
  "relations_found": 2,
  "visual_similar_count": 1
}
```

### scene_director

```json
{
  "scenes": [
    {
      "scene_index": 1,
      "present_entity_ids": ["C01", "C02", "L01", "P03"],
      "primary_location": "L01",
      "entity_classifications": {
        "C01": "V", "C02": "V", "C03": "A", "L01": "V", "P03": "V"
      }
    }
  ]
}
```

### shot_director

```json
{
  "scenes": [
    {
      "scene_index": 1,
      "shots": [
        {
          "shot_index": 1,
          "visible_entity_ids": ["C01", "L01", "P03"],
          "variant_resolved": {"C05": "C01"}
        }
      ]
    }
  ]
}
```

### scene_camera_flow (신규 — order 17.05)

```json
{
  "scenes": [
    {
      "scene_index": 5,
      "flow_summary": "씬을 관통하는 카메라 흐름 요약",
      "flow_stages": [
        {
          "stage_index": 1,
          "camera_state": "medium wide from entrance, waist high",
          "focus": "공간 전체 파악",
          "transition_note": "진입 시점"
        },
        {
          "stage_index": 2,
          "camera_state": "slow push-in to medium, shoulder high",
          "focus": "인물 반응 집중",
          "transition_note": "앞 stage에서 거리만 좁힘 (delta axis = distance)"
        }
      ],
      "shot_assignments": [
        {"shot_index": 1, "stage_index": 1, "reason": "도입 넓은 구도"},
        {"shot_index": 3, "stage_index": 2, "reason": "반응 강조"}
      ]
    }
  ]
}
```

- `shot_assignments[].shot_index`의 스키마 enum은 **선택된 shot_index만** 허용.
- 단일 샷 씬이나 선택된 샷이 없는 씬은 `flow_stages: []` + `flow_summary: "선택된 샷 없음 — 플로우 없음"`으로 기록.

### outlook_phase3 (최종)

```json
{
  "outlooks": [
    {
      "short_id": "O01",
      "name": "평상복",
      "description": "...",
      "character_id": "C01"
    },
    {
      "short_id": "O00",
      "name": "Null Outlook",
      "description": "Non-human or non-outfit entity.",
      "character_id": null,
      "is_null": true
    }
  ],
  "scene_assignments": [
    {
      "scene_index": 1,
      "assignments": [
        {"character_id": "C01", "outlook_id": "O01"},
        {"character_id": "C05", "outlook_id": "O00"}
      ]
    }
  ],
  "null_outlook_chars": ["C05"]
}
```

### shot_staging

```json
{
  "shots": [
    {
      "scene_index": 1,
      "shot_index": 1,
      "camera_direction": "eye level medium shot, slightly right of center",
      "lighting_mood": "warm afternoon light from window",
      "perspective": "observer",
      "pov_character": "",
      "perception_mode": "direct",
      "key_bg_elements": [
        {"element": "창문", "state": "열린", "camera_use": "역광 소스", "orientation": "left"}
      ],
      "character_angles": [
        {
          "character": "인물A",
          "angle": "three_quarter_left",
          "body_pose": "seated, leaning forward",
          "gaze_direction_kind": "looks_at_object",
          "gaze_target_id": "P01",
          "subject_state": "alive"
        }
      ]
    }
  ]
}
```

> `character_angles[].subject_state`가 `dead`, `unconscious`, `severely_injured` 중 하나면 `character_state_variant` 단계의 입력이 됨. `gaze_direction_kind` enum: `camera` / `down` / `up` / `distant` / `closed_eyes` / `off_screen` / `looks_at_character` / `looks_at_object` — 인물/물체 대상 시 `gaze_target_id`로 등록된 short_id (`^C\d{2,3}$` for character, `^(P|B)\d{2,3}$` for object). Area #2 (2026-05-17): legacy mixed gaze field 폐기, 3 field SOT 분리.

### scene_consistency (신규 — order 19.9)

```json
{
  "scenes": [
    {
      "scene_index": 5,
      "analysis_summary": "2개 샷에 걸친 3개 고정 요소 발견",
      "fixed_elements": [
        {
          "element_type": "character_state",
          "element_id": "E01",
          "description": "motionless figure lying face down at left side of corridor",
          "applies_to_shots": [2, 4, 7],
          "character_name": "인물C"
        },
        {
          "element_type": "environment_state",
          "element_id": "E02",
          "description": "broken window with dark-red-stained fragments",
          "applies_to_shots": [2, 4, 7]
        },
        {
          "element_type": "persistent_prop",
          "element_id": "E03",
          "description": "overturned wooden chair near the doorway",
          "applies_to_shots": [2, 7]
        }
      ]
    }
  ]
}
```

- `element_type` 값:
  - `character_state`: 인물의 사망/부상 상태. `character_name`에 인물 이름.
  - `environment_state`: 환경 변경 상태 (깨진 창문, 뒤집힌 테이블 등).
  - `persistent_prop`: 씬 내내 같은 위치 유지 소품.
- `applies_to_shots`: 해당 요소가 공유되어야 할 shot_index 목록.
- 단일 샷 씬은 `fixed_elements: []`로 기록되거나 스킵.
- **안전 필터 대응**: description 내 민감 표현(혈흔/시신 등)은 촬영 세트 용어로 순화되어 저장.

### scene_detail

```json
{
  "scenes": [
    {
      "scene_index": 1,
      "_shot_index": 1,
      "t2i_variations": [
        {
          "t2i_prompt": "Photorealistic cinematic still. C01 sits at a wooden desk in three-quarter framing, gaze directed downward, [L01: traditional office interior with paper sliding doors, warm afternoon sunlight], shallow depth-of-field on her face and shoulders",
          "outfit_assignments": {"C01": "O01"},
          "theme": "contemplation"
        }
      ]
    }
  ]
}
```

- `t2i_prompt`는 v2 프롬프트 규칙에 따라:
  - 단일 순간만 (시간 연결어 금지)
  - 얼굴 보이는 인물만 C##, 뒷모습/클로즈업은 보통명사
  - 단일 시점 (전신 + 클로즈업 혼합 금지)
  - fixed_elements의 character_state는 C##에 통합 (이중 묘사 금지)

### shot_dependency / shot_dependency_t2i

```json
{
  "dependencies": [
    {
      "scene_index": 5,
      "shot_index": 3,
      "location_refs": [
        {
          "scene_index": 3,
          "shot_index": 1,
          "keep_elements": ["corridor layout", "motionless figure"],
          "ignore_elements": ["standing characters"],
          "ref_usage": "exact_background"
        }
      ],
      "character_refs": []
    }
  ]
}
```

- `ref_usage` 값 (shot_dependency_t2i만 생성):
  - `"exact_background"`: 같은 방 — 배경 그대로 사용
  - `"atmosphere_reference"`: 다른 방/층/앵글 — 분위기만 참고
- 1차 `shot_dependency`는 `ref_usage`를 생성하지 않음. 2차 `shot_dependency_t2i`가 덮어쓰며 `ref_usage`를 채움.

### character_state_variant (신규 — order 24.5)

```json
{
  "state_variants": [
    {
      "char_name": "인물C",
      "char_uuid": "uuid-of-C01",
      "state_type": "dead",
      "image_asset_id": "uuid-of-asset",
      "status": "generated"
    },
    {
      "char_name": "인물B",
      "char_uuid": "uuid-of-C02",
      "state_type": "unconscious",
      "image_asset_id": "uuid-of-asset-2",
      "status": "skipped_existing"
    }
  ]
}
```

- `state_type` 값: `dead` / `severely_injured` / `unconscious`.
- `status`: `generated` / `skipped_existing` / `failed`.
- 생성된 ImageAsset은 `asset_type="reference"`, `entity_type="character_state_variant"`, `prompt_used="[state_variant:{char_uuid}:{state_type}] {char_name}"` 프리픽스로 DB에 등록되어 `_resolve_refs_for_prompt`가 찾을 수 있도록 한다.

---

## visible_entities 포맷

visible_entities는 LLM 의존 없이 코드에서 자동 구축:

1. **씬 레벨**: `scene_director.scenes[].present_entity_ids` → `["C01", "C02", "L01", "P03"]`
2. **샷 레벨**: `shot_director.scenes[].shots[].visible_entity_ids` → `["C01", "L01"]`

scene_detail에서 shot_director 결과가 있으면 shot별 VE를 사용하고, 없으면 씬 레벨 VE를 fallback.

---

## outfit_assignments 포맷

scene_detail이 생성하는 각 t2i_variation의 인물별 아웃룩 배정:

```json
{
  "C01": "O01",
  "C02": "O03"
}
```

- LLM은 bare ID만 사용 (C01, O01)
- 코드에서 `C01` + `O01` → `C01O01`로 조합
- O00인 경우: `C05O00` → composite 없이 C05 base ref 직접 사용

---

## scene_detail → image_service 데이터 흐름

```mermaid
sequenceDiagram
    participant SD as scene_detail
    participant IS as image_service
    participant GI as Gemini Image

    SD->>IS: scenes[].t2i_variations[].t2i_prompt
    SD->>IS: scenes[].t2i_variations[].outfit_assignments

    IS->>IS: C01 + O01 → C01O01 (outfit 조합)
    IS->>IS: state_variant ref 우선 주입 (있으면)
    IS->>IS: _resolve_refs_for_prompt (composite/base ref 매칭)
    IS->>IS: _build_image_index (Image N 번호 부여)
    IS->>IS: _rewrite_t2i_with_image_refs (C##O## → Image N)
    IS->>IS: _build_final_scene_prompt (중립화된 지시문)

    IS->>GI: 최종 프롬프트 + 참조 이미지들
    GI-->>IS: 생성 이미지

    IS->>IS: 검증 + 앵글 + 최종 선택
    IS->>IS: DB 저장 (SceneStill + ImageAsset)
```

---

## DB 테이블 관계

```mermaid
erDiagram
    project_registry ||--o{ episode : has
    project_registry ||--o{ entity_canon : has
    project_registry ||--o{ scene_still : has
    project_registry ||--o{ relation_fact : has
    project_registry ||--o{ world_guide : has

    episode ||--o{ scene_still : has
    episode ||--o{ entity_episode_link : has

    entity_canon ||--o{ entity_episode_link : linked
    entity_canon ||--o{ entity_alias : has
    entity_canon ||--o{ image_asset : has
    entity_canon ||--o{ character_outlook : "as character"
    entity_canon ||--o{ character_outlook : "as outlook"

    scene_still ||--o{ image_asset : has

    relation_fact ||--o{ relation_participant : has
    relation_participant }o--|| entity_canon : references

    entity_canon {
        text id PK
        text project_id FK
        text short_id "C01/L01/P01/O01"
        text entity_type "character/location/prop/outlook"
        text name
        text description
        text t2i_prompt
        text stable_traits "JSON"
        text status
    }

    scene_still {
        text id PK
        text project_id FK
        text episode_id FK
        int still_index
        int scene_index "씬 번호 (그룹핑)"
        int shot_index "샷 번호 (씬 내)"
        text beat_title
        text still_frame_prompt
        text t2i_variations_json "JSON array"
        text visible_entities_json "JSON array"
        bool is_selected "shot_selection 결과"
        bool image_generated "이미지 생성 완료 여부"
    }

    image_asset {
        text id PK
        text project_id FK
        text episode_id FK
        text entity_id FK "nullable"
        text still_id FK "nullable"
        text asset_type "reference/composite/scene"
        text entity_type "character/location/prop/outlook/character_state_variant"
        text file_path
        text prompt_used
        text generation_model
        text variant_type
        int is_primary
        text status
    }

    character_outlook {
        text id PK
        text character_id FK "entity_canon.id (character)"
        text outlook_id FK "entity_canon.id (outlook)"
        text project_id FK
    }

    relation_fact {
        text id PK
        text project_id FK
        text relation_family
        text relation_type
        text directionality
        text temporal_scope
        text continuity_priority
    }
```

### scene_still의 신규 필드 (v0.5.0)

- `is_selected`: shot_selection이 true면 이미지 생성 대상.
- `image_generated`: 씬 이미지 생성 완료 여부.

두 필드 모두 v0.5.0에서 추가되었으며 기존 데이터는 백필되었다.

### 주요 관계

| 관계 | 설명 |
|------|------|
| EntityCanon ↔ CharacterOutlook | 인물(character)과 아웃룩(outlook)의 매핑. 1:N |
| EntityCanon ↔ ImageAsset | 엔티티별 참조/합성/state_variant 이미지. asset_type/entity_type으로 구분 |
| SceneStill ↔ ImageAsset | 씬 스틸의 최종 이미지. still_id로 연결 |
| RelationFact ↔ RelationParticipant | 변형 관계(빙의, 변신 등)의 참여자. participant_role로 base/variant 구분 |
| EntityCanon ↔ EntityEpisodeLink | 에피소드별 엔티티 존재 여부 |

### asset_type / entity_type 구분

| asset_type | entity_type | 생성 단계 | 설명 |
|------------|-------------|-----------|------|
| `reference` | `character` / `location` / `prop` / `outlook` | ref_image_gen | 엔티티 참조 이미지 |
| `composite` | (null) | composite_image_gen | 인물+아웃룩 합성 이미지 |
| `reference` | `character_state_variant` | character_state_variant | 상태 변형 (dead/unconscious). prompt_used에 `state_variant:{uuid}:{state}` 프리픽스 |
| `scene` | (null) | scene_image_pipeline | 최종 씬 이미지 |

---

## 체크포인트 → DB 동기화

이미지 단계 실행 전에 분석 체크포인트를 DB에 동기화하는 과정:

1. `entity_t2i` 체크포인트 → `EntityCanon` 테이블 UPSERT
2. `outlook_phase3` 체크포인트 → `CharacterOutlook` 테이블 UPSERT
3. `scene_detail` 체크포인트 → `SceneStill` 테이블 UPSERT (기존 still_id 보존, `is_selected`/`image_generated` 반영)
4. `entity_relation` 체크포인트 → `RelationFact` + `RelationParticipant` UPSERT
5. **v0.5.0부터**: 비선택 샷도 SceneStill row 생성 (`is_selected=false`, 맥락 보존)

> scene_still sync는 반드시 UPSERT. DELETE→INSERT는 금지 (이미지 still_id 참조 보존).

```mermaid
flowchart LR
    CP[체크포인트 JSON] --> |_sync_checkpoints_to_db| DB[(PostgreSQL)]
    DB --> |이미지 단계 시작| IS[ImageService]
    IS --> |EntityCanon 조회| DB
    IS --> |SceneStill 조회| DB
    IS --> |ImageAsset 저장| DB
```

---

## 참고: 레거시 체크포인트

아래 step들의 체크포인트가 존재할 수 있으나 현재 파이프라인은 **참조하지 않는다**. 기존 프로젝트에 잔존 데이터가 있어도 무해.

| Step | 상태 | 비고 |
|------|------|------|
| `scene_split` | `on_demand` | beat/shot로 대체 |
| `scene_cinematography` | `on_demand` | shot_staging이 대체 |
| `shot_cinematography` | `disabled` | 의존성 제거됨. `detail_steps.py`에 호환 로드 코드만 잔존(legacy path, 실제로는 scene_detail이 shot_staging을 우선 참조) |
| `scene_dependency` | `disabled` | shot_dependency가 대체. scene_detail이 fallback 경로로만 읽음 |
| `scene_verify` | `disabled` | STEP_CLASSES 등록됐지만 applicability=disabled로 실행 경로 제외 |
| `outlook_extraction` | `on_demand` | outlook_phase1/2/3 래퍼 |

파일 전체 dead code:

- `backend/app/core/steps/analysis_steps_legacy.py` — v3 잔재, STEP_CLASSES 미등록.
