playreel/backend/routers/playreel.py
2026-09-08 15:57:35 +09:00

163 lines
6.3 KiB
Python

"""/api/playreel — 공연 상품페이지 → 30초 롱컷"""
import re
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.pipeline_state import PlayreelState
from pipelines import playreel
from pipelines.gate import (REVERSIBLE_GATES, PlayreelGate, apply_playreel_edits,
previous_gate, resume_stage)
from pipelines.runner import AWAITING_REVIEW, DONE, FAILED, QUEUED, RUNNING, reset_from
from routers.view import playreel_view
from tables.task import PlayreelTask
from utils.database import get_session
router = APIRouter(prefix="/api/playreel", tags=["playreel"])
# 프론트 parseGoodsId와 같은 규칙. 프론트 검사는 입력 도중의 안내이고 판정은 여기서 한다
GOODS_ID_PATTERNS = (
r"tickets\.interpark\.com/goods/(\d{5,})",
r"nol\.interpark\.com/[^?]*?(\d{8,})",
r"[?&]goodsCode=(\d{5,})",
r"^(\d{8,})$",
)
SLUG_PATTERN = re.compile(r"[a-z0-9_]+")
def parse_goods_id(url: str) -> str | None:
for pattern in GOODS_ID_PATTERNS:
found = re.search(pattern, (url or "").strip(), re.I)
if found:
return found.group(1)
return None
class CreateBody(BaseModel):
url: str
# 하이브리드 합성이 작품별 좌표를 요구한다. 검증된 작품을 다시 돌릴 때 지정한다
slug: str | None = None
class ApproveBody(BaseModel):
gate: str | None = None
edits: dict | None = None
async def find_task(session: AsyncSession, task_id: str) -> PlayreelTask:
task = await session.get(PlayreelTask, task_id)
if task is None:
raise HTTPException(404, "playreel 잡을 찾을 수 없습니다")
return task
@router.post("/jobs")
async def create(body: CreateBody, session: AsyncSession = Depends(get_session)):
goods_id = parse_goods_id(body.url)
if not goods_id:
raise HTTPException(400, "지원하지 않는 주소입니다. 공연 상품페이지 주소를 넣어 주세요.")
slug = (body.slug or "").strip() or f"g{goods_id}"
if not SLUG_PATTERN.fullmatch(slug):
raise HTTPException(400, "slug는 영문 소문자·숫자·밑줄만 씁니다")
task = await playreel.create_task(session, body.url.strip(), goods_id, slug)
return {"id": task.id}
@router.get("/jobs")
async def list_jobs(session: AsyncSession = Depends(get_session)):
found = await session.scalars(
select(PlayreelTask).order_by(PlayreelTask.created_at.desc()))
return [playreel_view(task) for task in found]
@router.get("/jobs/{task_id}")
async def get_job(task_id: str, session: AsyncSession = Depends(get_session)):
return playreel_view(await find_task(session, task_id))
@router.post("/jobs/{task_id}/approve")
async def approve(task_id: str, body: ApproveBody | None = None,
session: AsyncSession = Depends(get_session)):
"""게이트 승인. edits가 없으면 검수 기본값 그대로 간다"""
task = await find_task(session, task_id)
if task.status != AWAITING_REVIEW:
raise HTTPException(409, "지금은 승인할 수 있는 상태가 아닙니다")
if body and body.gate and body.gate != task.gate:
raise HTTPException(409, f"게이트가 어긋납니다: 화면 {body.gate} / 서버 {task.gate}")
if body and body.edits:
apply_playreel_edits(task, task.gate, body.edits)
task.status = QUEUED
task.gate = None
await session.commit()
return playreel_view(task)
@router.post("/jobs/{task_id}/back")
async def back(task_id: str, session: AsyncSession = Depends(get_session)):
"""직전 게이트로. 크레딧을 이미 쓴 지점 뒤로는 되돌리지 않는다"""
task = await find_task(session, task_id)
if task.status != AWAITING_REVIEW or not task.gate:
raise HTTPException(409, "되돌릴 수 있는 상태가 아닙니다")
# 프론트가 버튼을 숨기는 것에 기대면 14크레딧을 쓴 뒤 되돌아가 다시 태우는 길이 열린다
if PlayreelGate(task.gate) not in REVERSIBLE_GATES:
raise HTTPException(409, "이 단계에서는 되돌릴 수 없습니다. "
"영상을 다시 만들려면 '다시 만들기'를 쓰세요.")
previous = previous_gate(PlayreelGate(task.gate))
reset_from(task, resume_stage(previous))
task.status = AWAITING_REVIEW
task.gate = previous.value
await session.commit()
return playreel_view(task)
@router.post("/jobs/{task_id}/retry")
async def retry(task_id: str, session: AsyncSession = Depends(get_session)):
"""실패 재시도와 클립 재생성. i2v를 다시 돌리면 크레딧이 다시 든다"""
task = await find_task(session, task_id)
# 끝난 것을 다시 돌리면 크레딧만 나간다. 새로 만들 거면 새 잡이다
if task.status == DONE:
raise HTTPException(409, "이미 끝난 작업입니다")
restart = (PlayreelState.I2V.value if task.gate == PlayreelGate.CLIP_CONFIRM
else task.error_stage or PlayreelState.FETCH.value)
reset_from(task, restart)
task.status = QUEUED
task.gate = None
task.error_stage = None
task.error_detail = None
await session.commit()
return playreel_view(task)
@router.post("/jobs/{task_id}/force")
async def force(task_id: str, session: AsyncSession = Depends(get_session)):
"""게이트 실패를 알고도 진행"""
task = await find_task(session, task_id)
if task.status not in (FAILED, AWAITING_REVIEW):
raise HTTPException(409, "지금은 진행할 수 있는 상태가 아닙니다")
task.status = QUEUED
task.gate = None
task.error_stage = None
task.error_detail = None
await session.commit()
return playreel_view(task)
@router.delete("/jobs/{task_id}")
async def delete(task_id: str, session: AsyncSession = Depends(get_session)):
task = await find_task(session, task_id)
if task.status == RUNNING:
raise HTTPException(409, "진행 중인 작업은 삭제할 수 없습니다. 끝난 뒤 지워주세요")
await session.delete(task)
await session.commit()
return {"id": task_id, "removed": True}
@router.get("/voices/{voice_id}/sample")
async def voice_sample(voice_id: str):
raise HTTPException(404, "보이스 샘플이 아직 준비되지 않았습니다")