161 lines
6.0 KiB
Python
161 lines
6.0 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/outputs 완료된 영상 목록 (갤러리)
|
|
GET /api/health
|
|
Static: /outputs/<file> (final videos), / (the web app)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
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, VideoMeta
|
|
from app.pipeline import spec_builder, azure_storage
|
|
|
|
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.get("/api/outputs", response_model=list[VideoMeta])
|
|
def list_outputs():
|
|
"""완료된 영상 목록을 최신순으로 반환.
|
|
|
|
Azure 설정이 있으면 Azure Blob 목록을 조회하고,
|
|
로컬 JSON 에서 메타데이터(업체명·캡션·프로파일)를 보완한다.
|
|
Azure 미설정 시 로컬 outputs/ 디렉터리를 직접 스캔한다.
|
|
"""
|
|
# ── Azure 우선 ──
|
|
if cfg.azure_enabled():
|
|
try:
|
|
blobs = azure_storage.list_videos() # [{job_id, video_url, created_at}]
|
|
results: list[VideoMeta] = []
|
|
for b in blobs:
|
|
job_id = b["job_id"]
|
|
meta_file = OUTPUTS / f"{job_id}.json"
|
|
if meta_file.exists():
|
|
try:
|
|
data = json.loads(meta_file.read_text(encoding="utf-8"))
|
|
data["video_url"] = b["video_url"] # Azure URL 우선
|
|
results.append(VideoMeta(**data))
|
|
continue
|
|
except Exception:
|
|
pass
|
|
results.append(VideoMeta(
|
|
job_id=job_id,
|
|
video_url=b["video_url"],
|
|
created_at=b["created_at"],
|
|
))
|
|
return results
|
|
except Exception as e:
|
|
# Azure 조회 실패 시 로컬 fallback
|
|
print(f"[outputs] Azure 목록 조회 실패, 로컬 fallback: {e}")
|
|
|
|
# ── 로컬 fallback ──
|
|
OUTPUTS.mkdir(parents=True, exist_ok=True)
|
|
results = []
|
|
for mp4 in sorted(OUTPUTS.glob("*.mp4"), key=lambda p: p.stat().st_mtime, reverse=True):
|
|
job_id = mp4.stem
|
|
meta_file = OUTPUTS / f"{job_id}.json"
|
|
if meta_file.exists():
|
|
try:
|
|
data = json.loads(meta_file.read_text(encoding="utf-8"))
|
|
results.append(VideoMeta(**data))
|
|
continue
|
|
except Exception:
|
|
pass
|
|
results.append(VideoMeta(
|
|
job_id=job_id,
|
|
video_url=f"/outputs/{job_id}.mp4",
|
|
created_at=mp4.stat().st_mtime,
|
|
))
|
|
return results
|
|
|
|
|
|
@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")
|