playreel/backend/routers/f1.py

176 lines
7.1 KiB
Python

"""/api/f1 — 포스터 한 장 → 8초 숏폼"""
import io
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from PIL import Image
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.pipeline_state import PosterAliveState
from pipelines import poster_alive
from pipelines.artifact import load_image
from pipelines.runner import AWAITING_REVIEW, FAILED, QUEUED, RUNNING, reset_from
from routers.view import motion_elements, poster_alive_view
from services.i2v import MIN_LONG_EDGE_PX
from services.motion import plan_motion
from tables.task import PosterAliveTask
from utils.database import get_session
router = APIRouter(prefix="/api/f1", tags=["f1"])
ACCEPTED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
MAX_UPLOAD_BYTES = 30 * 1024 * 1024
NARRATION_COUNT = 3
NARRATION_MAX_CHARS = 40
class NarrationBody(BaseModel):
narration: list[str]
class MetadataBody(BaseModel):
event_name: str
date_text: str = ""
place: str
keywords: list[str] | None = None
class ApproveBody(BaseModel):
# 검수 화면에서 사람이 모션을 더하거나 뺀 결과. None이면 그대로 간다
motions: list[str] | None = None
async def find_task(session: AsyncSession, task_id: str) -> PosterAliveTask:
task = await session.get(PosterAliveTask, task_id)
if task is None:
raise HTTPException(404, "잡을 찾을 수 없습니다")
return task
async def rewrite_motion_plan(task: PosterAliveTask, motions: list[str]) -> None:
"""고른 모션으로 프롬프트를 다시 쓴다. 바뀐 게 없으면 건드리지 않는다"""
chosen = [key.strip() for key in motions if key.strip()]
if sorted(chosen) == sorted(motion_elements(task.motion_plan) or []):
return
plan = await plan_motion(await load_image(task.poster_url), force_motions=chosen)
task.motion_plan = plan.model_dump()
@router.post("/jobs")
async def create(poster: UploadFile = File(...), name: str = Form(""),
auto: bool = Form(False),
session: AsyncSession = Depends(get_session)):
if poster.content_type not in ACCEPTED_TYPES:
raise HTTPException(415, f"지원 형식: JPG/PNG/WEBP/GIF (받은 것: {poster.content_type})")
raw = await poster.read()
if len(raw) > MAX_UPLOAD_BYTES:
raise HTTPException(413, "30MB 이하만 올릴 수 있습니다")
try:
with Image.open(io.BytesIO(raw)) as image:
width, height = image.size
image.verify()
except Exception:
raise HTTPException(422, "이미지 파일을 열 수 없습니다")
# i2v가 확대 시 뭉개져 거절한다. 거기까지 가면 나레이션·TTS·BGM을 다 쓰고 터지므로
# 같은 기준을 업로드에서 먼저 건다
if max(width, height) < MIN_LONG_EDGE_PX:
raise HTTPException(
422, f"포스터 긴 변이 {MIN_LONG_EDGE_PX}px 이상이어야 합니다 "
f"(받은 것: {width}x{height}). 고해상 원본으로 올려주세요.")
# 이름을 비워 두면 narration_text 가 포스터에서 읽은 행사명으로 채운다
task = await poster_alive.create_task(session, name.strip(), raw, skip_review=auto)
return {"id": task.id, "ahead": 0}
@router.get("/jobs")
async def list_jobs(session: AsyncSession = Depends(get_session)):
found = await session.scalars(
select(PosterAliveTask).order_by(PosterAliveTask.created_at.desc()))
return [poster_alive_view(task) for task in found]
@router.get("/jobs/{task_id}")
async def get_job(task_id: str, session: AsyncSession = Depends(get_session)):
return poster_alive_view(await find_task(session, task_id))
@router.put("/jobs/{task_id}/narration")
async def edit_narration(task_id: str, body: NarrationBody,
session: AsyncSession = Depends(get_session)):
task = await find_task(session, task_id)
if task.status != AWAITING_REVIEW:
raise HTTPException(409, "나레이션은 검수 대기 상태에서만 고칠 수 있습니다")
lines = [line.strip() for line in body.narration]
if len(lines) != NARRATION_COUNT or any(
not line or len(line) > NARRATION_MAX_CHARS for line in lines):
raise HTTPException(422, f"나레이션은 {NARRATION_MAX_CHARS}자 이내 "
f"{NARRATION_COUNT}문장이어야 합니다")
task.narration_lines = lines
await session.commit()
return poster_alive_view(task)
@router.put("/jobs/{task_id}/metadata")
async def edit_metadata(task_id: str, body: MetadataBody,
session: AsyncSession = Depends(get_session)):
"""VLM이 연도나 지명을 잘못 읽어도 아카이브에는 사실이 남게 한다"""
task = await find_task(session, task_id)
if task.status != AWAITING_REVIEW:
raise HTTPException(409, "메타태그는 검수 대기 상태에서만 고칠 수 있습니다")
if not body.event_name.strip() or not body.place.strip():
raise HTTPException(422, "행사명과 장소는 비울 수 없습니다")
metadata = dict(task.poster_metadata or {})
metadata["event_name"] = body.event_name.strip()
metadata["date_text"] = body.date_text.strip()
metadata["place"] = body.place.strip()
if body.keywords is not None:
metadata["keywords"] = [word.strip() for word in body.keywords if word.strip()]
task.poster_metadata = metadata
# 잡 이름이 곧 내려받는 파일명이라 고친 행사명을 따라간다
task.name = metadata["event_name"]
await session.commit()
return poster_alive_view(task)
@router.post("/jobs/{task_id}/approve")
async def approve(task_id: str, body: ApproveBody | None = None,
session: AsyncSession = Depends(get_session)):
task = await find_task(session, task_id)
if task.status != AWAITING_REVIEW:
raise HTTPException(409, f"검수 대기 상태가 아닙니다: {task.status}")
if body and body.motions is not None:
await rewrite_motion_plan(task, body.motions)
task.status = QUEUED
await session.commit()
return {"id": task.id, "ahead": 0}
@router.post("/jobs/{task_id}/retry")
async def retry(task_id: str, body: ApproveBody | None = None,
session: AsyncSession = Depends(get_session)):
task = await find_task(session, task_id)
if task.status != FAILED:
raise HTTPException(409, f"실패 상태가 아닙니다: {task.status}")
if body and body.motions is not None:
await rewrite_motion_plan(task, body.motions)
reset_from(task, task.error_stage or PosterAliveState.DETECT.value)
task.status = QUEUED
task.error_stage = None
task.error_detail = None
await session.commit()
return {"id": task.id, "ahead": 0}
@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}