430 lines
15 KiB
Python
430 lines
15 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""P2V F1(무빙 포스터) 프록시 라우터.
|
|
|
|
생성 → 폴링 → (검수: 나레이션/메타 수정 → 승인) → 폴링 → done 의 2단 게이트.
|
|
크레딧은 생성 시점 선차감. 실패 시 즉시 환불하지 않는다 — 재시도 버튼이 있어
|
|
"환불 + 공짜 재시도"가 되기 때문이다. 환불은 삭제·고아 스윕에서 일어난다
|
|
(f1_service 모듈 docstring 참조).
|
|
"""
|
|
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, UploadFile
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.credit.exceptions import InsufficientCreditError
|
|
from app.database.session import AsyncSessionLocal, get_session
|
|
from app.p2v.exceptions import (
|
|
P2vDisabledError,
|
|
P2vException,
|
|
P2vInvalidStateError,
|
|
P2vUploadTooLargeError,
|
|
P2vUpstreamError,
|
|
)
|
|
from app.p2v.models import P2vF1Job
|
|
from app.p2v.schemas.p2v_schema import (
|
|
ApproveRequest,
|
|
MetadataUpdateRequest,
|
|
NarrationUpdateRequest,
|
|
P2vDeleteResponse,
|
|
P2vF1StatusResponse,
|
|
P2vJobCreateResponse,
|
|
P2vJobError,
|
|
P2vMetadata,
|
|
)
|
|
from app.p2v.services import archive_service, client, f1_service
|
|
from app.user.dependencies.auth import get_current_user
|
|
from app.user.models import User
|
|
from app.utils.logger import get_logger
|
|
from config import p2v_settings
|
|
|
|
logger = get_logger("p2v")
|
|
|
|
router = APIRouter(prefix="/p2v/f1", tags=["P2V"])
|
|
|
|
|
|
def _guard_enabled() -> None:
|
|
if not p2v_settings.P2V_ENABLED:
|
|
raise P2vDisabledError()
|
|
|
|
|
|
@router.post(
|
|
"/jobs",
|
|
response_model=P2vJobCreateResponse,
|
|
summary="무빙 포스터 생성 요청",
|
|
description="**이 시점에 크레딧이 선차감됩니다.** 검수 승인 없이 방치하면 "
|
|
"일정 시간 후 자동 환불·실패 처리됩니다.",
|
|
responses={
|
|
401: {"description": "인증 실패"},
|
|
402: {"description": "크레딧 부족"},
|
|
413: {"description": "용량 초과"},
|
|
503: {"description": "P2V 서버 연결 실패"},
|
|
},
|
|
)
|
|
async def create_job(
|
|
background_tasks: BackgroundTasks,
|
|
poster: UploadFile = File(...),
|
|
name: str = Form(""),
|
|
current_user: User = Depends(get_current_user),
|
|
) -> P2vJobCreateResponse:
|
|
_guard_enabled()
|
|
raw = await poster.read()
|
|
if len(raw) > p2v_settings.P2V_MAX_UPLOAD_BYTES:
|
|
raise P2vUploadTooLargeError(p2v_settings.P2V_MAX_UPLOAD_BYTES)
|
|
|
|
# 행 삽입 + 크레딧 선차감을 한 트랜잭션으로 묶는다 (ssulbox create_ssul 패턴)
|
|
async with AsyncSessionLocal() as session:
|
|
try:
|
|
row = await f1_service.create_job(
|
|
session, user_uuid=current_user.user_uuid, name=name.strip()
|
|
)
|
|
await session.commit()
|
|
job_id = row.id
|
|
except InsufficientCreditError:
|
|
await session.rollback()
|
|
logger.info(f"[f1] INSUFFICIENT CREDIT user={current_user.user_uuid}")
|
|
raise
|
|
except Exception:
|
|
await session.rollback()
|
|
raise
|
|
|
|
# 차감이 확정된 뒤에야 P2V 를 부른다
|
|
try:
|
|
created = await client.request_json(
|
|
"POST",
|
|
"/api/f1/jobs",
|
|
data={"name": name},
|
|
files={
|
|
"poster": (
|
|
poster.filename or "poster.png",
|
|
raw,
|
|
poster.content_type or "image/png",
|
|
)
|
|
},
|
|
)
|
|
except P2vException as e:
|
|
# P2V 를 태우지 못했다(실비용 없음) — 환불하고 실패로 남긴다
|
|
async with AsyncSessionLocal() as session:
|
|
failed_row = await session.get(P2vF1Job, job_id)
|
|
if failed_row is not None:
|
|
await f1_service.fail_job(
|
|
session, failed_row, f"P2V 요청 실패: {e.message}", refund=True
|
|
)
|
|
await session.commit()
|
|
raise
|
|
|
|
async with AsyncSessionLocal() as session:
|
|
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
|
|
row.p2v_job_id = str(created["id"])
|
|
await session.commit()
|
|
|
|
background_tasks.add_task(
|
|
archive_service.upload_source_image,
|
|
"f1",
|
|
job_id,
|
|
current_user.user_uuid,
|
|
raw,
|
|
poster.filename or "poster.png",
|
|
)
|
|
return P2vJobCreateResponse(
|
|
id=job_id,
|
|
status="queued",
|
|
poll_interval_seconds=p2v_settings.P2V_POLL_HINT_SECONDS,
|
|
)
|
|
|
|
|
|
def _error_of(row: P2vF1Job) -> Optional[P2vJobError]:
|
|
if row.status != "failed" or not row.error:
|
|
return None
|
|
stage, _, detail = row.error.partition(": ")
|
|
return P2vJobError(stage=stage or "?", detail=detail or row.error)
|
|
|
|
|
|
def _metadata_of(row: P2vF1Job) -> Optional[P2vMetadata]:
|
|
if not (row.event_name or row.place or row.date_text):
|
|
return None
|
|
return P2vMetadata(
|
|
event_name=row.event_name or "",
|
|
date_text=row.date_text or "",
|
|
place=row.place or "",
|
|
keywords=row.keywords,
|
|
)
|
|
|
|
|
|
def _db_response(row: P2vF1Job) -> P2vF1StatusResponse:
|
|
"""P2V 를 부르지 않고 castad DB 미러만으로 응답 (archived/failed 종결 상태)."""
|
|
artifacts: dict[str, str] = {}
|
|
if row.p2v_video_url:
|
|
artifacts["video"] = row.p2v_video_url
|
|
if row.poster_url:
|
|
artifacts["thumbnail"] = row.poster_url
|
|
return P2vF1StatusResponse(
|
|
id=row.id,
|
|
status=row.status, # type: ignore[arg-type]
|
|
name=row.name,
|
|
narration=row.narration,
|
|
metadata=_metadata_of(row),
|
|
duration=float(row.duration) if row.duration is not None else None,
|
|
error=_error_of(row),
|
|
artifacts=artifacts,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/jobs/{job_id}",
|
|
response_model=P2vF1StatusResponse,
|
|
summary="생성 진행 상태 (폴링)",
|
|
responses={401: {"description": "인증 실패"}, 404: {"description": "잡 없음"}},
|
|
)
|
|
async def get_job(
|
|
job_id: int,
|
|
background_tasks: BackgroundTasks,
|
|
current_user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> P2vF1StatusResponse:
|
|
_guard_enabled()
|
|
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
|
|
|
|
# 종결 상태 — P2V 를 부르지 않는다 (컨테이너 재기동으로 잡이 지워졌을 수 있다)
|
|
if row.archived_at is not None or row.status == "failed" or not row.p2v_job_id:
|
|
return _db_response(row)
|
|
|
|
try:
|
|
p2v_job = await client.request_json("GET", f"/api/f1/jobs/{row.p2v_job_id}")
|
|
except P2vUpstreamError as e:
|
|
if e.status_code == 404:
|
|
# P2V 재기동으로 잡 소실 — 재시도가 불가능하므로 환불하고 실패 처리
|
|
await f1_service.fail_job(
|
|
session,
|
|
row,
|
|
"서버 재시작으로 작업이 유실되었습니다. 크레딧을 환불했습니다",
|
|
refund=True,
|
|
)
|
|
await session.commit()
|
|
return _db_response(row)
|
|
raise
|
|
|
|
f1_service.sync_from_p2v(row, p2v_job)
|
|
|
|
status = row.status
|
|
if p2v_job.get("status") == "done" and row.archived_at is None:
|
|
# Blob 업로드 전까지는 진행 중으로 보인다 — URL 없는 done 창을 만들지 않는다
|
|
status = "archiving"
|
|
background_tasks.add_task(archive_service.archive_f1, row.id)
|
|
await session.commit()
|
|
|
|
# 검수용 영역분석 이미지는 프록시 경로로 재작성한다 (regions/ 는 인증 프록시)
|
|
artifacts: dict[str, str] = {}
|
|
check = (p2v_job.get("artifacts") or {}).get("check_jpg")
|
|
if check and check.startswith("/files/"):
|
|
artifacts["check_jpg"] = "/p2v" + check
|
|
|
|
return P2vF1StatusResponse(
|
|
id=row.id,
|
|
status=status, # type: ignore[arg-type]
|
|
stage=p2v_job.get("stage"),
|
|
stages=p2v_job.get("stages"),
|
|
name=row.name,
|
|
narration=row.narration,
|
|
metadata=_metadata_of(row),
|
|
motion_elements=p2v_job.get("motion_elements"),
|
|
duration=float(row.duration) if row.duration is not None else None,
|
|
queue_size=p2v_job.get("queue_size"),
|
|
error=_error_of(row),
|
|
artifacts=artifacts,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# 검수 게이트
|
|
# =============================================================================
|
|
|
|
|
|
@router.put(
|
|
"/jobs/{job_id}/narration",
|
|
summary="검수 중 나레이션 수정",
|
|
responses={401: {"description": "인증 실패"}, 404: {"description": "잡 없음"}},
|
|
)
|
|
async def update_narration(
|
|
job_id: int,
|
|
body: NarrationUpdateRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> dict:
|
|
_guard_enabled()
|
|
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
|
|
if not row.p2v_job_id:
|
|
raise P2vInvalidStateError("아직 서버에 접수되지 않은 작업입니다")
|
|
await client.request_json(
|
|
"PUT",
|
|
f"/api/f1/jobs/{row.p2v_job_id}/narration",
|
|
json_body={"narration": body.narration},
|
|
)
|
|
row.narration = body.narration
|
|
await session.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.put(
|
|
"/jobs/{job_id}/metadata",
|
|
summary="검수 중 행사 메타데이터 수정",
|
|
responses={401: {"description": "인증 실패"}, 404: {"description": "잡 없음"}},
|
|
)
|
|
async def update_metadata(
|
|
job_id: int,
|
|
body: MetadataUpdateRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> dict:
|
|
_guard_enabled()
|
|
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
|
|
if not row.p2v_job_id:
|
|
raise P2vInvalidStateError("아직 서버에 접수되지 않은 작업입니다")
|
|
await client.request_json(
|
|
"PUT",
|
|
f"/api/f1/jobs/{row.p2v_job_id}/metadata",
|
|
json_body=body.model_dump(),
|
|
)
|
|
row.event_name = body.event_name[:200]
|
|
row.date_text = body.date_text[:100] or None
|
|
row.place = body.place[:200]
|
|
await session.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post(
|
|
"/jobs/{job_id}/approve",
|
|
summary="검수 승인 — TTS·BGM·애니메이션·렌더 시작",
|
|
responses={401: {"description": "인증 실패"}, 404: {"description": "잡 없음"}},
|
|
)
|
|
async def approve_job(
|
|
job_id: int,
|
|
body: Optional[ApproveRequest] = None,
|
|
current_user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> dict:
|
|
_guard_enabled()
|
|
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
|
|
if not row.p2v_job_id:
|
|
raise P2vInvalidStateError("아직 서버에 접수되지 않은 작업입니다")
|
|
json_body = (
|
|
{"motions": body.motions} if body is not None and body.motions is not None else None
|
|
)
|
|
await client.request_json(
|
|
"POST", f"/api/f1/jobs/{row.p2v_job_id}/approve", json_body=json_body
|
|
)
|
|
row.status = "running"
|
|
await session.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post(
|
|
"/jobs/{job_id}/retry",
|
|
summary="실패한 스테이지부터 재시도",
|
|
description="선차감분이 그대로 유효하므로 재시도에 크레딧이 추가 차감되지 않는다. "
|
|
"(재과금 정책은 D-2 로 보류 — 도입 시 attempt 컬럼 추가)",
|
|
responses={
|
|
401: {"description": "인증 실패"},
|
|
404: {"description": "잡 없음"},
|
|
409: {"description": "실패 상태가 아님"},
|
|
},
|
|
)
|
|
async def retry_job(
|
|
job_id: int,
|
|
body: Optional[ApproveRequest] = None,
|
|
current_user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> dict:
|
|
_guard_enabled()
|
|
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
|
|
if row.status != "failed":
|
|
raise P2vInvalidStateError(f"실패 상태가 아닙니다: {row.status}")
|
|
if not row.p2v_job_id:
|
|
raise P2vInvalidStateError("서버에 접수되지 못한 작업은 재시도할 수 없습니다")
|
|
json_body = (
|
|
{"motions": body.motions} if body is not None and body.motions is not None else None
|
|
)
|
|
await client.request_json(
|
|
"POST", f"/api/f1/jobs/{row.p2v_job_id}/retry", json_body=json_body
|
|
)
|
|
row.status = "queued"
|
|
row.error = None
|
|
await session.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post(
|
|
"/jobs/{job_id}/force",
|
|
summary="실패를 무시하고 진행 (i2v 게이트 실패 전용)",
|
|
description="제목 훼손 게이트 등 클립 생성 **이후의** 실패를 사람이 확인하고 "
|
|
"이미 만들어진 클립으로 렌더를 잇는다. 재시도와 달리 Higgsfield 재생성이 없다. "
|
|
"클립이 없는 실패는 P2V 가 409 로 거절한다.",
|
|
responses={
|
|
401: {"description": "인증 실패"},
|
|
404: {"description": "잡 없음"},
|
|
409: {"description": "실패 상태가 아니거나 무시할 수 없는 실패"},
|
|
},
|
|
)
|
|
async def force_job(
|
|
job_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> dict:
|
|
_guard_enabled()
|
|
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
|
|
if row.status != "failed":
|
|
raise P2vInvalidStateError(f"실패 상태가 아닙니다: {row.status}")
|
|
if not row.p2v_job_id:
|
|
raise P2vInvalidStateError("서버에 접수되지 못한 작업은 진행할 수 없습니다")
|
|
await client.request_json("POST", f"/api/f1/jobs/{row.p2v_job_id}/force")
|
|
row.status = "queued"
|
|
row.error = None
|
|
await session.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.delete(
|
|
"/jobs/{job_id}",
|
|
response_model=P2vDeleteResponse,
|
|
summary="잡과 산출물 삭제 — 되돌릴 수 없다",
|
|
description="완성(done) 전에 버리는 잡은 이 시점에 크레딧이 환불된다.",
|
|
responses={401: {"description": "인증 실패"}, 404: {"description": "잡 없음"}},
|
|
)
|
|
async def delete_job(
|
|
job_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> P2vDeleteResponse:
|
|
_guard_enabled()
|
|
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
|
|
|
|
# P2V 쪽 산출물 먼저 정리. 이미 사라진 잡(404)은 정상 — 지울 게 없다.
|
|
if row.p2v_job_id:
|
|
try:
|
|
await client.request_json("DELETE", f"/api/f1/jobs/{row.p2v_job_id}")
|
|
except P2vUpstreamError as e:
|
|
if e.status_code != 404:
|
|
raise # 진행 중(409) 등은 그대로 중계 — 반쪽 삭제를 만들지 않는다
|
|
|
|
# 환불 판정은 잠금 재조회로 한다 (2026-08-26 리뷰 TOCTOU 반영) —
|
|
# 처음 읽은 스냅샷과 달리, 그 사이 백그라운드 아카이빙이 done 으로 전이를
|
|
# 커밋했을 수 있다. FOR UPDATE 로 최신 상태를 고정한 뒤 판정해야
|
|
# "완성본도 받고 환불도 받는" 창이 닫힌다.
|
|
result = await session.execute(
|
|
select(P2vF1Job)
|
|
.where(P2vF1Job.id == job_id)
|
|
.with_for_update()
|
|
)
|
|
row = result.scalar_one_or_none()
|
|
if row is None:
|
|
return P2vDeleteResponse(id=job_id, removed=True)
|
|
|
|
# done(완성)·archiving(렌더 완료, 업로드 중)은 결과물이 이미 만들어졌다 — 환불 없음
|
|
if row.archived_at is None and row.status not in ("done", "archiving"):
|
|
await f1_service.refund_job(session, row, reason="무빙 포스터 삭제(미완성)")
|
|
|
|
await session.delete(row)
|
|
await session.commit()
|
|
return P2vDeleteResponse(id=job_id, removed=True)
|