"""④ 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/ (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")