# πŸ“‹ 섀계 λ¬Έμ„œ β€” P2V(무빙 ν¬μŠ€ν„°Β·ν¬μŠ€ν„° μŠ€νƒ€μΌλ§) ν¬λ ˆλ”§ 연동 μž‘μ„±μΌ: 2026-08-25 λŒ€μƒ λͺ¨λ“ˆ: `app/p2v/` (μ‹ κ·œ) --- ## 1. μš”κ΅¬μ‚¬ν•­ μš”μ•½ ### λ°°κ²½ ν”„λ‘ νŠΈμ˜ **무빙 ν¬μŠ€ν„°(F1)** 와 **ν¬μŠ€ν„° μŠ€νƒ€μΌλ§(F2)** 은 castad λ°±μ—”λ“œλ₯Ό κ±°μΉ˜μ§€ μ•Šκ³  별도 P2V μ„œλ²„(`:8010`, `o2o-ado2-poster-to-video`)λ₯Ό λΈŒλΌμš°μ €μ—μ„œ **직접 호좜**ν•œλ‹€. κ·Έ κ²°κ³Ό castad의 ν¬λ ˆλ”§ 원μž₯이 이 μš”μ²­μ„ λ³Ό 수 μ—†μ–΄ **과금이 μ „ν˜€ λ˜μ§€ μ•ŠλŠ”λ‹€.** ### κΈ°λŠ₯적 μš”κ΅¬μ‚¬ν•­ | # | λ‚΄μš© | |---|---| | FR-1 | F1Β·F2 생성 μš”μ²­ μ‹œ ν¬λ ˆλ”§μ„ **선차감**ν•˜κ³ , μ‹€νŒ¨ μ‹œ **μžλ™ ν™˜λΆˆ**ν•œλ‹€ | | FR-2 | 작의 **μ†Œμœ μž**λ₯Ό castadκ°€ κ΄€λ¦¬ν•œλ‹€ (P2V μ„œλ²„μ—λŠ” μ‚¬μš©μž κ°œλ…μ΄ μ—†μŒ) | | FR-3 | μ™„μ„± **결과물을 Azure Blob에 μ—…λ‘œλ“œ**ν•˜κ³  URL을 DB에 λ³΄κ΄€ν•œλ‹€ | | FR-4 | ν”„λ‘ νŠΈμ˜ P2V 직접 ν˜ΈμΆœμ„ castad ν”„λ‘μ‹œ 경유둜 μ „ν™˜ν•œλ‹€ | | FR-5 | νŒŒμ΄ν”„λΌμΈλ³„λ‘œ κ²°κ³Ό 메타데이터λ₯Ό 보관해 P2V 없이도 이λ ₯ μ‘°νšŒκ°€ κ°€λŠ₯ν•΄μ•Ό ν•œλ‹€ | ### λΉ„κΈ°λŠ₯적 μš”κ΅¬μ‚¬ν•­ - P2V μ„œλ²„λŠ” **단일 μ›Œμ»€** μ „μ œ β€” castadκ°€ λ™μ‹œ μš”μ²­μ„ λŠ˜λ €λ„ P2V μ²˜λ¦¬λŸ‰μ€ κ·ΈλŒ€λ‘œλ‹€ - ν¬λ ˆλ”§ 차감/ν™˜λΆˆμ€ **λ©±λ“±**이어야 ν•œλ‹€ (폴링 μ€‘λ³΅Β·μž¬μ‹œλ„Β·ν¬λž˜μ‹œ 볡ꡬ) - μ €μž‘κΆŒ: 결과물이 곡개 Blob URL에 μ˜¬λΌκ°€λ―€λ‘œ **ν…œν”Œλ¦Ώ λΌμ΄μ„ μŠ€ 등급을 기둝**ν•œλ‹€ ### λ²”μœ„ λ°– (λͺ…μ‹œμ  μ œμ™Έ) - P2V μ„œλ²„ 자체 μ½”λ“œ μˆ˜μ • (콜백/μ›Ήν›… μΆ”κ°€ λ“±) β€” castadκ°€ 폴링으둜 ν•΄κ²°ν•œλ‹€ - retry 재과금 μ •μ±… β€” **보λ₯˜**. Β§9 μ°Έμ‘° --- ## 2. 섀계 κ°œμš” ``` λΈŒλΌμš°μ € β”‚ Authorization: Bearer β–Ό castad :8000 /p2v/* β”œβ”€ 인증(get_current_user) Β· μ†Œμœ κΆŒ 검사 β”œβ”€ ν¬λ ˆλ”§ 선차감/ν™˜λΆˆ (credit_transaction 원μž₯ μž¬μ‚¬μš©) β”œβ”€ P2V API 쀑계 (httpx) └─ μ™„λ£Œ 감지 μ‹œ κ²°κ³Όλ¬Ό Blob 아카이빙 (BackgroundTasks) β”‚ X-P2V-Key β–Ό P2V :8010 (Docker, μ™ΈλΆ€ λΉ„κ³΅κ°œ) ``` ### 핡심 κ²°μ • 1. **id 체계**: ν”„λ‘ νŠΈμ—λŠ” castad 자체 id(BIGINT)만 λ…ΈμΆœν•œλ‹€. P2V 작 idλŠ” λ‚΄λΆ€ μ»¬λŸΌμ—λ§Œ λ‘”λ‹€. μ†Œμœ κΆŒ 검사가 castad id κΈ°μ€€μœΌλ‘œ λ‹¨μˆœν•΄μ§€κ³ , P2V μ‹λ³„μžκ°€ μ™ΈλΆ€λ‘œ μƒˆμ§€ μ•ŠλŠ”λ‹€. 2. **결과물은 Blob, λ‚˜λ¨Έμ§€λŠ” κ²½λŸ‰ ν”„λ‘μ‹œ**: mp4Β·κ²°κ³Ό μ΄λ―Έμ§€λŠ” Blob 곡개 URL둜 μ„œλΉ™ν•˜λ―€λ‘œ Range μš”μ²­ 슀트리밍 ν”„λ‘μ‹œκ°€ **λΆˆν•„μš”**ν•˜λ‹€. κ²€μˆ˜μš© `check_jpg`와 ν…œν”Œλ¦Ώ μΈλ„€μΌλ§Œ μž‘μ€ 이미지 ν”„λ‘μ‹œλ‘œ μ²˜λ¦¬ν•œλ‹€. 3. **`archiving` μƒνƒœ λ„μž…**: P2Vκ°€ `done`을 보고해도 Blob μ—…λ‘œλ“œ μ „κΉŒμ§€λŠ” `archiving`으둜 μ‘λ‹΅ν•œλ‹€. ν”„λ‘ νŠΈλŠ” 이λ₯Ό μ§„ν–‰ μ€‘μœΌλ‘œ μ·¨κΈ‰ν•˜λ―€λ‘œ "URL이 잠깐 λΉ„μ–΄ μžˆλŠ”" 창이 생기지 μ•ŠλŠ”λ‹€. 4. **μ„œλΉ„μŠ€λŠ” commit ν•˜μ§€ μ•ŠλŠ”λ‹€** β€” νŠΈλžœμž­μ…˜ μ†Œμœ λŠ” λΌμš°ν„°/μ›Œμ»€ (ssulbox κ·œμΉ™ μ€€μˆ˜). --- ## 3. API 섀계 `APIRouter(prefix="/p2v", tags=["P2V"])` β€” μ „ μ—”λ“œν¬μΈνŠΈ `Depends(get_current_user)`. ### F1 β€” 무빙 ν¬μŠ€ν„° | λ©”μ„œλ“œ | 경둜 | μš”μ²­ | 응닡 | ν¬λ ˆλ”§ | |---|---|---|---|---| | POST | `/p2v/f1/jobs` | multipart(`poster`, `name`) | `P2vJobCreateResponse` | **선차감** | | GET | `/p2v/f1/jobs/{job_id}` | β€” | `P2vF1StatusResponse` | β€” | | PUT | `/p2v/f1/jobs/{job_id}/narration` | `NarrationUpdateRequest` | `204` | β€” | | PUT | `/p2v/f1/jobs/{job_id}/metadata` | `MetadataUpdateRequest` | `204` | β€” | | POST | `/p2v/f1/jobs/{job_id}/approve` | `ApproveRequest?` | `202` | β€” | | POST | `/p2v/f1/jobs/{job_id}/retry` | `ApproveRequest?` | `202` | 보λ₯˜(Β§9) | | DELETE | `/p2v/f1/jobs/{job_id}` | β€” | `P2vDeleteResponse` | β€” | ### F2 β€” ν¬μŠ€ν„° μŠ€νƒ€μΌλ§ | λ©”μ„œλ“œ | 경둜 | μš”μ²­ | 응닡 | ν¬λ ˆλ”§ | |---|---|---|---|---| | GET | `/p2v/f2/templates` | β€” | `list[F2TemplateResponse]` | β€” | | POST | `/p2v/f2/templates` | multipart(`reference`, `name`) | `F2TemplateResponse` | β€” | | DELETE | `/p2v/f2/templates/{template_id}` | β€” | `P2vDeleteResponse` | β€” | | GET | `/p2v/f2/categories` | β€” | `list[F2CategoryResponse]` | β€” | | GET | `/p2v/f2/formats` | β€” | `list[F2FormatResponse]` | β€” | | GET | `/p2v/f2/upload-hint` | β€” | `F2UploadHintResponse` | β€” | | POST | `/p2v/f2/jobs` | multipart(`poster`, `template_id`, `format`) | `P2vJobCreateResponse` | **선차감** | | GET | `/p2v/f2/jobs/{job_id}` | β€” | `P2vF2StatusResponse` | β€” | ### 정적 파일 κ²½λŸ‰ ν”„λ‘μ‹œ | λ©”μ„œλ“œ | 경둜 | μš©λ„ | |---|---|---| | GET | `/p2v/files/{path:path}` | κ²€μˆ˜μš© `check_jpg`, ν…œν”Œλ¦Ώ 썸넀일 | - ν—ˆμš© prefix **ν™”μ΄νŠΈλ¦¬μŠ€νŠΈ**: `files/templates/`, `files/user_templates/`, `files/regions/` β†’ `files/render/`Β·`files/uploads/`λŠ” **차단**(결과물은 Blob으둜만 λ‚˜κ°„λ‹€) - 경둜 traversal λ°©μ–΄(`..` κ±°λΆ€), `Content-Type` 쀑계, Range 미지원(μž‘μ€ 이미지 μ „μš©) ### 곡톡 응닡 μ½”λ“œ `401` 인증 μ‹€νŒ¨ Β· `402` ν¬λ ˆλ”§ λΆ€μ‘± Β· `404` 작 μ—†μŒ/**λ‚¨μ˜ 작** Β· `503` P2V λΉ„ν™œμ„±Β·μ—°κ²° μ‹€νŒ¨ > μ†Œμœ κΆŒ μœ„λ°˜μ€ 403이 μ•„λ‹ˆλΌ **404** β€” 쑴재 μ—¬λΆ€λ₯Ό λ…ΈμΆœν•˜μ§€ μ•ŠλŠ” ssulbox 정책을 λ”°λ₯Έλ‹€. --- ## 4. 데이터 λͺ¨λΈ ### `app/p2v/models.py` νŒŒμ΄ν”„λΌμΈλ³„λ‘œ **2개 ν…Œμ΄λΈ”**. κ³Όμ •κ³Ό μ‚°μΆœλ¬Όμ΄ λ‹€λ₯΄κ³ , P2V 없이도 이λ ₯이 μ™„κ²°λ˜μ–΄μ•Ό ν•œλ‹€. #### `P2vF1Job` β†’ `p2v_video` (클래슀λͺ…은 νŒŒμ΄ν”„λΌμΈ μ½”λ“œλͺ… F1을 μœ μ§€, ν…Œμ΄λΈ”λͺ…은 μ‚°μΆœλ¬Ό μ’…λ₯˜λ₯Ό λ”°λ₯Έλ‹€) | 컬럼 | νƒ€μž… | μ„€λͺ… | |---|---|---| | `id` | BigInteger PK | ν¬λ ˆλ”§ 원μž₯ `job_ref` 액컀 | | `user_uuid` | String(36) FKβ†’user | μ†Œμœ μž | | `p2v_job_id` | String(64) UNIQUE NULL | P2V 작 id. μ„œλ²„ 호좜 성곡 ν›„ 채움 | | `name` | String(100) NULL | μ‚¬μš©μž μž…λ ₯ 행사λͺ… | | `status` | String(20) | `queued/running/awaiting_review/archiving/done/failed` | | `credit_amount` | Integer | 차감 ν¬λ ˆλ”§ (ν™˜λΆˆ κΈˆμ•‘ κ²°μ •) | | `p2v_video_url` | String(500) NULL | μ™„μ„± μ˜μƒ **Blob URL** (κ²°κ³Όλ¬Ό) | | `poster_url` | String(500) NULL | 썸넀일 Blob URL (SNS 곡유 og:image μ—­ν•  β€” `ssul_content.poster_url` κ΄€λ‘€) | | `source_image_url` | String(500) NULL | 원본 μ—…λ‘œλ“œ ν¬μŠ€ν„° Blob URL | | `event_name` / `date_text` / `place` | String | κ²€μˆ˜ ν™•μ • 메타데이터 | | `keywords` / `narration` | JSON NULL | ν‚€μ›Œλ“œΒ·λ‚˜λ ˆμ΄μ…˜ 3λ¬Έμž₯ | | `duration` | Decimal(6,2) NULL | μ˜μƒ 길이(초) | | `error` | String(1000) NULL | μ‹€νŒ¨ μ‚¬μœ  | | `archived_at` | DateTime NULL | Blob μ—…λ‘œλ“œ μ™„λ£Œ μ‹œκ° | | `created_at` / `updated_at` | DateTime | | #### `P2vF2Job` β†’ `p2v_poster` | 컬럼 | νƒ€μž… | μ„€λͺ… | |---|---|---| | `id` | BigInteger PK | ν¬λ ˆλ”§ 원μž₯ 액컀 | | `user_uuid` | String(36) FKβ†’user | μ†Œμœ μž | | `p2v_job_id` | String(64) UNIQUE NULL | P2V 작 id | | `source_video_id` | BigInteger FKβ†’p2v_video NULL | P2V `source_slug` λŒ€μ‘. 독립 작이면 NULL | | `status` | String(20) | `queued/running/archiving/done/failed` | | `credit_amount` | Integer | 차감 ν¬λ ˆλ”§ | | `template_id` | String(64) | μ‚¬μš© ν…œν”Œλ¦Ώ | | `template_name` | String(100) NULL | 이름 μŠ€λƒ…μƒ· (ν…œν”Œλ¦Ώ μ‚­μ œ λŒ€λΉ„) | | `license` | String(20) NULL | 배포 λ“±κΈ‰ β€” **μ™ΈλΆ€ 곡개 κ°€λΆ€ νŒλ‹¨μš©** | | `format` | String(16) | `poster/story/feed/square` | | `p2v_poster_url` | String(500) NULL | μŠ€νƒ€μΌ λ³€ν™˜ κ²°κ³Ό 이미지 **Blob URL** (κ²°κ³Όλ¬Ό) | | `source_image_url` | String(500) NULL | 원본 μ—…λ‘œλ“œ ν¬μŠ€ν„° Blob URL | | `error` / `archived_at` / `created_at` / `updated_at` | | F1κ³Ό 동일 | ### ν¬λ ˆλ”§ 원μž₯ β€” **μŠ€ν‚€λ§ˆ λ³€κ²½ μ—†μŒ** `credit_transaction`이 이미 `(job_type, job_ref, type)` λ©±λ“± ν‚€λ‘œ 이질적 μž‘μ—…μ„ μˆ˜μš©ν•œλ‹€. `constants.py`에 μƒμˆ˜λ§Œ μΆ”κ°€: ```python JOB_TYPE_P2V_F1: Final[str] = "p2v_f1" JOB_TYPE_P2V_F2: Final[str] = "p2v_f2" ``` νŒŒμ΄ν”„λΌμΈλ³„ 가격 차등이 μžμ—°μŠ€λŸ½κ²Œ κ°€λŠ₯ν•˜λ‹€ (F1은 Higgsfield 22ν¬λ ˆλ”§+OpenAI λ‹€μˆ˜, F2λŠ” gpt-image 1회둜 μ‹€λΉ„μš© 차이가 크닀). ### λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ `docs/database-schema/migration_2026-08-25_p2v.sql` β€” **μˆ˜λ™ μ‹€ν–‰**. 앱은 DDL을 μ‹€ν–‰ν•˜μ§€ μ•ŠλŠ”λ‹€. `app/database/session.py`의 `create_db_tables()`에 λͺ¨λΈ import + `__table__` 등둝 ν•„μš”. --- ## 5. μ„œλΉ„μŠ€ λ ˆμ΄μ–΄ ### `app/p2v/services/client.py` β€” P2V HTTP ν΄λΌμ΄μ–ΈνŠΈ - `httpx.AsyncClient` λͺ¨λ“ˆ μ‹±κΈ€ν„΄ (`app/utils/creatomate.py`의 `_shared_client` νŒ¨ν„΄) - λͺ¨λ“  μš”μ²­μ— `X-P2V-Key` μ£Όμž…, timeout/limits νŠœλ‹ - `createF2Template`은 μ„œλ²„κ°€ **λ™κΈ°λ‘œ 10초 μ•ˆνŒŽ 화풍 뢄석**을 ν•˜λ―€λ‘œ timeout β‰₯ 60s - μ—°κ²° μ‹€νŒ¨ β†’ `P2vUnavailableError`(503) ### `app/p2v/services/f1_service.py` / `f2_service.py` β€” 작 라이프사이클 **λͺ¨λ“ˆ 레벨 ν•¨μˆ˜, commit ν•˜μ§€ μ•ŠμŒ** (ssulbox `task_service` μŠ€νƒ€μΌ) ``` create_job(session, *, user_uuid, ...) -> P2vF1Job session.add(row) await session.flush() # id 확보 await deduct_credit_for_job( # ← 같은 νŠΈλžœμž­μ…˜ session, user_uuid=..., amount=..., job_type=JOB_TYPE_P2V_F1, job_ref=str(row.id), reason=...) return row # commit 은 λΌμš°ν„°κ°€ sync_from_p2v(session, row, p2v_job) -> P2vF1Job μƒνƒœΒ·λ©”νƒ€λ°μ΄ν„° 미러링. failed 둜 μ „μ΄ν•˜λ©΄ refund_credit_for_job 호좜(λ©±λ“±) fail_job(session, row, detail) # ν™˜λΆˆ + status='failed' sweep_orphans(session) # 였래된 awaiting_review/queued ν™˜λΆˆ (lifespan ν›…) ``` ### `app/p2v/services/archive_service.py` β€” Blob 아카이빙 - `blob_enabled()` κ°€λ“œ (ssulbox `blob_service` 볡제) - `AzureBlobUploader(user_uuid=..., task_id=f"p2v-f1-{id}")` β†’ κ²½λ‘œκ°€ castad μ½˜ν…μΈ μ™€ 뢄리됨 - P2Vμ—μ„œ λ°”μ΄νŠΈλ‘œ λ°›μ•„ **둜컬 파일 없이** `upload_video_bytes` / `upload_image_bytes`둜 쀑계 - 성곡 μ‹œ F1은 `p2v_video_url`/`poster_url`, F2λŠ” `p2v_poster_url` + `archived_at` 기둝, `status='done'` ### νŠΈλžœμž­μ…˜ 경계 (ssulbox κ·œμΉ™ μ€€μˆ˜) | μ‹œμ  | μ„Έμ…˜ | 이유 | |---|---|---| | 생성(POST) | `AsyncSessionLocal()` **직접** | INSERT + 선차감을 ν•œ νŠΈλžœμž­μ…˜μœΌλ‘œ λ¬ΆλŠ”λ‹€ | | κ·Έ μ™Έ λΌμš°ν„° | `Depends(get_session)` | 일반 κ·œμΉ™ | | 아카이빙(BackgroundTasks) | `BackgroundSessionLocal()` | μš”μ²­ ν’€κ³Ό 뢄리 | **생성 μˆœμ„œ β€” 이 μˆœμ„œκ°€ 핡심이닀:** ``` 1. INSERT + flush + ν¬λ ˆλ”§ 차감 ← λΆ€μ‘±ν•˜λ©΄ 402, P2V 미호좜 2. commit ← 차감 ν™•μ • 3. P2V API 호좜 (파일 μ—…λ‘œλ“œ) 4. p2v_job_id UPDATE + commit ``` ν¬λ ˆλ”§ 검사λ₯Ό P2V ν˜ΈμΆœλ³΄λ‹€ **λ¨Όμ €** ν•˜λ―€λ‘œ, μž”μ•‘μ΄ μ—†λŠ” μ‚¬μš©μžκ°€ OpenAI μ‹€λΉ„μš©μ„ νƒœμš°λŠ” 일이 μ—†λ‹€. 3μ—μ„œ μ‹€νŒ¨ν•˜λ©΄ `fail_job`으둜 μ¦‰μ‹œ ν™˜λΆˆν•œλ‹€. ### Blob 아카이빙 트리거 폴링 μ—”λ“œν¬μΈνŠΈκ°€ P2V `done`을 **처음 κ΄€μΈ‘**ν–ˆμ„ λ•Œ `BackgroundTasks`둜 μœ„μž„ν•œλ‹€. (짧은 async I/Oμ΄λ―€λ‘œ job_manager μŠ€λ ˆλ“œ 방식은 κ³Όν•˜λ‹€ β€” 선택 κΈ°μ€€ μ€€μˆ˜) 쀑볡 μ‹€ν–‰ λ°©μ§€λŠ” 낙관적 잠금: ```sql UPDATE p2v_video SET status='archiving' WHERE id=:id AND status<>'archiving' AND archived_at IS NULL ``` `rowcount == 0`이면 λ‹€λ₯Έ 폴링이 이미 μ§‘μ–΄κ°„ κ²ƒμ΄λ―€λ‘œ 쑰용히 λ„˜μ–΄κ°„λ‹€. --- ## 6. μŠ€ν‚€λ§ˆ (`app/p2v/schemas/p2v_schema.py`) Pydantic v2. μš”μ²­μ€ `str | None`, 응닡은 `Optional[...]`, μƒνƒœλŠ” `Literal`둜 μ’νžŒλ‹€. 검증 λ²”μœ„Β·κΈ°λ³Έκ°’μ€ **μŠ€ν‚€λ§ˆμ—λ§Œ** 두고 DB λͺ¨λΈμ— μ€‘λ³΅ν•˜μ§€ μ•ŠλŠ”λ‹€. ```python F1StatusLiteral = Literal["queued", "running", "awaiting_review", "archiving", "done", "failed"] F2StatusLiteral = Literal["queued", "running", "archiving", "done", "failed"] FormatLiteral = Literal["poster", "story", "feed", "square"] class P2vJobCreateResponse(BaseModel): """생성 μš”μ²­ μ ‘μˆ˜. id 둜 ν΄λ§ν•œλ‹€.""" id: int = Field(..., description="castad 작 ID (P2V μ‹λ³„μžκ°€ μ•„λ‹ˆλ‹€)") status: F1StatusLiteral = Field(..., description="μ ‘μˆ˜ 직후 μƒνƒœ") poll_interval_seconds: int = Field(..., description="ꢌμž₯ 폴링 간격(초)") class P2vF1StatusResponse(BaseModel): id: int = Field(..., description="작 ID") status: F1StatusLiteral = Field(..., description="μ§„ν–‰ μƒνƒœ") stage: Optional[str] = Field(None, description="ν˜„μž¬ μŠ€ν…Œμ΄μ§€") progress: int = Field(0, ge=0, le=100, description="μ§„ν–‰λ₯ ") narration: Optional[list[str]] = Field(None, description="λ‚˜λ ˆμ΄μ…˜ 3λ¬Έμž₯ (κ²€μˆ˜ λŒ€μƒ)") metadata: Optional[P2vMetadata] = Field(None, description="행사 메타데이터 (κ²€μˆ˜ λŒ€μƒ)") motion_elements: Optional[list[str]] = Field(None, description="μ±„νƒλœ λͺ¨μ…˜ μš”μ†Œ") p2v_video_url: Optional[str] = Field(None, description="μ™„μ„± μ˜μƒ Blob URL (done μ—μ„œλ§Œ)") poster_url: Optional[str] = Field(None, description="썸넀일 Blob URL (SNS 곡유 og:image μ—­ν• )") check_image_url: Optional[str] = Field(None, description="κ²€μˆ˜μš© μ˜μ—­λΆ„μ„ 이미지 (ν”„λ‘μ‹œ 경둜)") error: Optional[str] = Field(None, description="μ‹€νŒ¨ μ‚¬μœ ") # ν•„λ“œλͺ…을 DB 컬럼λͺ…κ³Ό 맞좰 from_attributes μžλ™ 맀핑이 κΉ¨μ§€μ§€ μ•Šκ²Œ ν•œλ‹€. model_config = {"from_attributes": True} class NarrationUpdateRequest(BaseModel): narration: list[str] = Field(..., min_length=1, max_length=5, description="μˆ˜μ •ν•œ λ‚˜λ ˆμ΄μ…˜ λ¬Έμž₯ λͺ©λ‘") class MetadataUpdateRequest(BaseModel): event_name: str = Field(..., max_length=200, description="행사λͺ…") date_text: str = Field(default="", max_length=100, description="μΌμ‹œ ν‘œκΈ°") place: str = Field(..., max_length=200, description="μž₯μ†Œ") class F2JobCreateRequest(BaseModel): """multipart μ΄λ―€λ‘œ μ‹€μ œλ‘œλŠ” Form νŒŒλΌλ―Έν„°. 검증 λ²”μœ„ λ¬Έμ„œν™”μš©.""" template_id: str = Field(..., max_length=64, description="μŠ€νƒ€μΌ ν…œν”Œλ¦Ώ id") format: FormatLiteral = Field(default="poster", description="좜λ ₯ 포맷") ``` --- ## 7. 파일 ꡬ쑰 ### μ‹ κ·œ β€” `app/p2v/` ``` app/p2v/ β”œβ”€β”€ __init__.py λͺ¨λ“ˆ docstring β”œβ”€β”€ constants.py JOB_TYPE_P2V_F1/F2, μƒνƒœ Enum, ν”„λ‘μ‹œ ν™”μ΄νŠΈλ¦¬μŠ€νŠΈ β”œβ”€β”€ exceptions.py P2vException + ν•˜μœ„ μ˜ˆμ™Έ β”œβ”€β”€ models.py P2vF1Job, P2vF2Job β”œβ”€β”€ api/routers/v1/ β”‚ β”œβ”€β”€ f1.py 무빙 ν¬μŠ€ν„° λΌμš°ν„° β”‚ β”œβ”€β”€ f2.py μŠ€νƒ€μΌλ§ λΌμš°ν„° β”‚ └── files.py κ²½λŸ‰ 정적 ν”„λ‘μ‹œ β”œβ”€β”€ schemas/p2v_schema.py μš”μ²­/응닡 DTO └── services/ β”œβ”€β”€ client.py P2V httpx μ‹±κΈ€ν„΄ β”œβ”€β”€ f1_service.py F1 작 라이프사이클 + ν¬λ ˆλ”§ β”œβ”€β”€ f2_service.py F2 작 라이프사이클 + ν¬λ ˆλ”§ └── archive_service.py Blob μ—…λ‘œλ“œ ``` (각 `__init__.py`λŠ” 빈 파일 β€” μž¬μˆ˜μΆœν•˜μ§€ μ•ŠλŠ”λ‹€) ### μˆ˜μ • | 파일 | μž‘μ—… | |---|---| | `config.py` | `P2vSettings` μΆ”κ°€ + 파일 끝 `p2v_settings = P2vSettings()` | | `app/core/exceptions.py` | `add_exception_handlers()`에 `@app.exception_handler(P2vException)` 블둝 μΆ”κ°€ | | `app/database/session.py` | `create_db_tables()`에 λͺ¨λΈ import + `__table__` 등둝 | | `app/core/common.py` | lifespan에 `sweep_orphans` ν›… (ssulbox νŒ¨ν„΄) | | `main.py` | λΌμš°ν„° import + `tags_metadata` μ—”νŠΈλ¦¬ + `if p2v_settings.P2V_ENABLED:` 쑰건뢀 등둝 | | `docs/database-schema/migration_2026-08-25_p2v.sql` | DDL (μˆ˜λ™ μ‹€ν–‰) | ### `P2vSettings` ν•„λ“œ (env_prefix λŒ€μ‹  ν•„λ“œλͺ…에 접두) ``` P2V_ENABLED bool κΈ°λ³Έ True P2V_BASE_URL str κΈ°λ³Έ http://localhost:8010 P2V_ACCESS_KEY str κΈ°λ³Έ "" (P2V μ„œλ²„ X-P2V-Key) P2V_CREDITS_PER_F1 int κΈ°λ³Έ ? ← 가격 λ―Έμ • P2V_CREDITS_PER_F2 int κΈ°λ³Έ ? ← 가격 λ―Έμ • P2V_BLOB_PREFIX str κΈ°λ³Έ "p2v" P2V_POLL_HINT_SECONDS int κΈ°λ³Έ 3 P2V_ORPHAN_TIMEOUT_HOURS int κΈ°λ³Έ 24 (κ²€μˆ˜ 방치 작 ν™˜λΆˆ κΈ°μ€€) ``` ### ν”„λ‘ νŠΈμ—”λ“œ (별도 μ €μž₯μ†Œ) | 파일 | μž‘μ—… | |---|---| | `src/utils/p2vApi.ts` | **핡심 ꡐ체 지점** β€” `P2V_URL`β†’castad `/p2v`, `p2vFetch`β†’`authenticatedFetch`, `p2vFile()` μž¬μž‘μ„±, 402 처리 μΆ”κ°€, ν‚€ μ €μž₯ ν•¨μˆ˜ 제거 | | `src/components/P2vKeyGate.tsx` | **μ‚­μ œ** (castad JWTκ°€ λŒ€μ²΄) | | `PosterCreateForm.tsx` / `StylingContent.tsx` | `P2vAuthError` λΆ„κΈ° β†’ 402 ν¬λ ˆλ”§ λΆ€μ‘± UI | | `PosterResultContent.tsx` / `PosterReviewContent.tsx` | `p2vFile` μ‚¬μš©μ²˜κ°€ Blob URLΒ·ν”„λ‘μ‹œ 경둜둜 λ°”λ€œ | | `useP2vJob.ts` | `authNeeded` 의미 λ³€κ²½ | --- ## 8. κ΅¬ν˜„ μˆœμ„œ 1. **λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ SQL μž‘μ„±** β†’ μ‚¬μš©μžκ°€ μˆ˜λ™ μ‹€ν–‰ (앱은 DDL λ―Έμ‹€ν–‰) 2. `config.py`에 `P2vSettings` + μΈμŠ€ν„΄μŠ€ 3. `app/p2v/` λΌˆλŒ€: `__init__/constants/exceptions/models` 4. `app/core/exceptions.py`에 `P2vException` ν•Έλ“€λŸ¬ 등둝 5. `app/database/session.py`에 λͺ¨λΈ 등둝 6. `services/client.py` β€” P2V 쀑계 ν΄λΌμ΄μ–ΈνŠΈ (κ°€μž₯ λ¨Όμ € 단독 검증 κ°€λŠ₯) 7. `schemas/p2v_schema.py` 8. `services/f1_service.py` / `f2_service.py` β€” ν¬λ ˆλ”§ μ°¨κ°Β·ν™˜λΆˆΒ·μƒνƒœ 미러 9. `services/archive_service.py` β€” Blob μ—…λ‘œλ“œ 10. `api/routers/v1/f2.py` β€” **F2λ₯Ό λ¨Όμ €** (단일 단계라 흐름이 λ‹¨μˆœ, 검증이 빠름) 11. `api/routers/v1/f1.py` β€” κ²€μˆ˜ 게이트 포함 12. `api/routers/v1/files.py` β€” κ²½λŸ‰ ν”„λ‘μ‹œ 13. `main.py` 등둝 + `tags_metadata` 14. `app/core/common.py` lifespan κ³ μ•„ μŠ€μœ• 15. ν”„λ‘ νŠΈ `p2vApi.ts` ꡐ체 β†’ λ‚˜λ¨Έμ§€ μ»΄ν¬λ„ŒνŠΈ > **10λ²ˆμ„ F2λΆ€ν„°** ν•˜λŠ” 이유: F1은 7단계 νŒŒμ΄ν”„λΌμΈ + κ²€μˆ˜ 게이트라 왕볡이 κΈΈλ‹€. > F2둜 "μΈμ¦β†’μ°¨κ°β†’ν”„λ‘μ‹œβ†’Blobβ†’ν™˜λΆˆ" μ „ 경둜λ₯Ό λ¨Όμ € κ΄€ν†΅μ‹œμΌœ κ²€μ¦ν•œ λ’€ F1에 μ μš©ν•œλ‹€. --- ## 9. 섀계 κ²€μˆ˜ κ²°κ³Ό ### 체크리슀트 | ν•­λͺ© | κ²°κ³Ό | |---|---| | κΈ°μ‘΄ ν”„λ‘œμ νŠΈ νŒ¨ν„΄κ³Ό 일관성 | βœ… ssulbox κ΅¬μ‘°Β·μ˜ˆμ™Έ(B계열)Β·νŠΈλžœμž­μ…˜ 경계·Blob μž¬μ‚¬μš© | | 비동기 처리 μ μ ˆμ„± | βœ… httpx AsyncClient, BackgroundTasks, BackgroundSessionLocal | | N+1 쿼리 | βœ… 단건 쑰회 μœ„μ£Ό. λͺ©λ‘μ€ 단일 `select` + `scalars().all()` | | νŠΈλžœμž­μ…˜ 경계 λͺ…ν™•μ„± | βœ… μ„œλΉ„μŠ€λŠ” commit μ•ˆ 함, μƒμ„±λ§Œ `AsyncSessionLocal()` 직접 | | μ˜ˆμ™Έ 처리 μ „λž΅ | βœ… `P2vException(message, status_code, code)` + μ „μ—­ ν•Έλ“€λŸ¬ | | ν™•μž₯μ„± | βœ… νŒŒμ΄ν”„λΌμΈ μΆ”κ°€ μ‹œ ν…Œμ΄λΈ”Β·μ„œλΉ„μŠ€λ§Œ μΆ”κ°€. 원μž₯은 무변경 | | 직관적 ꡬ쑰 | βœ… F1/F2 λΌμš°ν„° λΆ„λ¦¬λ‘œ νŒŒμ΄ν”„λΌμΈ 차이가 μ½”λ“œμ— λ“œλŸ¬λ‚¨ | | SOLID | βœ… client/service/archive μ±…μž„ 뢄리 | ### 발견된 μœ„ν—˜κ³Ό λŒ€μ‘ | # | μœ„ν—˜ | λŒ€μ‘ | |---|---|---| | R-1 | **κ²€μˆ˜ 방치 μ‹œ ν¬λ ˆλ”§ 증발** β€” μ—…λ‘œλ“œ ν›„ approve μ•ˆ ν•˜λ©΄ 선차감뢄이 μ•ˆ λŒμ•„μ˜΄ | lifespan `sweep_orphans`κ°€ `P2V_ORPHAN_TIMEOUT_HOURS` 초과 `awaiting_review` 작 ν™˜λΆˆ | | R-2 | **게이트 μ‹€νŒ¨ μ‹œ μ‹€λΉ„μš© 손싀** β€” 제λͺ© 훼손 κ²Œμ΄νŠΈλŠ” Higgsfield ν¬λ ˆλ”§μ΄ λ‚˜κ°„ **λ’€** μ‹€νŒ¨ν•œλ‹€. μœ μ € ν™˜λΆˆ μ‹œ μ‹€λΉ„μš©μ€ νšŒμ‚¬ λΆ€λ‹΄ | 정책상 수용. 손싀 좔적을 μœ„ν•΄ `error`에 μ‚¬μœ  보쑴 | | R-3 | 폴링 μ€‘λ³΅μœΌλ‘œ Blob 이쀑 μ—…λ‘œλ“œ | `status='archiving'` 낙관적 잠금 (rowcount 검사) | | R-4 | P2V 단일 μ›Œμ»€ β€” castad λ™μ‹œμ„±μ΄ λŠ˜μ–΄λ„ μ²˜λ¦¬λŸ‰ λΆˆλ³€ | `queue_size` λ₯Ό 응닡에 λ…ΈμΆœν•΄ λŒ€κΈ°μ—΄ μ•ˆλ‚΄ | | R-5 | μ‚¬μš©μž μ—…λ‘œλ“œ λ ˆνΌλŸ°μŠ€λŠ” ꢌ리 미확인 | `license` μ»¬λŸΌμ— λ“±κΈ‰ μŠ€λƒ…μƒ· 보쑴 | | R-6 | P2V μ»¨ν…Œμ΄λ„ˆ μž¬μƒμ„± μ‹œ `/app/prompts` μ†Œμ‹€ | P2V `docker-compose.yml`에 `p2v-prompts` λ³Όλ₯¨ μΆ”κ°€ ꢌμž₯ (λ²”μœ„ λ°–, 별도 쑰치) | ### λ―Έκ²° 사항 β€” **κ΅¬ν˜„ 착수 μ „ ν™•μ • ν•„μš”** | # | ν•­λͺ© | 영ν–₯ | |---|---|---| | D-1 | **F1 차감 μ‹œμ ** β€” 작 생성 vs approve(κ²€μˆ˜ 승인) | λ³Έ μ„€κ³„λŠ” ssulbox μ„ λ‘€λ₯Ό 따라 **생성 μ‹œμ **으둜 작고 R-1 μŠ€μœ•μœΌλ‘œ λ³΄μ™„ν–ˆλ‹€. approve μ‹œμ μœΌλ‘œ λ°”κΎΈλ©΄ μŠ€μœ•μ΄ λΆˆν•„μš”ν•΄μ§€λŠ” λŒ€μ‹  κ²€μˆ˜ μ „ 무료 ꡬ간이 생긴닀 | | D-2 | **retry 재과금** β€” 보λ₯˜ 쀑 | 재과금 μ‹œ `attempt INT NOT NULL DEFAULT 1` 컬럼 μΆ”κ°€ + `job_ref = "{id}:{attempt}"`. **순수 μΆ”κ°€ν˜• ALTER**라 λ‚˜μ€‘μ— 결정해도 λΉ„νŒŒκ΄΄μ . λ―Έκ²° λ™μ•ˆμ€ μž¬μ‹œλ„ λ¬΄λ£Œκ°€ κΈ°λ³Έ λ™μž‘ | | D-3 | **ν¬λ ˆλ”§ 단가** β€” `P2V_CREDITS_PER_F1/F2` | μ‹€λΉ„μš© 차이가 크닀(F1: Higgsfield 22ν¬λ ˆλ”§+OpenAI λ‹€μˆ˜ / F2: gpt-image 1회) | --- ## 10. κ΅¬ν˜„ λ…ΈνŠΈ (2026-08-25 /develop 반영) 섀계와 달라진 두 κ°€μ§€ β€” κ΅¬ν˜„ 쀑 λ°œκ²¬ν•œ 근거둜 μ‘°μ •ν–ˆλ‹€: 1. **응닡 μŠ€ν‚€λ§ˆλŠ” P2V ν˜Έν™˜ ν˜•νƒœ(artifacts λ”•μ…”λ„ˆλ¦¬)λ₯Ό μœ μ§€ν–ˆλ‹€.** Β§6 μ΄ˆμ•ˆμ˜ `p2v_video_url` 평면 ν•„λ“œ λŒ€μ‹ , ν”„λ‘ νŠΈκ°€ 이미 맞좰져 μžˆλŠ” P2V 작 JSON ν˜•νƒœ (`artifacts.video`, `stages`, `queue_size`)λ₯Ό μœ μ§€ν•΄ ν”„λ‘ νŠΈ μˆ˜μ •μ„ 3개 파일둜 μ€„μ˜€λ‹€. DB 컬럼λͺ…(Β§4)은 μ΄ˆμ•ˆλŒ€λ‘œλ‹€ β€” API ν‘œν˜„κ³Ό μ €μž₯ ν‘œν˜„μ„ λΆ„λ¦¬ν•œ 것. 2. **F1 은 μ‹€νŒ¨ κ΄€μΈ‘ μ‹œ ν™˜λΆˆν•˜μ§€ μ•ŠλŠ”λ‹€.** ν¬λ ˆλ”§ 원μž₯의 (job_type, job_ref, type) μœ λ‹ˆν¬ μ œμ•½μƒ ν™˜λΆˆ ν›„ 재차감이 λΆˆκ°€λŠ₯ν•΄, "μ‹€νŒ¨ β†’ μžλ™ ν™˜λΆˆ β†’ 무료 μž¬μ‹œλ„"κ°€ λ˜λŠ” ꡬ멍이 μžˆμ—ˆλ‹€. F1 ν™˜λΆˆ μ‹œμ μ€ **μ‚­μ œΒ·κ³ μ•„ μŠ€μœ•Β·P2V 작 μ†Œμ‹€** μ„Έ 경우둜 ν•œμ •ν•œλ‹€. F2 λŠ” μž¬μ‹œλ„ κ²½λ‘œκ°€ μ—†μœΌλ―€λ‘œ μ΄ˆμ•ˆλŒ€λ‘œ μ‹€νŒ¨ μ¦‰μ‹œ ν™˜λΆˆν•œλ‹€. ## λ‹€μŒ 단계 `/review` 둜 μ½”λ“œ 리뷰λ₯Ό μ§„ν–‰ν•œλ‹€. 배포 μ „ μˆ˜λ™ μž‘μ—…: - `migration_2026-08-25_p2v.sql` μˆ˜λ™ μ‹€ν–‰ (DDL 은 앱이 μ‹€ν–‰ν•˜μ§€ μ•ŠλŠ”λ‹€) - castad λ°±μ—”λ“œκ°€ μ»¨ν…Œμ΄λ„ˆλ‘œ λœ¨λŠ” ν™˜κ²½μ—μ„œλŠ” `.env` 에 `P2V_BASE_URL=http://host.docker.internal:8010` μ§€μ • (κΈ°λ³Έκ°’ localhost λŠ” μ»¨ν…Œμ΄λ„ˆ μžμ‹ μ„ 가리킨닀). P2V ν¬νŠΈλŠ” 호슀트 127.0.0.1 μ—λ§Œ 바인딩돼 μžˆλ‹€. - ν¬λ ˆλ”§ 단가(D-3) ν™•μ • μ‹œ `P2V_CREDITS_PER_F1/F2` μ„€μ •