188 lines
7.8 KiB
Python
188 lines
7.8 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.auth import creating_user, current_user
|
|
from routers.view import motion_elements, poster_alive_view
|
|
from tables.user import User
|
|
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"],
|
|
dependencies=[Depends(current_user)])
|
|
|
|
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),
|
|
user: User = Depends(creating_user),
|
|
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,
|
|
user_id=user.id)
|
|
# 잡을 지워도 회복되지 않게 누적값을 올린다
|
|
user.jobs_created += 1
|
|
await session.commit()
|
|
return {"id": task.id, "ahead": 0}
|
|
|
|
|
|
@router.get("/jobs")
|
|
async def list_jobs(user: User = Depends(current_user),
|
|
session: AsyncSession = Depends(get_session)):
|
|
"""자기가 만든 것만 돌려준다"""
|
|
found = await session.scalars(
|
|
select(PosterAliveTask).where(PosterAliveTask.user_id == user.id)
|
|
.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}
|