22 KiB
📋 설계 문서 — 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 JWT>
▼
castad :8000 /p2v/*
├─ 인증(get_current_user) · 소유권 검사
├─ 크레딧 선차감/환불 (credit_transaction 원장 재사용)
├─ P2V API 중계 (httpx)
└─ 완료 감지 시 결과물 Blob 아카이빙 (BackgroundTasks)
│ X-P2V-Key
▼
P2V :8010 (Docker, 외부 비공개)
핵심 결정
- id 체계: 프론트에는 castad 자체 id(BIGINT)만 노출한다. P2V 잡 id는 내부 컬럼에만 둔다. 소유권 검사가 castad id 기준으로 단순해지고, P2V 식별자가 외부로 새지 않는다.
- 결과물은 Blob, 나머지는 경량 프록시: mp4·결과 이미지는 Blob 공개 URL로 서빙하므로
Range 요청 스트리밍 프록시가 불필요하다. 검수용
check_jpg와 템플릿 썸네일만 작은 이미지 프록시로 처리한다. archiving상태 도입: P2V가done을 보고해도 Blob 업로드 전까지는archiving으로 응답한다. 프론트는 이를 진행 중으로 취급하므로 "URL이 잠깐 비어 있는" 창이 생기지 않는다.- 서비스는 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에 상수만 추가:
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()가드 (ssulboxblob_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 스레드 방식은 과하다 — 선택 기준 준수)
중복 실행 방지는 낙관적 잠금:
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 모델에 중복하지 않는다.
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. 구현 순서
- 마이그레이션 SQL 작성 → 사용자가 수동 실행 (앱은 DDL 미실행)
config.py에P2vSettings+ 인스턴스app/p2v/뼈대:__init__/constants/exceptions/modelsapp/core/exceptions.py에P2vException핸들러 등록app/database/session.py에 모델 등록services/client.py— P2V 중계 클라이언트 (가장 먼저 단독 검증 가능)schemas/p2v_schema.pyservices/f1_service.py/f2_service.py— 크레딧 차감·환불·상태 미러services/archive_service.py— Blob 업로드api/routers/v1/f2.py— F2를 먼저 (단일 단계라 흐름이 단순, 검증이 빠름)api/routers/v1/f1.py— 검수 게이트 포함api/routers/v1/files.py— 경량 프록시main.py등록 +tags_metadataapp/core/common.pylifespan 고아 스윕- 프론트
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 반영)
설계와 달라진 두 가지 — 구현 중 발견한 근거로 조정했다:
- 응답 스키마는 P2V 호환 형태(artifacts 딕셔너리)를 유지했다. §6 초안의
p2v_video_url평면 필드 대신, 프론트가 이미 맞춰져 있는 P2V 잡 JSON 형태 (artifacts.video,stages,queue_size)를 유지해 프론트 수정을 3개 파일로 줄였다. DB 컬럼명(§4)은 초안대로다 — API 표현과 저장 표현을 분리한 것. - 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설정