174 lines
6.9 KiB
Python
174 lines
6.9 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.auth import creating_user, current_user
|
|
from routers.view import playreel_view
|
|
from tables.task import PlayreelTask
|
|
from tables.user import User
|
|
from utils.database import get_session
|
|
|
|
# 이 라우터의 모든 경로가 로그인을 요구한다. 잡 생성만 한도 검사를 더 받는다
|
|
router = APIRouter(prefix="/api/playreel", tags=["playreel"],
|
|
dependencies=[Depends(current_user)])
|
|
|
|
# 프론트 parseGoodsId와 같은 규칙. 프론트 검사는 입력 도중의 안내이고 판정은 여기서 한다
|
|
GOODS_ID_PATTERNS = (
|
|
# 인터파크 주소는 야놀자로 리다이렉트된다. 상품 번호는 양쪽이 같다
|
|
r"nol\.yanolja\.com/ticket/products/(\d{5,})",
|
|
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, user: User = Depends(creating_user),
|
|
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,
|
|
user_id=user.id)
|
|
# 잡을 지워도 회복되지 않게 누적값을 올린다
|
|
user.jobs_created += 1
|
|
await session.commit()
|
|
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, "보이스 샘플이 아직 준비되지 않았습니다")
|