o2o-ado2-short-form/server/app/main.py

104 lines
3.8 KiB
Python

"""④ FastAPI orchestration — LLM → Higgsfield → Remotion wrapper.
POST /api/generate (multipart: photos[] + interview fields) → job_id 즉시 반환
백그라운드 워커: build_spec (OpenAI) → higgsfield.generate → remotion.render → mp4
GET /api/jobs/{id} 작업 상태 폴링 (queued | running | done | error)
GET /api/health
Static: /outputs/<file> (final videos), / (the web app)
"""
from __future__ import annotations
import shutil
import uuid
from pathlib import Path
from fastapi import FastAPI, File, Form, UploadFile, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app import config as cfg, jobs
from app.schemas import GenerateRequest, JobStatus, ScriptResult
from app.pipeline import spec_builder
WEBAPP = cfg.WEBAPP_DIR
OUTPUTS = cfg.OUTPUTS_DIR
UPLOADS = cfg.UPLOADS_DIR
OUTPUTS.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="ADO2 Higgsfield Shorts")
app.add_middleware(
CORSMiddleware, allow_origins=cfg.CORS_ORIGINS, allow_methods=["*"], allow_headers=["*"],
)
@app.get("/api/health")
def health():
return {"ok": True}
@app.post("/api/captions", response_model=ScriptResult)
def captions(req: GenerateRequest):
"""인터뷰 답변 → 인트로·셀링포인트·감성스토리·CTA 4블록 (OpenAI)."""
try:
return spec_builder.build_script(req)
except Exception as e:
raise HTTPException(502, f"자막 생성 실패: {e}") from e
@app.post("/api/generate", response_model=JobStatus, status_code=202)
def generate( # 논블로킹: 사진만 저장하고 작업을 큐에 넣은 뒤 job_id 반환
photos: list[UploadFile] = File(...),
kind: str = Form(...),
biz_name: str = Form(...),
selling: str = Form(...),
addr: str | None = Form(None),
price: str | None = Form(None),
dry_run: bool = Form(False),
):
if len(photos) < 4:
raise HTTPException(400, "사진 4장 이상이 필요합니다.")
# 업로드 사진을 디스크에 저장 (워커 스레드가 경로로 읽음).
# UploadFile 의 스트림은 요청 종료 후 닫히므로 반드시 여기서 영속화한다.
upload_id = uuid.uuid4().hex[:8]
job_dir = UPLOADS / upload_id
job_dir.mkdir(parents=True, exist_ok=True)
photo_paths: list[Path] = []
for i, up in enumerate(photos):
# 원본 파일명에 한글·유니코드 문자가 있으면 fal 업로드 클라이언트가 ASCII 에러를 냄.
# 확장자만 보존하고 나머지는 순수 숫자로 대체한다.
suffix = Path(up.filename or "img.jpg").suffix.lower() or ".jpg"
dest = job_dir / f"{i:02d}{suffix}"
with dest.open("wb") as f:
shutil.copyfileobj(up.file, f)
photo_paths.append(dest)
req = GenerateRequest(kind=kind, biz_name=biz_name, addr=addr, price=price, selling=selling)
# 파이프라인은 백그라운드 워커에서 실행 → 즉시 queued 상태를 돌려준다.
job_id = jobs.submit(req, photo_paths, dry_run=dry_run)
status = jobs.get(job_id)
return JobStatus(**{k: status[k] for k in JobStatus.model_fields})
@app.get("/api/jobs/{job_id}", response_model=JobStatus)
def job_status(job_id: str):
"""작업 진행 상태 폴링. 프론트가 주기적으로 호출."""
status = jobs.get(job_id)
if status is None:
raise HTTPException(404, "작업을 찾을 수 없습니다(만료되었거나 잘못된 ID).")
return JobStatus(**{k: status[k] for k in JobStatus.model_fields})
# Static mounts (after API routes)
app.mount("/outputs", StaticFiles(directory=str(OUTPUTS)), name="outputs")
@app.get("/")
def index():
return FileResponse(str(WEBAPP / "index.html"))
app.mount("/", StaticFiles(directory=str(WEBAPP)), name="webapp")