diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9536b69 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,33 @@ +# VCS / IDE +.git +.gitignore +.vscode +.idea +*.swp +.DS_Store + +# Node / Python 산출물 (이미지 안에서 재설치) +**/node_modules +**/__pycache__ +**/*.pyc +**/*.egg-info +**/.venv +.pytest_cache +.mypy_cache +.ruff_cache + +# 런타임/렌더 산출물 (재생성 가능) +server/outputs +server/.uploads +server/.tmp +remotion/out +remotion/public/job_*.mp4 + +# 대용량 입력/출력 미디어 +inputs/* +outputs/* + +# 배포 메타 / 시크릿 +.vercel +**/.env +**/.env.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..407abb4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# syntax=docker/dockerfile:1 +# ADO2 Hookit — 올인원 로컬 이미지 (FastAPI 백엔드 + 정적 프론트 + Remotion 렌더) +# · Python 3.12 (server) + Node 22 (Remotion) + ffmpeg + headless Chromium +# · dry_run=true 면 Higgsfield 호출 없이 데모 영상 + Remotion 오버레이까지 완주 +# · 실제 생성은 Higgsfield CLI 디바이스 인증이 별도로 필요 (HANDOFF.md §7-2) +FROM python:3.12-slim-bookworm + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PORT=10001 \ + WEBAPP_DIR=/app/webapp \ + OUTPUTS_DIR=/app/server/outputs \ + UPLOADS_DIR=/app/server/.uploads + +# 시스템 의존성: ffmpeg(ffprobe) + Node 22 + Remotion headless Chromium 런타임 libs + CJK 폰트 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl gnupg ffmpeg \ + libnss3 libdbus-1-3 libatk1.0-0 libgbm1 libasound2 \ + libxrandr2 libxkbcommon0 libxfixes3 libxcomposite1 libxdamage1 \ + libatk-bridge2.0-0 libpango-1.0-0 libcairo2 libcups2 \ + fonts-liberation fonts-noto-cjk \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# --- Python 백엔드 (deps + 패키지) --- +COPY server ./server +RUN pip install ./server + +# --- Remotion (Node) deps — 락파일 기반 재현 설치 --- +COPY remotion/package.json remotion/package-lock.json ./remotion/ +RUN cd remotion && npm ci + +# --- 나머지 소스 --- +COPY remotion ./remotion +COPY webapp ./webapp + +# Remotion이 쓰는 headless 브라우저 미리 다운로드 (런타임 첫 렌더 지연 제거) +RUN cd remotion && npx remotion browser ensure + +EXPOSE 10001 +WORKDIR /app/server +CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT}"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1b452ad --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +# ADO2 Hookit — 로컬 실행 (포트 10001) +# docker compose up --build → http://localhost:10001 +# 환경변수는 셸 또는 레포 루트 ./.env 에서 자동 주입됩니다 (예: OPENAI_API_KEY=...). +services: + app: + build: + context: . + dockerfile: Dockerfile + image: ado2-hookit:local + ports: + - "10001:10001" + # .env 의 모든 키를 컨테이너로 주입 (config.py 가 읽음) + env_file: + - .env + volumes: + # 프론트 정적 파일 — 재빌드 없이 즉시 반영 (StaticFile은 매 요청 디스크 읽음) + - ./webapp:/app/webapp + # 생성된 최종 영상 보존 (컨테이너 재시작에도 유지) + - ./server/outputs:/app/server/outputs + # 실제 Higgsfield 생성 시 호스트의 디바이스 로그인 인증 공유 (dry_run엔 불필요) + # - ${HOME}/.higgsfield:/root/.higgsfield:ro + restart: unless-stopped diff --git a/remotion/src/components/BrandLines.tsx b/remotion/src/components/BrandLines.tsx index badbe55..9712e17 100644 --- a/remotion/src/components/BrandLines.tsx +++ b/remotion/src/components/BrandLines.tsx @@ -24,9 +24,14 @@ export const BrandLines: React.FC<{ > {lines.map((line, i) => { const start = fromFrame + i * LINE_STAGGER; + // interpolate는 배열이 단조증가해야 한다. + // 영상이 짧아 toFrame 이 start+24 이하로 내려오면 범위가 뒤집혀 크래시 발생. + const fadeIn = start + 12; + const fadeOut = Math.max(fadeIn + 1, toFrame - 12); + const end = Math.max(fadeOut + 1, toFrame); const opacity = interpolate( frame, - [start, start + 12, toFrame - 12, toFrame], + [start, fadeIn, fadeOut, end], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, ); diff --git a/remotion/src/components/HookTitle.tsx b/remotion/src/components/HookTitle.tsx index 5ee3fe8..8eba087 100644 --- a/remotion/src/components/HookTitle.tsx +++ b/remotion/src/components/HookTitle.tsx @@ -25,9 +25,12 @@ export const HookTitle: React.FC<{ }); const translateY = interpolate(enter, [0, 1], [-60, 0]); // 퇴장 페이드 + const fadeIn = fromFrame + 8; + const fadeOut = Math.max(fadeIn + 1, toFrame - 12); + const end = Math.max(fadeOut + 1, toFrame); const opacity = interpolate( frame, - [fromFrame, fromFrame + 8, toFrame - 12, toFrame], + [fromFrame, fadeIn, fadeOut, end], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, ); diff --git a/remotion/src/components/SellingBadge.tsx b/remotion/src/components/SellingBadge.tsx index ea3e657..9b99046 100644 --- a/remotion/src/components/SellingBadge.tsx +++ b/remotion/src/components/SellingBadge.tsx @@ -37,9 +37,12 @@ export const SellingBadge: React.FC<{ config: { damping: 200, mass: 0.5 }, }); const rise = interpolate(enter, [0, 1], [22, 0]); + const fadeIn = start + 8; + const fadeOut = Math.max(fadeIn + 1, toFrame - 10); + const end = Math.max(fadeOut + 1, toFrame); const appear = interpolate( frame, - [start, start + 8, toFrame - 10, toFrame], + [start, fadeIn, fadeOut, end], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, ); diff --git a/remotion/src/fonts.ts b/remotion/src/fonts.ts index 038d830..383b303 100644 --- a/remotion/src/fonts.ts +++ b/remotion/src/fonts.ts @@ -1,15 +1,21 @@ // 한글 폰트 로딩 (@remotion/google-fonts, korean subset) // Hook = Black Han Sans (굵고 강렬한 바이럴 헤드라인) // Body/Brand = Nanum Myeongjo (우아한 명조 — 머뭄의 정적 브랜드 톤) +// +// 주의: korean subset 자체가 unicode-range 파일이 많아 요청 수가 많다. +// weights 를 최소화(NanumMyeongjo는 400/700만 실제 존재)해 요청 수를 줄임. +// latin 서브셋 제거 — 콘텐츠가 한국어 전용이므로 불필요. import { loadFont as loadHook } from "@remotion/google-fonts/BlackHanSans"; import { loadFont as loadSerif } from "@remotion/google-fonts/NanumMyeongjo"; export const hookFont = loadHook("normal", { weights: ["400"], - subsets: ["korean", "latin"], + subsets: ["korean"], + ignoreTooManyRequestsWarning: true, }).fontFamily; export const serifFont = loadSerif("normal", { - weights: ["400", "700", "800"], - subsets: ["korean", "latin"], + weights: ["400", "700"], // 800은 NanumMyeongjo에 없음 → 제거 + subsets: ["korean"], + ignoreTooManyRequestsWarning: true, }).fontFamily; diff --git a/server/.env.example b/server/.env.example index f3eefc4..94f1fca 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,6 +1,70 @@ -# ADO2 Higgsfield Shorts server -# Claude API (spec_builder) -ANTHROPIC_API_KEY= +# ADO2 Higgsfield Shorts server — 이 파일을 .env 로 복사해 채우세요 (.env 는 gitignore됨) -# Higgsfield CLI must be authenticated separately: `higgsfield auth login` -# (the server shells out to the CLI; no key needed here) +# --- OpenAI API (spec_builder, 필수) --- +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o + +# --- HTTP --- +PORT=10001 +# CORS 허용 출처. 콤마구분. 운영 시 실제 도메인으로 제한 (예: https://ado2short.o2osolution.ai) +CORS_ORIGINS=* + +# --- 비동기 작업 큐 --- +# 동시에 실제 생성을 돌릴 작업 수. 초과 요청은 큐에서 대기(status=queued). +# Higgsfield/Remotion이 무겁고 외부 단가가 있으므로 보수적으로 둔다. +MAX_CONCURRENT_JOBS=2 +# 완료/실패한 작업 상태를 메모리에 보관할 시간(초). 지나면 폴링 시 청소. +JOB_RETENTION_SECONDS=3600 + +# --- 비디오 생성 백엔드 선택 --- +# fal : fal.ai Seedance 2.0 reference-to-video (멀티이미지 9장 + 9:16 세로). 멀티이미지엔 이쪽 권장. +# higgsfield : Higgsfield(dop/seedance v1/kling). 단일 이미지만. +VIDEO_BACKEND=higgsfield + +# --- fal.ai (Seedance 2.0, VIDEO_BACKEND=fal 일 때) --- +# 키 발급: https://fal.ai → API Keys. 형식은 keyid:secret. +FAL_KEY= +FAL_MODEL_ID=bytedance/seedance-2.0/reference-to-video +FAL_MAX_IMAGES=9 # Seedance 2.0 멀티이미지 최대 9장 +FAL_ASPECT_RATIO=9:16 # auto/16:9/9:16/4:3/3:4/1:1/21:9 +FAL_RESOLUTION=720p # 480p/720p/1080p +FAL_DURATION=8 # "auto" 또는 4~15(초) + +# --- Higgsfield (공통, VIDEO_BACKEND=higgsfield 일 때) --- +# 백엔드: cli(디바이스 로그인) | api(공식 SDK). 컨테이너에서는 api 권장. +HIGGSFIELD_BACKEND=cli +HIGGSFIELD_ASPECT_RATIO=9:16 +HIGGSFIELD_DURATION=8 +HIGGSFIELD_WAIT_TIMEOUT=15m + +# --- Higgsfield: CLI 백엔드 --- +# 인증: `higgsfield auth login` (~/.higgsfield, 컨테이너에선 별도 주입 필요) +HIGGSFIELD_MODEL=marketing_studio_video +HIGGSFIELD_MODE=tv_spot +HIGGSFIELD_GENERATE_AUDIO=true + +# --- Higgsfield: API 백엔드 (공식 higgsfield-client SDK) --- +# 키 발급: https://cloud.higgsfield.ai → API 섹션. 형식은 key:secret. +HIGGSFIELD_API_KEY= +HIGGSFIELD_API_SECRET= +# (또는 SDK 네이티브 변수로 직접 줘도 됨: HF_KEY="key:secret") +# +# 비디오 application 선택 (platform.higgsfield.ai 실측): +# · DoP : APP=/v1/image2video/dop, VARIANT=dop-turbo|dop-lite|dop-preview +# input_images(복수). aspect_ratio 파라미터 무시 → 입력 이미지 비율 따라감(세로 보장 X). +# · Seedance : APP=/v1/image2video/seedance, VARIANT=seedance_pro|seedance_lite +# input_image(단수, 1장). aspect_ratio 9:16 네이티브 지원(세로 보장 O), duration 3~12. +# 세로 9:16 숏폼에는 Seedance 권장. +HIGGSFIELD_API_APP=/v1/image2video/seedance +HIGGSFIELD_API_VARIANT=seedance_pro +# Seedance/DoP image2video 모두 입력 이미지 1장만 사용. +HIGGSFIELD_API_MAX_IMAGES=1 + +# --- Remotion --- +REMOTION_FPS=30 +REMOTION_COMPOSITION=MumumShort + +# --- Paths (Docker에서 오버라이드. 비우면 레포 기본 경로 사용) --- +# WEBAPP_DIR= +# OUTPUTS_DIR= +# UPLOADS_DIR= diff --git a/server/app/config.py b/server/app/config.py new file mode 100644 index 0000000..ab77666 --- /dev/null +++ b/server/app/config.py @@ -0,0 +1,91 @@ +"""Centralized runtime configuration — env-driven, no hardcoded constants. + +Every value has a sensible default so local/dev runs work with zero config; +override any of them via environment variables (see server/.env.example). +Import as `from app import config as cfg` and read `cfg.`. +""" +from __future__ import annotations + +import os +from pathlib import Path + +ENGINE = Path(__file__).resolve().parents[2] # engine/higgsfield_shorts + + +def _csv(name: str, default: str) -> list[str]: + raw = os.getenv(name, default) + return [s.strip() for s in raw.split(",") if s.strip()] + + +def _path(name: str, default: Path) -> Path: + val = os.getenv(name) + return Path(val) if val else default + + +# ---- HTTP / CORS ---- +PORT = int(os.getenv("PORT", "10001")) +CORS_ORIGINS = _csv("CORS_ORIGINS", "*") # 콤마구분. 운영 시 도메인으로 제한. + +# ---- 비동기 작업 큐 (인메모리 스레드풀) ---- +# 동시에 실제로 생성을 돌릴 작업 수. 초과분은 큐에서 대기(status=queued). +# Higgsfield/Remotion이 무거우므로(CPU·메모리·외부 단가) 보수적으로 둔다. +MAX_CONCURRENT_JOBS = int(os.getenv("MAX_CONCURRENT_JOBS", "2")) +# 완료/실패한 작업 레코드를 메모리에 보관할 시간(초). 지나면 폴링 GET 시 청소. +JOB_RETENTION_SECONDS = int(os.getenv("JOB_RETENTION_SECONDS", "3600")) + +# ---- LLM (OpenAI) ---- +# 인증: SDK가 OPENAI_API_KEY 를 환경에서 읽음. +OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o") + +# ---- 비디오 생성 백엔드 선택 ---- +# "higgsfield" — Higgsfield(dop/seedance v1/kling). 입력 이미지 1장만 사용. +# "fal" — fal.ai Seedance 2.0 reference-to-video. 입력 이미지 최대 9장(멀티) + 9:16 세로. +VIDEO_BACKEND = os.getenv("VIDEO_BACKEND", "higgsfield").lower() + +# ---- Higgsfield (공통) ---- +# 백엔드 선택: "cli"(기존 검증 경로, ~/.higgsfield 디바이스 로그인) | "api"(공식 SDK, 컨테이너 친화) +HIGGSFIELD_BACKEND = os.getenv("HIGGSFIELD_BACKEND", "cli").lower() +HIGGSFIELD_ASPECT_RATIO = os.getenv("HIGGSFIELD_ASPECT_RATIO", "9:16") +HIGGSFIELD_DURATION = int(os.getenv("HIGGSFIELD_DURATION", "8")) +HIGGSFIELD_WAIT_TIMEOUT = os.getenv("HIGGSFIELD_WAIT_TIMEOUT", "15m") + +# ---- Higgsfield: CLI 백엔드 (subprocess) ---- +HIGGSFIELD_MODEL = os.getenv("HIGGSFIELD_MODEL", "marketing_studio_video") +HIGGSFIELD_MODE = os.getenv("HIGGSFIELD_MODE", "tv_spot") +HIGGSFIELD_GENERATE_AUDIO = os.getenv("HIGGSFIELD_GENERATE_AUDIO", "true") + +# ---- Higgsfield: API 백엔드 (공식 higgsfield-client SDK) ---- +# 인증: SDK가 HF_KEY="key:secret" 또는 HF_API_KEY/HF_API_SECRET 를 환경에서 읽음. +# 편의상 HIGGSFIELD_API_KEY/SECRET 로 줘도 아래에서 HF_* 로 매핑. +# 주의: 공개 문서가 다루는 비디오 모델은 DoP image2video. marketing_studio_video 가 +# API로 노출되는지는 미검증 → app/variant 를 env로 빼둠(대시보드 확인 후 교체). +HIGGSFIELD_API_APP = os.getenv("HIGGSFIELD_API_APP", "/v1/image2video/dop") +HIGGSFIELD_API_VARIANT = os.getenv("HIGGSFIELD_API_VARIANT", "dop-turbo") +# DoP image2video 는 입력 이미지 정확히 1장만 허용(실측). 모델 교체 시 늘릴 수 있음. +HIGGSFIELD_API_MAX_IMAGES = int(os.getenv("HIGGSFIELD_API_MAX_IMAGES", "1")) +_hf_key = os.getenv("HIGGSFIELD_API_KEY") +_hf_secret = os.getenv("HIGGSFIELD_API_SECRET") +if _hf_key and not os.getenv("HF_API_KEY"): + os.environ["HF_API_KEY"] = _hf_key +if _hf_secret and not os.getenv("HF_API_SECRET"): + os.environ["HF_API_SECRET"] = _hf_secret + +# ---- fal.ai (Seedance 2.0 reference-to-video, VIDEO_BACKEND=fal) ---- +# 인증: SDK가 FAL_KEY("keyid:secret")를 환경에서 읽음. 편의상 FAL_API_KEY 로 줘도 매핑. +FAL_MODEL_ID = os.getenv("FAL_MODEL_ID", "bytedance/seedance-2.0/reference-to-video") +FAL_MAX_IMAGES = int(os.getenv("FAL_MAX_IMAGES", "9")) # Seedance 2.0 멀티이미지 최대 9장 +FAL_ASPECT_RATIO = os.getenv("FAL_ASPECT_RATIO", "9:16") # auto/16:9/9:16/4:3/3:4/1:1/21:9 +FAL_RESOLUTION = os.getenv("FAL_RESOLUTION", "720p") # 480p/720p/1080p +FAL_DURATION = os.getenv("FAL_DURATION", "8") # "auto" 또는 4~15(초) +_fal_key = os.getenv("FAL_API_KEY") +if _fal_key and not os.getenv("FAL_KEY"): + os.environ["FAL_KEY"] = _fal_key + +# ---- Remotion (Node render) ---- +REMOTION_FPS = int(os.getenv("REMOTION_FPS", "30")) +REMOTION_COMPOSITION = os.getenv("REMOTION_COMPOSITION", "MumumShort") + +# ---- Paths (Docker는 환경변수로 오버라이드) ---- +WEBAPP_DIR = _path("WEBAPP_DIR", ENGINE / "webapp") +OUTPUTS_DIR = _path("OUTPUTS_DIR", ENGINE / "server" / "outputs") +UPLOADS_DIR = _path("UPLOADS_DIR", ENGINE / "server" / ".uploads") diff --git a/server/app/jobs.py b/server/app/jobs.py new file mode 100644 index 0000000..18b0642 --- /dev/null +++ b/server/app/jobs.py @@ -0,0 +1,181 @@ +"""비동기 작업 큐 — 인메모리 레지스트리 + 스레드풀. + +`/api/generate` 는 더 이상 파이프라인을 동기 블로킹하지 않는다. 대신: + + submit(req, photo_paths, dry_run) → job_id 즉시 반환 + ThreadPoolExecutor(max_workers=cfg.MAX_CONCURRENT_JOBS) 위에서 _run() 실행 + get(job_id) → 현재 상태(JobStatus dict) — 프론트가 폴링 + +동시성: 워커 수만큼 동시에 생성하고, 초과분은 풀의 큐에서 status="queued" 로 대기한다. +영속성: 인메모리이므로 단일 프로세스 한정. 서버 재시작 시 진행 중 작업은 사라진다. + (단일 컨테이너·단일 uvicorn 배포 전제. 멀티프로세스/스케일아웃이 필요해지면 + Redis 등 외부 스토어로 교체.) +""" +from __future__ import annotations + +import shutil +import threading +import time +import traceback +import uuid +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from app import config as cfg +from app.schemas import GenerateRequest +from app.pipeline import spec_builder, higgsfield_client, fal_client, remotion_render + +OUTPUTS = cfg.OUTPUTS_DIR + +# 단계 → (stage_index, 진입 시 표시할 대략 진행률). 프론트 진행바 3개와 1:1 매핑. +_STAGE_PROGRESS = {"spec": (0, 10), "higgsfield": (1, 40), "remotion": (2, 80)} + +_executor = ThreadPoolExecutor( + max_workers=cfg.MAX_CONCURRENT_JOBS, thread_name_prefix="gen" +) +_jobs: dict[str, dict] = {} +_lock = threading.Lock() + + +def _now() -> float: + return time.time() + + +def _set(job_id: str, **fields) -> None: + """레지스트리의 작업 레코드를 스레드 안전하게 부분 갱신.""" + with _lock: + job = _jobs.get(job_id) + if job is not None: + job.update(fields, updated_at=_now()) + + +def _stage(job_id: str, stage: str) -> None: + idx, progress = _STAGE_PROGRESS[stage] + _set(job_id, status="running", stage=stage, stage_index=idx, progress=progress) + + +def _purge_expired() -> None: + """완료/실패 후 보존시간이 지난 레코드를 청소(메모리 누수 방지).""" + cutoff = _now() - cfg.JOB_RETENTION_SECONDS + with _lock: + stale = [ + jid for jid, j in _jobs.items() + if j["status"] in ("done", "error") and j["updated_at"] < cutoff + ] + for jid in stale: + _jobs.pop(jid, None) + + +def _cleanup( + upload_dir: Path | None, + base_video: Path | None, + remotion_out: Path | None, + remotion_temps: list[Path], +) -> None: + """작업 완료·실패 후 임시 파일 전부 삭제. 오류는 무시(정리 실패가 메인 흐름에 영향 없게).""" + # 업로드 사진 폴더 통째로 + if upload_dir and upload_dir.exists(): + shutil.rmtree(upload_dir, ignore_errors=True) + # Higgsfield가 받아둔 베이스 영상 (.tmp/). dry_run 데모 파일은 제외 + if base_video and base_video != higgsfield_client.DEMO_VIDEO: + try: + base_video.unlink(missing_ok=True) + except OSError: + pass + # Remotion 중간 파일들 (remotion/public/ 복사본, props JSON) + for p in remotion_temps: + try: + p.unlink(missing_ok=True) + except OSError: + pass + # Remotion 최종 출력 (outputs/ 로 복사 완료된 뒤 원본 삭제) + if remotion_out: + try: + remotion_out.unlink(missing_ok=True) + except OSError: + pass + + +def _run(job_id: str, req: GenerateRequest, photo_paths: list[Path], dry_run: bool) -> None: + """워커 스레드 본체 — 기존 동기 파이프라인을 단계별 상태 갱신과 함께 실행.""" + upload_dir = photo_paths[0].parent if photo_paths else None + base_video: Path | None = None + remotion_out: Path | None = None + remotion_temps: list[Path] = [] + + try: + # 1. LLM → spec + _stage(job_id, "spec") + spec = spec_builder.build_spec(req) + _set(job_id, caption=spec.caption, profile=spec.profile) + + # 2. 비디오 생성 → 베이스 영상 (백엔드 선택) + # fal: Seedance 2.0 멀티이미지(9장) / higgsfield: 단일 이미지 + _stage(job_id, "higgsfield") + backend = fal_client if cfg.VIDEO_BACKEND == "fal" else higgsfield_client + base_video, credits = backend.generate( + spec.higgsfield_prompt, photo_paths, dry_run=dry_run + ) + _set(job_id, cost_credits=credits) + + # Higgsfield 완료 즉시 outputs/ 에 저장. + # Remotion 이 실패해도 베이스 영상은 접근 가능하고, + # Remotion 이 성공하면 같은 경로에 최종본을 덮어쓴다. + OUTPUTS.mkdir(parents=True, exist_ok=True) + served = OUTPUTS / f"{job_id}.mp4" + if base_video != higgsfield_client.DEMO_VIDEO: + shutil.copy(base_video, served) + else: + shutil.copy(base_video, served) # dry_run 데모도 동일하게 복사 + _set(job_id, video_url=f"/outputs/{job_id}.mp4") + + # 3. Remotion → 자막 합성 + _stage(job_id, "remotion") + remotion_out, remotion_temps = remotion_render.render(spec, base_video) + + # Remotion 성공 → 자막 합성본으로 덮어쓰기 + shutil.copy(remotion_out, served) + + _set( + job_id, status="done", stage=None, stage_index=2, progress=100, + video_url=f"/outputs/{job_id}.mp4", + ) + except Exception as e: + # 어떤 단계에서 왜 실패했는지 uvicorn 콘솔에 전체 스택을 남긴다(디버깅 필수). + print(f"[job {job_id}] FAILED at stage={get(job_id).get('stage') if get(job_id) else '?'}: {e}") + traceback.print_exc() + _set(job_id, status="error", error=str(e)) + finally: + # 완료·실패 어느 쪽이든 임시 파일 정리 (outputs/ 의 최종본은 건드리지 않음) + _cleanup(upload_dir, base_video, remotion_out, remotion_temps) + + +def submit(req: GenerateRequest, photo_paths: list[Path], dry_run: bool) -> str: + """작업을 큐에 등록하고 job_id 를 즉시 반환(논블로킹).""" + _purge_expired() + job_id = uuid.uuid4().hex[:8] + now = _now() + with _lock: + _jobs[job_id] = { + "job_id": job_id, + "status": "queued", + "stage": None, + "stage_index": 0, + "progress": 0, + "video_url": None, + "caption": None, + "profile": None, + "cost_credits": 0.0, + "error": None, + "created_at": now, + "updated_at": now, + } + _executor.submit(_run, job_id, req, photo_paths, dry_run) + return job_id + + +def get(job_id: str) -> dict | None: + """작업 상태 스냅샷(복사본) 반환. 없으면 None.""" + with _lock: + job = _jobs.get(job_id) + return dict(job) if job is not None else None diff --git a/server/app/main.py b/server/app/main.py index 15b6136..ea9939e 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -1,7 +1,8 @@ """④ FastAPI orchestration — LLM → Higgsfield → Remotion wrapper. -POST /api/generate (multipart: photos[] + interview fields) - → build_spec (Claude) → higgsfield.generate → remotion.render → mp4 +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/ (final videos), / (the web app) """ @@ -16,18 +17,18 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles -from app.schemas import GenerateRequest, GenerateResult, ScriptResult -from app.pipeline import spec_builder, higgsfield_client, remotion_render +from app import config as cfg, jobs +from app.schemas import GenerateRequest, JobStatus, ScriptResult +from app.pipeline import spec_builder -ENGINE = Path(__file__).resolve().parents[2] # engine/higgsfield_shorts -WEBAPP = ENGINE / "webapp" -OUTPUTS = ENGINE / "server" / "outputs" -UPLOADS = ENGINE / "server" / ".uploads" +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=["*"], allow_methods=["*"], allow_headers=["*"], + CORSMiddleware, allow_origins=cfg.CORS_ORIGINS, allow_methods=["*"], allow_headers=["*"], ) @@ -38,15 +39,15 @@ def health(): @app.post("/api/captions", response_model=ScriptResult) def captions(req: GenerateRequest): - """인터뷰 답변 → 인트로·셀링포인트·감성스토리·CTA 4블록 (Claude).""" + """인터뷰 답변 → 인트로·셀링포인트·감성스토리·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=GenerateResult) -async def generate( +@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(...), @@ -58,49 +59,36 @@ async def generate( if len(photos) < 4: raise HTTPException(400, "사진 4장 이상이 필요합니다.") - # 1. Persist uploaded photos - job = uuid.uuid4().hex[:8] - job_dir = UPLOADS / job + # 업로드 사진을 디스크에 저장 (워커 스레드가 경로로 읽음). + # 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): - dest = job_dir / f"{i:02d}_{Path(up.filename or 'img').name}" + # 원본 파일명에 한글·유니코드 문자가 있으면 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) - # 2. LLM → spec - try: - spec = spec_builder.build_spec(req) - except Exception as e: # surface as a clean 502, don't swallow - raise HTTPException(502, f"spec 생성 실패: {e}") from e + # 파이프라인은 백그라운드 워커에서 실행 → 즉시 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}) - # 3. Higgsfield → base video - try: - base_video, credits = higgsfield_client.generate( - spec.higgsfield_prompt, photo_paths, dry_run=dry_run - ) - except Exception as e: - raise HTTPException(502, f"Higgsfield 생성 실패: {e}") from e - # 4. Remotion → final with overlays - try: - final = remotion_render.render(spec, base_video) - except Exception as e: - raise HTTPException(502, f"Remotion 합성 실패: {e}") from e - - served = OUTPUTS / f"{job}.mp4" - shutil.copy(final, served) - - return GenerateResult( - video_url=f"/outputs/{job}.mp4", - caption=spec.caption, - profile=spec.profile, - cost_credits=credits, - job_id=job, - ) +@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) diff --git a/server/app/pipeline/fal_client.py b/server/app/pipeline/fal_client.py new file mode 100644 index 0000000..91ef15c --- /dev/null +++ b/server/app/pipeline/fal_client.py @@ -0,0 +1,86 @@ +"""fal.ai 백엔드 — Seedance 2.0 reference-to-video (멀티이미지 9장, 9:16 세로). + +Higgsfield platform API 가 모든 모델(dop/seedance v1/kling)을 단일 이미지로만 노출하는 +한계를 우회한다. 업로드한 사진 여러 장을 그대로 넣어 ① AI 환각(안 보이는 공간 창작)을 +줄이고 ② 여러 공간을 둘러보는 영상을 만든다. + +흐름: 로컬 사진 → fal 업로드(URL) → subscribe(자동 폴링) → 결과 video.url → 다운로드. +인증: fal SDK 가 FAL_KEY("keyid:secret") 를 환경에서 읽음(config 에서 주입). +dry_run=True 면 호출 없이 번들 데모 클립 반환. +""" +from __future__ import annotations + +import os +import uuid +from pathlib import Path + +import httpx + +from app import config as cfg + +ENGINE = Path(__file__).resolve().parents[3] # engine/higgsfield_shorts +TMP = ENGINE / "server" / ".tmp" +DEMO_VIDEO = cfg.WEBAPP_DIR / "demo" / "mumum.mp4" + + +def _download(url: str) -> Path: + # httpx + 브라우저 UA (CDN이 기본 UA를 403 차단하는 경우 대비) + TMP.mkdir(parents=True, exist_ok=True) + dest = TMP / f"base_{uuid.uuid4().hex[:8]}.mp4" + with httpx.stream("GET", url, follow_redirects=True, timeout=180.0, + headers={"User-Agent": "Mozilla/5.0"}) as r: + r.raise_for_status() + with dest.open("wb") as f: + for chunk in r.iter_bytes(): + f.write(chunk) + return dest + + +def generate( + prompt: str, + image_paths: list[Path], + duration: int | None = None, + dry_run: bool = False, + **_, +) -> tuple[Path, float]: + """Return (local mp4 path, credits). credits 는 fal 응답에 없어 0.0.""" + if dry_run: + return DEMO_VIDEO, 0.0 + + try: + import fal_client + except ImportError as e: + raise RuntimeError( + "fal-client 미설치. `pip install fal-client` 또는 이미지 재빌드 필요." + ) from e + + if not os.environ.get("FAL_KEY"): + raise RuntimeError( + "FAL_KEY 없음. .env 에 FAL_KEY='keyid:secret'(또는 FAL_API_KEY) 설정 필요." + ) + + # 1) 입력 사진 업로드 → URL. Seedance 2.0 는 최대 9장. + image_urls = [fal_client.upload_file(str(p)) for p in image_paths[:cfg.FAL_MAX_IMAGES]] + if not image_urls: + raise RuntimeError("업로드된 입력 이미지가 없습니다.") + + # 2) 요청 인자. duration 은 "auto" 또는 4~12 정수. + arguments: dict = { + "prompt": prompt, + "image_urls": image_urls, + "aspect_ratio": cfg.FAL_ASPECT_RATIO, + "resolution": cfg.FAL_RESOLUTION, + } + dur = duration if duration is not None else cfg.FAL_DURATION + if str(dur).lower() != "auto": + arguments["duration"] = int(dur) + + # 3) 동기 호출(자동 폴링). subscribe 가 완료까지 블록. + result = fal_client.subscribe(cfg.FAL_MODEL_ID, arguments=arguments) + + # 4) 결과 영상 URL 추출 → 다운로드. (응답: {"video": {"url": ...}, "seed": ...}) + video = result.get("video") if isinstance(result, dict) else None + url = video.get("url") if isinstance(video, dict) else None + if not url: + raise RuntimeError(f"fal 결과에서 영상 URL을 찾지 못함: {result!r}") + return _download(url), 0.0 diff --git a/server/app/pipeline/higgsfield_client.py b/server/app/pipeline/higgsfield_client.py index b8da8f9..2026763 100644 --- a/server/app/pipeline/higgsfield_client.py +++ b/server/app/pipeline/higgsfield_client.py @@ -1,37 +1,67 @@ """② Higgsfield step — photos + prompt → completed 8s base video. -Thin wrapper around the verified CLI flow: - higgsfield generate cost/create marketing_studio_video --mode tv_spot ... -dry_run=True returns a bundled demo clip and spends no credits. +두 가지 백엔드를 지원한다 (cfg.HIGGSFIELD_BACKEND): + + · "cli" — 기존 검증 경로. `higgsfield` CLI 를 subprocess 로 호출. + ~/.higgsfield 디바이스 로그인 인증 필요(컨테이너 비친화). + · "api" — 공식 Python SDK(higgsfield-client). HF_KEY 환경변수 인증. + 컨테이너/서버리스 친화. 공개 문서 기준 DoP image2video 사용. + +dry_run=True 면 양쪽 모두 건너뛰고 번들된 데모 클립을 반환(크레딧 0). + +API 백엔드는 DoP image2video(POST {app} → job set, GET /v1/job-sets/{id} 폴링, +jobs[0].results.raw.url 추출)로 실측 검증됨. 단가/오디오 특성이 CLI(marketing_studio, +tv_spot)와 다르므로 app/variant 는 env(cfg.HIGGSFIELD_API_APP/VARIANT)로 교체 가능. """ from __future__ import annotations -import json +import os import re import subprocess -import urllib.request +import time import uuid from pathlib import Path +import httpx + +from app import config as cfg + ENGINE = Path(__file__).resolve().parents[3] # engine/higgsfield_shorts -DEMO_VIDEO = ENGINE / "webapp" / "demo" / "mumum.mp4" +DEMO_VIDEO = cfg.WEBAPP_DIR / "demo" / "mumum.mp4" TMP = ENGINE / "server" / ".tmp" -MODEL = "marketing_studio_video" +MODEL = cfg.HIGGSFIELD_MODEL _URL_RE = re.compile(r'"result_url"\s*:\s*"([^"]+)"') _CREDITS_RE = re.compile(r'"credits(?:_exact)?"\s*:\s*([0-9.]+)') +def _download(url: str) -> Path: + # httpx + 브라우저 UA 로 받음. (CDN이 urllib 기본 UA를 403 차단 — 실측 확인) + TMP.mkdir(parents=True, exist_ok=True) + dest = TMP / f"base_{uuid.uuid4().hex[:8]}.mp4" + with httpx.stream("GET", url, follow_redirects=True, timeout=120.0, + headers={"User-Agent": "Mozilla/5.0"}) as r: + r.raise_for_status() + with dest.open("wb") as f: + for chunk in r.iter_bytes(): + f.write(chunk) + return dest + + +# ============================ CLI 백엔드 ============================ + def _base_args(prompt: str, image_paths: list[Path], duration: int) -> list[str]: args = ["--prompt", prompt] for p in image_paths: args += ["--image", str(p)] - args += ["--mode", "tv_spot", "--duration", str(duration), - "--generate_audio", "true", "--aspect_ratio", "9:16"] + args += ["--mode", cfg.HIGGSFIELD_MODE, "--duration", str(duration), + "--generate_audio", cfg.HIGGSFIELD_GENERATE_AUDIO, + "--aspect_ratio", cfg.HIGGSFIELD_ASPECT_RATIO] return args -def cost(prompt: str, image_paths: list[Path], duration: int = 8) -> float: +def cost(prompt: str, image_paths: list[Path], duration: int = cfg.HIGGSFIELD_DURATION) -> float: + """CLI 백엔드 전용 — 생성 전 크레딧 선확인.""" out = subprocess.run( ["higgsfield", "generate", "cost", MODEL, *_base_args(prompt, image_paths, duration), "--json"], capture_output=True, text=True, check=True, @@ -40,17 +70,9 @@ def cost(prompt: str, image_paths: list[Path], duration: int = 8) -> float: return float(m.group(1)) if m else 0.0 -def generate( - prompt: str, - image_paths: list[Path], - duration: int = 8, - dry_run: bool = False, - wait_timeout: str = "15m", +def _generate_cli( + prompt: str, image_paths: list[Path], duration: int, wait_timeout: str, ) -> tuple[Path, float]: - """Return (local mp4 path, credits spent).""" - if dry_run: - return DEMO_VIDEO, 0.0 - out = subprocess.run( ["higgsfield", "generate", "create", MODEL, *_base_args(prompt, image_paths, duration), @@ -60,11 +82,125 @@ def generate( m = _URL_RE.search(out.stdout) if not m: raise RuntimeError(f"Higgsfield returned no result_url:\n{out.stdout[-2000:]}") - url = m.group(1) credits_m = _CREDITS_RE.search(out.stdout) credits = float(credits_m.group(1)) if credits_m else 0.0 + return _download(m.group(1)), credits - TMP.mkdir(parents=True, exist_ok=True) - dest = TMP / f"base_{uuid.uuid4().hex[:8]}.mp4" - urllib.request.urlretrieve(url, dest) - return dest, credits + +# ============================ API 백엔드 ============================ +# +# DoP image2video 응답은 공식 SDK의 generic submit() 가정(request_id)과 달라 +# 'job set' 구조다(실측 확인). 따라서 SDK 의 저수준 클라이언트(인증·base_url)만 +# 빌려 직접 흐름을 구현한다: +# POST {app} → {"id": , "jobs": [{status, results}]} +# GET /v1/job-sets/{id} → jobs[0].status / jobs[0].results.{raw,min}.url +# +_TERMINAL = {"completed", "failed", "nsfw", "canceled", "cancelled"} + + +def _parse_timeout(value: str) -> float: + """'15m' / '900s' / '900' → 초(float).""" + v = str(value).strip().lower() + try: + if v.endswith("m"): + return float(v[:-1]) * 60 + if v.endswith("s"): + return float(v[:-1]) + return float(v) + except ValueError: + return 900.0 + + +def _extract_url(results) -> str: + """job 의 results 에서 영상 URL 추출. raw(풀화질) 우선, 없으면 min.""" + if isinstance(results, dict): + for key in ("raw", "min", "result", "output"): + v = results.get(key) + if isinstance(v, dict) and v.get("url"): + return v["url"] + if isinstance(v, str): + return v + raise RuntimeError(f"Higgsfield 결과에서 영상 URL을 찾지 못함: {results!r}") + + +def _generate_api( + prompt: str, image_paths: list[Path], duration: int, wait_timeout: str, +) -> tuple[Path, float]: + try: + import higgsfield_client as hf # 공식 SDK (업로드/인증) + from higgsfield_client.http.client import SyncClient + except ImportError as e: + raise RuntimeError( + "higgsfield-client 미설치. `pip install higgsfield-client` 또는 이미지 재빌드 필요." + ) from e + + if not (os.environ.get("HF_KEY") or + (os.environ.get("HF_API_KEY") and os.environ.get("HF_API_SECRET"))): + raise RuntimeError( + "Higgsfield API 자격증명 없음. HF_KEY='key:secret' 또는 " + "HIGGSFIELD_API_KEY/HIGGSFIELD_API_SECRET(.env) 설정 필요." + ) + + # 1) 입력 사진 업로드 → URL. + image_urls = [hf.upload_file(str(p)) for p in image_paths[:cfg.HIGGSFIELD_API_MAX_IMAGES]] + if not image_urls: + raise RuntimeError("업로드된 입력 이미지가 없습니다.") + + # 2) 생성 요청. 실인자는 'params' 로 감싼다(실측: 미래핑 시 422). + # 엔드포인트별 이미지 필드 구조가 다름(platform API 실측): + # · DoP : input_images (복수 배열) + # · Seedance : input_image (단수 object). model enum=seedance_pro|seedance_lite, + # aspect_ratio 9:16 네이티브 지원, duration 3~12. + common = { + "model": cfg.HIGGSFIELD_API_VARIANT, + "prompt": prompt, + "aspect_ratio": cfg.HIGGSFIELD_ASPECT_RATIO, + "duration": duration, + } + if "seedance" in cfg.HIGGSFIELD_API_APP.lower(): + params = {**common, "input_image": {"type": "image_url", "image_url": image_urls[0]}} + else: + params = {**common, + "input_images": [{"type": "image_url", "image_url": u} for u in image_urls]} + body = {"params": params} + http = SyncClient()._client # 인증·base_url 설정된 httpx.Client + resp = http.post(cfg.HIGGSFIELD_API_APP, json=body) + resp.raise_for_status() + job_set_id = resp.json()["id"] + + # 3) 완료까지 폴링 + timeout_s = _parse_timeout(wait_timeout) + waited, interval = 0.0, 5.0 + while True: + js = http.get(f"/v1/job-sets/{job_set_id}") + js.raise_for_status() + job = js.json()["jobs"][0] + status = (job.get("status") or "").lower() + if status == "completed": + break + if status in _TERMINAL: + raise RuntimeError(f"Higgsfield 생성 실패: status={status} (job_set {job_set_id})") + if waited >= timeout_s: + raise RuntimeError(f"Higgsfield 생성 타임아웃({wait_timeout}, job_set {job_set_id})") + time.sleep(interval) + waited += interval + + # 4) 결과 영상 다운로드 (credits 는 응답에 없어 0.0) + return _download(_extract_url(job["results"])), 0.0 + + +# ============================ 디스패처 ============================ + +def generate( + prompt: str, + image_paths: list[Path], + duration: int = cfg.HIGGSFIELD_DURATION, + dry_run: bool = False, + wait_timeout: str = cfg.HIGGSFIELD_WAIT_TIMEOUT, +) -> tuple[Path, float]: + """Return (local mp4 path, credits spent). 백엔드는 cfg.HIGGSFIELD_BACKEND.""" + if dry_run: + return DEMO_VIDEO, 0.0 + if cfg.HIGGSFIELD_BACKEND == "api": + return _generate_api(prompt, image_paths, duration, wait_timeout) + return _generate_cli(prompt, image_paths, duration, wait_timeout) diff --git a/server/app/pipeline/remotion_render.py b/server/app/pipeline/remotion_render.py index d184550..3f9d525 100644 --- a/server/app/pipeline/remotion_render.py +++ b/server/app/pipeline/remotion_render.py @@ -12,6 +12,7 @@ import subprocess import uuid from pathlib import Path +from app import config as cfg from app.schemas import VideoSpec ENGINE = Path(__file__).resolve().parents[3] # engine/higgsfield_shorts @@ -19,7 +20,7 @@ REMOTION = ENGINE / "remotion" PUBLIC = REMOTION / "public" OUT_DIR = REMOTION / "out" -FPS = 30 +FPS = cfg.REMOTION_FPS def _probe_frames(video_path: Path, fps: int = FPS) -> tuple[int, int, int]: @@ -38,14 +39,23 @@ def _probe_frames(video_path: Path, fps: int = FPS) -> tuple[int, int, int]: def _timings(total: int) -> dict: - """Default 4-beat layout, clamped to the actual video length.""" - def clamp(f: int) -> int: - return max(0, min(f, total)) + """4-beat 레이아웃을 실제 프레임 수에 비례 스케일. + + 240프레임(8초 @30fps)을 기준으로 설계됐으나, 영상이 그보다 짧거나 길어도 + 각 구간 비율이 유지되도록 sc()로 선형 변환한다. + 고정값 clamp 방식은 짧은 영상에서 brandLines 창이 너무 좁아져 + interpolate 범위가 뒤집히는 크래시를 유발했음. + """ + REF = 240 # 기준 총 프레임 (8s @30fps) + + def sc(f: int) -> int: + return max(0, min(round(f * total / REF), total)) + return { - "hook": {"fromFrame": 0, "toFrame": clamp(78)}, - "sellingPoint": {"fromFrame": clamp(80), "toFrame": clamp(138)}, - "brandLines": {"fromFrame": clamp(138), "toFrame": clamp(196)}, - "endCard": {"fromFrame": clamp(total - 49), "toFrame": total}, + "hook": {"fromFrame": 0, "toFrame": sc(78)}, + "sellingPoint": {"fromFrame": sc(80), "toFrame": sc(138)}, + "brandLines": {"fromFrame": sc(138), "toFrame": sc(196)}, + "endCard": {"fromFrame": max(0, total - sc(49)), "toFrame": total}, } @@ -74,17 +84,24 @@ def build_props(spec: VideoSpec, base_video: Path) -> tuple[dict, str]: return props, job -def render(spec: VideoSpec, base_video: Path) -> Path: +def render(spec: VideoSpec, base_video: Path) -> tuple[Path, list[Path]]: + """렌더 후 호출자가 지울 임시 파일 목록도 함께 반환한다. + + 반환값: (최종 mp4, [삭제 대상 임시 파일]) + 호출자는 최종 mp4를 outputs/ 로 복사한 뒤 반환된 목록을 삭제해야 한다. + """ OUT_DIR.mkdir(parents=True, exist_ok=True) - props, _ = build_props(spec, base_video) + props, pub_job_name = build_props(spec, base_video) props_file = OUT_DIR / f"props_{uuid.uuid4().hex[:8]}.json" props_file.write_text(json.dumps(props, ensure_ascii=False), encoding="utf-8") out_file = OUT_DIR / f"final_{uuid.uuid4().hex[:8]}.mp4" subprocess.run( - ["npx", "remotion", "render", "MumumShort", str(out_file), + ["npx", "remotion", "render", cfg.REMOTION_COMPOSITION, str(out_file), f"--props={props_file}"], cwd=str(REMOTION), check=True, ) - return out_file + # 임시 파일: remotion/public/ 의 베이스 복사본 + props JSON (out_file 은 별도 반환) + temps = [PUBLIC / pub_job_name, props_file] + return out_file, temps diff --git a/server/app/pipeline/spec_builder.py b/server/app/pipeline/spec_builder.py index 5321140..1f905cf 100644 --- a/server/app/pipeline/spec_builder.py +++ b/server/app/pipeline/spec_builder.py @@ -1,19 +1,20 @@ -"""① LLM step — interview answers → VideoSpec (JSON). +"""① LLM step — interview answers → VideoSpec / ScriptResult (JSON). -Uses the Anthropic Python SDK (claude-opus-4-7) with structured outputs so the -result is guaranteed to match the Remotion data contract. The guardrail system -prompt is cached (stable prefix) to cut cost/latency across requests. +Uses the OpenAI SDK with Structured Outputs (response_format json_schema, strict) +so the result is guaranteed to match the Remotion data contract. The stable +guardrail system prompt benefits from OpenAI's automatic prompt caching. """ from __future__ import annotations import json -import anthropic +from openai import OpenAI +from app import config as cfg from app.schemas import GenerateRequest, VideoSpec, ScriptResult -MODEL = "claude-opus-4-7" +MODEL = cfg.OPENAI_MODEL -# Stable, cacheable guardrail + role prompt. +# Stable guardrail + role prompt (automatic prompt caching applies to this prefix). SYSTEM_PROMPT = """\ 당신은 한국 로컬 비즈니스(숙소·매장·제품)의 8초 세로형 숏폼 광고 카피 디렉터입니다. 사장/마케터가 직접 답한 정보만으로, 영상 사양(VideoSpec)을 설계합니다. 복잡한 분석은 하지 않습니다. @@ -41,10 +42,19 @@ SYSTEM_PROMPT = """\ - place + 뷰/활동성/포토스팟, 또는 product 일반 → "Rhythm Reveal" - 파티/워터파크/대형 액티비티 → "Maximum Viral" -[higgsfield_prompt] 영문. marketing_studio_video tv_spot용. 빠르고 역동적인 카메라(quick punchy moves, crash zoom, kinetic glide). 유형 반영: place=공간 투어, product=제품 쇼케이스. 반드시 "keep structure realistic, do not distort" 포함. +[higgsfield_prompt] 영문. marketing_studio_video 용 고품질 프롬프트. 아래 구조를 반드시 따를 것. + +① 포맷 명시: "VERTICAL 9:16 portrait format, mobile short-form." +② 카메라 무브 (place 유형): 드론/핸드헬드 하이브리드로 실내외를 역동적으로 순회. + 예시 패턴: "smooth drone glide through entrance → quick crash-zoom onto hero detail → kinetic handheld reveal of interior space → pull-back drone shot of exterior" + product 유형: "macro crash-zoom onto product → slow orbit reveal → snappy cut to detail closeup" +③ 분위기: 시간대(twilight/golden hour/daylight)·색감(teal-and-orange grade 또는 유형에 맞는 톤)·질감(subtle film grain, quiet luxury)을 구체적으로 명시. +④ 반드시 포함할 문구: "photorealistic, no AI artifacts, keep architecture and objects structurally accurate, do not warp or distort walls floors or furniture, no morphing, no hallucinated details." +⑤ 마지막 문장: "Hook viewer in first second. Beat-driven energetic edit." +유형(place/product/message)과 업체 분위기에 맞게 세부 내용을 구체화할 것. 절대 짧거나 추상적인 문장으로 끝내지 말 것. """ -# Structured-output JSON schema (additionalProperties:false everywhere). +# Structured-output JSON schema (strict: additionalProperties:false + all props required). _SPEC_SCHEMA = { "type": "object", "additionalProperties": False, @@ -83,36 +93,50 @@ _SPEC_SCHEMA = { } -def build_spec(req: GenerateRequest, client: anthropic.Anthropic | None = None) -> VideoSpec: - client = client or anthropic.Anthropic() +def _kind_label(kind: str) -> str: + return { + "place": "장소", + "message": "메시지(축하·안부·홍보)", + "product": "물건(제품·음식 등 정적 아이템)", + }.get(kind, kind) - user_msg = ( - f"유형: {'장소' if req.kind == 'place' else '메시지(축하·안부·홍보)' if req.kind == 'message' else '물건(제품·음식 등 정적 아이템)'}\n" + +def _user_msg(req: GenerateRequest, tail: str) -> str: + return ( + f"유형: {_kind_label(req.kind)}\n" f"업체/상품명: {req.biz_name}\n" f"주소/판매URL: {req.addr or '(없음)'}\n" f"가격: {req.price or '(없음)'}\n" f"강력한 한방 셀링포인트: {req.selling}\n\n" - "위 정보로 VideoSpec을 설계해줘." + f"{tail}" ) - resp = client.messages.create( + +def _complete(client: OpenAI, system: str, user: str, schema_name: str, + schema: dict, max_tokens: int) -> dict: + """OpenAI Structured Outputs 호출 → 검증된 JSON(dict) 반환.""" + resp = client.chat.completions.create( model=MODEL, - max_tokens=2048, - thinking={"type": "adaptive"}, - output_config={ - "effort": "medium", - "format": {"type": "json_schema", "name": "video_spec", "schema": _SPEC_SCHEMA}, + max_completion_tokens=max_tokens, + response_format={ + "type": "json_schema", + "json_schema": {"name": schema_name, "strict": True, "schema": schema}, }, - system=[{ - "type": "text", - "text": SYSTEM_PROMPT, - "cache_control": {"type": "ephemeral"}, - }], - messages=[{"role": "user", "content": user_msg}], + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], ) + return json.loads(resp.choices[0].message.content) - text = "".join(b.text for b in resp.content if b.type == "text") - return VideoSpec.model_validate(json.loads(text)) + +def build_spec(req: GenerateRequest, client: OpenAI | None = None) -> VideoSpec: + client = client or OpenAI() + data = _complete( + client, SYSTEM_PROMPT, _user_msg(req, "위 정보로 VideoSpec을 설계해줘."), + "video_spec", _SPEC_SCHEMA, max_tokens=2048, + ) + return VideoSpec.model_validate(data) # ---------- 자막 스크립트 4블록 ---------- @@ -146,26 +170,10 @@ _SCRIPT_SCHEMA = { } -def build_script(req: GenerateRequest, client: anthropic.Anthropic | None = None) -> ScriptResult: - client = client or anthropic.Anthropic() - user_msg = ( - f"유형: {'장소' if req.kind == 'place' else '메시지(축하·안부·홍보)' if req.kind == 'message' else '물건(제품·음식 등 정적 아이템)'}\n" - f"업체/상품명: {req.biz_name}\n" - f"주소/판매URL: {req.addr or '(없음)'}\n" - f"가격: {req.price or '(없음)'}\n" - f"강력한 한방 셀링포인트: {req.selling}\n\n" - "위 정보로 4블록 자막 스크립트를 만들어줘." +def build_script(req: GenerateRequest, client: OpenAI | None = None) -> ScriptResult: + client = client or OpenAI() + data = _complete( + client, SCRIPT_SYSTEM, _user_msg(req, "위 정보로 4블록 자막 스크립트를 만들어줘."), + "script", _SCRIPT_SCHEMA, max_tokens=1024, ) - resp = client.messages.create( - model=MODEL, - max_tokens=1024, - thinking={"type": "adaptive"}, - output_config={ - "effort": "low", - "format": {"type": "json_schema", "name": "script", "schema": _SCRIPT_SCHEMA}, - }, - system=[{"type": "text", "text": SCRIPT_SYSTEM, "cache_control": {"type": "ephemeral"}}], - messages=[{"role": "user", "content": user_msg}], - ) - text = "".join(b.text for b in resp.content if b.type == "text") - return ScriptResult.model_validate(json.loads(text)) + return ScriptResult.model_validate(data) diff --git a/server/app/schemas.py b/server/app/schemas.py index 1db6d8c..950692b 100644 --- a/server/app/schemas.py +++ b/server/app/schemas.py @@ -62,3 +62,20 @@ class GenerateResult(BaseModel): profile: str cost_credits: float = 0.0 job_id: Optional[str] = None + + +# ---------- Outbound: 비동기 작업 상태 (폴링) ---------- +class JobStatus(BaseModel): + job_id: str + # queued: 큐 대기 / running: 생성 중 / done: 완료 / error: 실패 + status: Literal["queued", "running", "done", "error"] + stage: Optional[str] = Field(None, description="현재 단계: spec | higgsfield | remotion") + stage_index: int = Field(0, description="단계 인덱스 0..2 (프론트 진행바 매핑)") + progress: int = Field(0, description="전체 진행률 0..100 (대략)") + # 완료 시 채워지는 결과 필드 (GenerateResult 와 동일 의미) + video_url: Optional[str] = None + caption: Optional[str] = None + profile: Optional[str] = None + cost_credits: float = 0.0 + # 실패 시 사용자에게 보여줄 메시지 + error: Optional[str] = None diff --git a/server/pyproject.toml b/server/pyproject.toml index fa296a1..92c951e 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -7,9 +7,19 @@ dependencies = [ "fastapi>=0.115", "uvicorn[standard]>=0.30", "python-multipart>=0.0.9", - "anthropic>=0.69", + "openai>=1.50", "pydantic>=2.7", + "higgsfield-client>=0.1", # 공식 SDK — HIGGSFIELD_BACKEND=api 일 때 사용 + "fal-client>=0.5", # fal.ai SDK — VIDEO_BACKEND=fal (Seedance 2.0 멀티이미지) + "httpx>=0.27", # 결과 영상 다운로드 (CDN UA 차단 회피) ] +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["app", "app.pipeline"] + [tool.uvicorn] -# run: uv run uvicorn app.main:app --reload --port 8000 +# run: uv run uvicorn app.main:app --reload --port 10001 diff --git a/webapp/index.html b/webapp/index.html index 4886459..a2bdd7d 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -215,10 +215,11 @@ const Icon = { }; /* ===== Config ===== */ -// 같은 출처(FastAPI가 이 페이지를 서빙)면 "" 로 두면 /api/generate 로 전송. -// 백엔드 미연결 시 fetch 실패 → 자동으로 데모 결과로 폴백. -const API_BASE = ""; -const DEMO_VIDEO = "./demo/mumum.mp4"; +// 백엔드 주소. 같은 출처(FastAPI가 이 페이지를 서빙)면 "" 로 두면 /api/generate 로 전송. +// 프론트/백엔드를 분리 배포할 땐 빌드/배포 시점에 window.__API_BASE__ 를 head 에 주입 +// 예) window.__API_BASE__ = "https://api.example.com"; +// 생성 실패/서버 미연결 시 데모 영상은 표시하지 않고 에러 카드만 보여준다. +const API_BASE = (typeof window !== "undefined" && window.__API_BASE__) || ""; const STEPS = [ { k:"setup", n:"1", b:"사진 업로드", s:"4~5장" }, @@ -245,7 +246,8 @@ function App(){ const [price, setPrice] = useState(""); const [selling, setSelling] = useState(""); // 강력한 한방 셀링포인트 const [prog, setProg] = useState([0,0,0]); - const [videoUrl, setVideoUrl] = useState(DEMO_VIDEO); + const [videoUrl, setVideoUrl] = useState(null); // 성공 시에만 채움 (데모 폴백 없음) + const [errMsg, setErrMsg] = useState(""); // 생성 실패 메시지 (있으면 결과 화면이 에러 카드로 전환) const [srvCaption, setSrvCaption] = useState(null); const [srvProfile, setSrvProfile] = useState(null); const [editedCaption, setEditedCaption] = useState(""); @@ -255,7 +257,10 @@ function App(){ const [scriptEditing, setScriptEditing] = useState({}); const [scriptLoading, setScriptLoading] = useState(false); const fileRef = useRef(); - const doneRef = useRef({ bars:false, fetch:false }); + const pollRef = useRef(null); // 진행 중인 폴링 타이머 (언마운트/리셋 시 정리) + + // 언마운트 시 폴링 타이머 정리 + useEffect(()=>()=>{ if(pollRef.current) clearTimeout(pollRef.current); }, []); // 톤 자동 매칭 (인텔리전스 IP를 가볍게 노출 — 유형 기반) const tone = kind === "product" @@ -282,29 +287,61 @@ function App(){ } function removePhoto(i){ setPhotos(p=>p.filter((_,idx)=>idx!==i)); } - function finishIfReady(){ - if(doneRef.current.bars && doneRef.current.fetch) setStep("result"); + // 작업 상태(stage_index 0..2)를 3개 진행바 배열로 변환. + // 이전 단계 = 100%, 현재 단계 = 진행 중(서버 progress 또는 점진 증가), 이후 단계 = 0% + function barsForStatus(st, prevBars){ + const idx = st.stage_index || 0; + return [0,1,2].map(i=>{ + if(st.status === "done") return 100; + if(i < idx) return 100; + if(i > idx) return 0; + // 현재 진행 중인 바: 95%까지 조금씩 차오르게(완료 신호는 폴링이 줌) + const cur = (prevBars && prevBars[i]) || 0; + return Math.min(95, Math.max(cur + Math.round(Math.random()*8+4), 8)); + }); } + + function stopPolling(){ if(pollRef.current){ clearTimeout(pollRef.current); pollRef.current = null; } } + + // 작업 완료/실패 시 결과 화면으로 + function finishWith(st){ + if(st.video_url) setVideoUrl((API_BASE||"") + st.video_url); + if(st.caption){ setSrvCaption(st.caption); setEditedCaption(st.caption); } + if(st.profile) setSrvProfile(st.profile); + setProg([100,100,100]); + setStep("result"); + } + + // job_id 를 주기적으로 폴링하며 진행바를 갱신 + function pollJob(jobId){ + fetch(`${API_BASE}/api/jobs/${jobId}`) + .then(r=> r.ok ? r.json() : Promise.reject(new Error("HTTP "+r.status))) + .then(st=>{ + setProg(prev=> barsForStatus(st, prev)); + if(st.caption && !srvCaption){ setSrvCaption(st.caption); setEditedCaption(st.caption); } + if(st.profile) setSrvProfile(st.profile); + if(st.status === "done"){ finishWith(st); return; } + if(st.status === "error"){ + // 실패 시 데모 영상 표시 안 함 — 에러 카드만 보여준다. + setVideoUrl(null); + setErrMsg(st.error || "알 수 없는 오류로 생성에 실패했습니다."); + setProg([100,100,100]); setStep("result"); + return; + } + pollRef.current = setTimeout(()=>pollJob(jobId), 2000); // 2초 간격 폴링 + }) + .catch(()=>{ // 폴링 실패 → 잠시 후 재시도 (네트워크 일시 단절 대비) + pollRef.current = setTimeout(()=>pollJob(jobId), 3000); + }); + } + function startGenerate(){ + stopPolling(); setStep("generating"); - setProg([0,0,0]); setNote(""); setSrvCaption(null); setCopied(false); + setProg([6,0,0]); setNote(""); setErrMsg(""); setVideoUrl(null); setSrvCaption(null); setCopied(false); setSrvProfile(tone.profile); setEditedCaption(caption); // 로컬 폴백; 서버 응답 오면 교체 - doneRef.current = { bars:false, fetch:false }; - // 진행 바 애니메이션 (시각용) - [0,1,2].forEach((agent)=>{ - let p = 0; const base = agent*1600; - const tick = ()=>{ - p += Math.random()*22+10; if(p>100) p=100; - setProg(prev=>{ const n=[...prev]; n[agent]=Math.round(p); return n; }); - if(p<100) setTimeout(tick, 260); - else if(agent===2){ doneRef.current.bars=true; finishIfReady(); } - }; - setTimeout(tick, base+300); - }); - - // 실제 생성 요청 (실패 시 데모 폴백) const fd = new FormData(); fd.append("kind", kind); fd.append("biz_name", bizName); @@ -313,20 +350,20 @@ function App(){ if(price) fd.append("price", price); photos.forEach(p=> p.file && fd.append("photos", p.file, p.name)); + // 1) 작업 제출 → job_id 즉시 수신, 2) job_id 폴링 fetch(`${API_BASE}/api/generate`, { method:"POST", body:fd }) .then(r=> r.ok ? r.json() : Promise.reject(new Error("HTTP "+r.status))) - .then(res=>{ - setVideoUrl((API_BASE||"") + res.video_url); - if(res.caption){ setSrvCaption(res.caption); setEditedCaption(res.caption); } - if(res.profile) setSrvProfile(res.profile); + .then(st=>{ + if(!st.job_id) throw new Error("no job_id"); + pollJob(st.job_id); }) - .catch(()=>{ - setVideoUrl(DEMO_VIDEO); - setNote("백엔드 미연결 — 데모 결과를 표시합니다. (서버 실행 시 실제 생성)"); - }) - .finally(()=>{ doneRef.current.fetch=true; finishIfReady(); }); + .catch(()=>{ // 서버 연결 실패 → 데모 안 띄우고 에러 카드 + setVideoUrl(null); + setErrMsg("서버에 연결하지 못했습니다. 잠시 후 다시 시도해 주세요."); + setProg([100,100,100]); setStep("result"); + }); } - function reset(){ setStep("setup"); setPhotos([]); setKind(""); setBizName(""); setAddr(""); setPrice(""); setSelling(""); setProg([0,0,0]); setNote(""); setSrvCaption(null); setSrvProfile(null); setEditedCaption(""); setCopied(false); setVideoUrl(DEMO_VIDEO); setScript(null); setScriptEditing({}); } + function reset(){ stopPolling(); setStep("setup"); setPhotos([]); setKind(""); setBizName(""); setAddr(""); setPrice(""); setSelling(""); setProg([0,0,0]); setNote(""); setErrMsg(""); setSrvCaption(null); setSrvProfile(null); setEditedCaption(""); setCopied(false); setVideoUrl(null); setScript(null); setScriptEditing({}); } function copyCaption(){ navigator.clipboard.writeText(editedCaption).then(()=>{ setCopied(true); setTimeout(()=>setCopied(false), 1800); }); @@ -549,8 +586,19 @@ function App(){ )} - {/* STEP 4 — RESULT */} - {step==="result" && ( + {/* STEP 4 — RESULT (성공: 영상 / 실패: 에러 카드) */} + {step==="result" && errMsg && ( +
+

영상 생성에 실패했습니다

+

아래 사유를 확인하고 다시 시도해 주세요. (데모 영상은 표시하지 않습니다)

+
{errMsg}
+
+ + +
+
+ )} + {step==="result" && !errMsg && videoUrl && (