"""/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.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: Image.open(io.BytesIO(raw)).verify() except Exception: raise HTTPException(422, "이미지 파일을 열 수 없습니다") # 이름을 비워 두면 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}