# 아웃룩 단독 이미지 + T2I 변형 표시 구현 계획

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

**Goal:** 아웃룩 참조이미지를 의상/장비만 단독 생성(얼굴 없이)하고, 씬 카드에서 T2I 변형 프롬프트를 편집 가능하게 표시한다.

**Architecture:**
- 아웃룩 이미지: 기존 "인물얼굴+의상 합성" → "의상/장비 단독" (마네킹/행거 스타일)
- 씬 참조: `"character in outfit"` 1장 → `"character identity"` + `"outfit appearance"` 2장 분리 첨부
- UI: 씬 설명 아래에 T2I 변형 N-1개를 펼칠 수 있게 표시, 편집/저장 가능

**Tech Stack:** Python (FastAPI), TypeScript (React), Gemini Image API, LiteLLM Router

---

## 파일 구조

| 파일 | 역할 | 변경 |
|------|------|------|
| `prompts/_base/ref_image_prompts/<new_ver>/character_outlook_ref.md` | 아웃룩 T2I 프롬프트 템플릿 | **신규** — 의상 단독 촬영 |
| `backend/app/services/image_service.py` | 이미지 생성 오케스트레이션 | **수정** — 아웃룩 생성 시 얼굴 참조 제거, _resolve_refs 분리 |
| `backend/app/modules/pipeline/ref_image_pipeline.py` | T2I+LVM 파이프라인 | 변경 없음 (프롬프트 템플릿만 변경) |
| `backend/app/api/v1/entities.py` | stills API | **수정** — resolved_entities에 아웃룩 단독 이미지, available_composites 구조 변경 |
| `frontend/src/components/shared/SceneVariationCard.tsx` | 씬 카드 UI | **수정** — T2I 변형 표시/편집, 엔티티 행 구조 변경 |
| `frontend/src/pages/EpisodeDetail.tsx` | 에피소드 상세 | **수정** — T2I 저장 핸들러 |

---

### Task 1: 아웃룩 프롬프트 템플릿 — 의상 단독

**Files:**
- Create: `prompts/_base/ref_image_prompts/2.202603220100/character_outlook_ref.md`

- [ ] **Step 1: 새 버전 디렉토리 생성**

```bash
mkdir -p prompts/_base/ref_image_prompts/2.202603220100
```

- [ ] **Step 2: 의상 단독 프롬프트 작성**

```markdown
Isolated outfit/costume on a plain neutral background. No person, no face, no body.
Display the clothing arranged on an invisible mannequin form or laid flat, showing the full outfit structure.
Product photography style, studio lighting, clean and detailed.
Show the complete outfit including all accessories, headgear, footwear if described.
Do NOT include any human face, skin, hair, or body parts.

Outfit/costume to display:
{outlook_description}
```

- [ ] **Step 3: 커밋**

```bash
git add prompts/_base/ref_image_prompts/2.202603220100/
git commit -m "feat: 아웃룩 프롬프트를 의상 단독 촬영으로 변경 (얼굴 제거)"
```

---

### Task 2: image_service.py — 아웃룩 이미지 생성 시 얼굴 참조 제거

**Files:**
- Modify: `backend/app/services/image_service.py` (lines 746-764)

현재 코드 (lines 746-761):
```python
labeled_refs = [("Reference face image", task["char_face_bytes"])]
# ... generate_and_validate_reference(extra_references=labeled_refs)
```

- [ ] **Step 1: 아웃룩 생성 시 얼굴 참조 제거**

아웃룩 이미지 생성 시 `extra_references=None` (얼굴 참조 이미지 전달 안 함)

```python
# 기존: labeled_refs = [("Reference face image", task["char_face_bytes"])]
# 변경: 얼굴 참조 없이 의상만 단독 생성
ref_result = generate_and_validate_reference(
    gemini_client=gemini_client,
    entity_name=f"{task['char_name']} ({task['outlook_name']})",
    entity_description=task["outlook_desc"],
    entity_type="outlook",
    t2i_prompt=ref_prompt,
    output_dir=output_dir,
    extra_references=None,  # ← 얼굴 참조 제거
    style_context=style_context,
)
```

- [ ] **Step 2: ImageAsset 저장 시 entity_id를 outlook_id로 변경**

기존: `entity_id=character_id` (캐릭터 소유)
변경: `entity_id=outlook_id` (아웃룩 엔티티 자체 소유)

```python
img_asset = ImageAsset(
    id=_new_id(),
    project_id=self._project_id,
    episode_id=episode_id,
    entity_id=outlook_id,  # ← 변경: 아웃룩 엔티티에 직접 연결
    asset_type="reference",
    file_path=ref_result["file_path"],
    prompt_used=f"[outlook:{outlook_id}] {outlook_desc[:200]}",
    is_primary=1,  # ← 아웃룩 엔티티의 primary 이미지
    # ...
)
```

- [ ] **Step 3: 커밋**

---

### Task 3: image_service.py — _resolve_refs_for_prompt 분리 첨부

**Files:**
- Modify: `backend/app/services/image_service.py` (lines 1253-1301)

현재: `[[인물]+[아웃룩]]` → `"character in outfit"` 합성이미지 1장
변경: `[[인물]+[아웃룩]]` → `"character identity"` 얼굴 + `"outfit appearance"` 의상 2장

- [ ] **Step 1: _resolve_refs_for_prompt 수정**

```python
for match in _re.finditer(r'\[\[([^\]]+)\]\+\[([^\]]+)\]\]', t2i_prompt):
    char_name, outlook_name = match.group(1), match.group(2)
    char_id = next(
        (ve["id"] for ve in visible_entities
         if ve.get("name") == char_name and ve.get("entity_type") == "character"),
        None,
    )
    if not char_id:
        continue

    # 1) 인물 얼굴 참조 (항상)
    if char_id in scene_ref_image_map and char_id not in _used_ref_ids:
        labeled_refs.append(("character identity", scene_ref_image_map[char_id]))
        _used_ref_ids.add(char_id)

    # 2) 아웃룩 의상 참조 (별도)
    outlook_id = next(
        (eid for eid, info in entity_lookup.items()
         if info.get("name") == outlook_name and info.get("entity_type") == "outlook"),
        None,
    )
    if outlook_id and outlook_id in scene_ref_image_map and outlook_id not in _used_ref_ids:
        labeled_refs.append(("outfit appearance", scene_ref_image_map[outlook_id]))
        _used_ref_ids.add(outlook_id)
```

- [ ] **Step 2: scene_ref_image_map 구성에 아웃룩 단독 이미지 추가**

기존 `scene_ref_image_map`은 `character_id` → face image, `outfit:{char_id}:{outlook_id}` → composite.
변경: `outlook_id` → outlook standalone image 추가.

아웃룩 이미지는 이제 `entity_id=outlook_id, is_primary=1`로 저장되므로,
기존 primary_image_map 로직에서 자동으로 `outlook_id → image` 매핑됨.

- [ ] **Step 3: 커밋**

---

### Task 4: entities.py API — resolved_entities 아웃룩 단독 이미지 매핑

**Files:**
- Modify: `backend/app/api/v1/entities.py` (lines 364-410)

- [ ] **Step 1: 캐릭터 resolve 시 얼굴 + 아웃룩 분리**

캐릭터: `display_type="character"`, 얼굴 참조 이미지
아웃룩: `display_type="outfit"`, 의상 단독 참조 이미지
둘 다 resolved_entities에 별도 항목으로 포함

```python
if entity.entity_type == "character":
    if ename in seen_chars:
        continue
    seen_chars.add(ename)
    outlook_name = char_to_outlook.get(ename)

    # 1) 캐릭터 얼굴
    ref_img = char_face_image_map.get(eid)
    resolved_entities.append({
        "entity_id": eid,
        "entity_name": ename,
        "entity_type": "character",
        "display_name": f"{ename}+{outlook_name}" if outlook_name else ename,
        "has_reference_image": ref_img is not None,
        "reference_image_id": ref_img.id if ref_img else None,
    })

    # 2) 아웃룩 의상 (별도)
    if outlook_name:
        outlook_ent = entity_name_map.get((outlook_name, "outlook"))
        if outlook_ent:
            outfit_img = primary_image_map.get(outlook_ent.id)
            resolved_entities.append({
                "entity_id": outlook_ent.id,
                "entity_name": outlook_name,
                "entity_type": "outfit",
                "display_name": outlook_name,
                "has_reference_image": outfit_img is not None,
                "reference_image_id": outfit_img.id if outfit_img else None,
            })
```

- [ ] **Step 2: available_composites → available_outfits로 변경**

CharacterOutlook 매핑 대신 아웃룩 엔티티의 단독 이미지 존재 여부:

```python
available_outfits = []
outlook_entities = [e for e in all_project_entities if e.entity_type == "outlook"]
for ol in outlook_entities:
    outfit_img = primary_image_map.get(ol.id)
    available_outfits.append({
        "outlook_id": ol.id,
        "outlook_name": ol.name,
        "has_image": outfit_img is not None,
        "image_id": outfit_img.id if outfit_img else None,
    })
return {"stills": result, "available_outfits": available_outfits}
```

- [ ] **Step 3: 커밋**

---

### Task 5: SceneVariationCard — T2I 변형 표시/편집

**Files:**
- Modify: `frontend/src/components/shared/SceneVariationCard.tsx`

씬 설명(still_frame_prompt) 아래에 T2I 변형 N-1개를 펼칠 수 있는 아코디언으로 표시.
각 변형은 편집 가능한 텍스트 영역 + 저장 버튼.

- [ ] **Step 1: 콘텐츠 아코디언 안에 T2I 변형 섹션 추가**

씬 설명 div 바로 아래:

```tsx
{/* T2I 변형 (N-1개) — 펼치면 보임 */}
{(() => {
  let t2iVars: Array<{variant_label?: string; camera_effect?: string; t2i_prompt: string}> = []
  try { t2iVars = JSON.parse(still.t2i_variations_json || '[]') } catch {}
  if (t2iVars.length === 0) return null

  return (
    <div style={{ marginTop: '8px' }}>
      <div
        onClick={() => toggleSection('t2i_vars')}
        style={{
          display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer',
          fontSize: '10px', fontWeight: 700, color: 'var(--text-dim)',
          textTransform: 'uppercase', letterSpacing: '0.04em',
        }}
      >
        <span>{openSections.has('t2i_vars') ? '▼' : '▶'}</span>
        T2I 프롬프트 ({t2iVars.length}개)
      </div>
      {openSections.has('t2i_vars') && (
        <div style={{ marginTop: '6px', display: 'flex', flexDirection: 'column', gap: '6px' }}>
          {t2iVars.map((v, vi) => (
            <T2iVariationEditor
              key={vi}
              index={vi}
              label={v.variant_label || `var_${vi+1}`}
              cameraEffect={v.camera_effect || ''}
              prompt={v.t2i_prompt}
              onSave={(newPrompt) => handleSaveT2iVariation(vi, newPrompt)}
            />
          ))}
        </div>
      )}
    </div>
  )
})()}
```

- [ ] **Step 2: T2iVariationEditor 인라인 컴포넌트 구현**

```tsx
function T2iVariationEditor({ index, label, cameraEffect, prompt, onSave }) {
  const [editing, setEditing] = useState(false)
  const [text, setText] = useState(prompt)

  if (!editing) {
    return (
      <div
        onClick={() => { setEditing(true); setText(prompt) }}
        style={{
          fontSize: '11px', background: 'var(--bg-input)', padding: '6px 10px',
          borderRadius: 'var(--radius-sm)', border: '1px solid transparent',
          cursor: 'pointer', lineHeight: 1.5,
        }}
        onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--accent)'}
        onMouseLeave={e => e.currentTarget.style.borderColor = 'transparent'}
      >
        <div style={{ fontSize: '9px', fontWeight: 700, color: 'var(--accent)', marginBottom: 2 }}>
          {label} {cameraEffect && `— ${cameraEffect}`}
        </div>
        <div style={{ color: 'var(--text-muted)' }}>{prompt}</div>
      </div>
    )
  }

  return (
    <div>
      <div style={{ fontSize: '9px', fontWeight: 700, color: 'var(--accent)', marginBottom: 2 }}>
        {label} {cameraEffect && `— ${cameraEffect}`}
      </div>
      <textarea
        rows={3} value={text} onChange={e => setText(e.target.value)}
        style={{ width: '100%', fontSize: '11px', fontFamily: 'var(--font-mono)' }}
      />
      <div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
        <Button size="sm" onClick={() => { onSave(text); setEditing(false) }}>저장</Button>
        <Button size="sm" variant="ghost" onClick={() => setEditing(false)}>취소</Button>
      </div>
    </div>
  )
}
```

- [ ] **Step 3: EpisodeDetail.tsx에 T2I 변형 저장 핸들러 추가**

```tsx
const handleSaveT2iVariation = async (stillId: string, varIndex: number, newPrompt: string) => {
  try {
    await api(`/api/v1/projects/${id}/stills/${stillId}/t2i-variation/${varIndex}`, {
      method: 'PATCH',
      body: JSON.stringify({ t2i_prompt: newPrompt }),
    })
    await fetchStills(true)
  } catch (err: any) {
    console.error('Failed to save T2I variation:', err)
    addToast('error', 'T2I 저장 실패')
  }
}
```

- [ ] **Step 4: 백엔드 — T2I 변형 개별 수정 API**

```python
@router.patch("/stills/{still_id}/t2i-variation/{var_index}")
def update_t2i_variation(
    still_id: str, var_index: int,
    body: dict,
    project_id: str = Depends(verify_project_access),
    db: OrmSession = Depends(get_db),
    current_user: UserAccount = Depends(get_current_user),
):
    still = db.query(SceneStill).filter(SceneStill.id == still_id, SceneStill.project_id == project_id).first()
    if not still:
        raise AppError(code="still.not_found", message="씬을 찾을 수 없습니다", status_code=404)

    variations = json.loads(still.t2i_variations_json or "[]")
    if var_index < 0 or var_index >= len(variations):
        raise AppError(code="still.invalid_index", message="잘못된 변형 인덱스", status_code=400)

    variations[var_index]["t2i_prompt"] = body.get("t2i_prompt", variations[var_index]["t2i_prompt"])
    still.t2i_variations_json = json.dumps(variations, ensure_ascii=False)
    db.commit()

    return {"ok": True, "variation_index": var_index}
```

- [ ] **Step 5: 커밋**

---

### Task 6: SceneVariationCard — 엔티티 행 업데이트

**Files:**
- Modify: `frontend/src/components/shared/SceneVariationCard.tsx`
- Modify: `frontend/src/pages/EpisodeDetail.tsx`

resolved_entities가 이제 character(얼굴) + outfit(의상) 분리.
피커도 available_outfits 기반으로 변경.

- [ ] **Step 1: 엔티티 행에서 character+outfit 쌍을 하나의 그룹으로 표시**

같은 캐릭터의 얼굴+의상을 묶어서 표시:
```
[얼굴 thumb] + [의상 thumb]  캐릭터명+아웃룩명  [x]
```

- [ ] **Step 2: 피커를 available_outfits 기반으로 변경**

인물 추가 시: 인물 선택 → 아웃룩 선택 (단독 이미지 있는 것만)

- [ ] **Step 3: EpisodeDetail에서 availableComposites → availableOutfits 전달**

- [ ] **Step 4: 커밋**

---

### Task 7: 교차 리뷰

- [ ] **Step 1: 전체 변경사항 Codex 교차 리뷰**

리뷰 범위:
1. 아웃룩 프롬프트 — 의상 단독 생성 확인
2. image_service.py — 얼굴 참조 제거, _resolve_refs 분리 첨부
3. entities.py — resolved_entities 얼굴/의상 분리, available_outfits
4. SceneVariationCard — T2I 변형 표시/편집
5. 백엔드 API — T2I 변형 수정 엔드포인트
6. 참조 이미지 매칭이 씬 생성 시 올바르게 동작하는지

- [ ] **Step 2: 리뷰 피드백 수정**

- [ ] **Step 3: 커밋**
