"""/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"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, 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, "보이스 샘플이 아직 준비되지 않았습니다")