seedance 적용.

main
hbyang 2026-06-01 13:41:51 +09:00
parent 4deefff061
commit 154bf5b992
18 changed files with 942 additions and 179 deletions

33
.dockerignore Normal file
View File

@ -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

45
Dockerfile Normal file
View File

@ -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}"]

22
docker-compose.yml Normal file
View File

@ -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

View File

@ -24,9 +24,14 @@ export const BrandLines: React.FC<{
> >
{lines.map((line, i) => { {lines.map((line, i) => {
const start = fromFrame + i * LINE_STAGGER; 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( const opacity = interpolate(
frame, frame,
[start, start + 12, toFrame - 12, toFrame], [start, fadeIn, fadeOut, end],
[0, 1, 1, 0], [0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }, { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
); );

View File

@ -25,9 +25,12 @@ export const HookTitle: React.FC<{
}); });
const translateY = interpolate(enter, [0, 1], [-60, 0]); 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( const opacity = interpolate(
frame, frame,
[fromFrame, fromFrame + 8, toFrame - 12, toFrame], [fromFrame, fadeIn, fadeOut, end],
[0, 1, 1, 0], [0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }, { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
); );

View File

@ -37,9 +37,12 @@ export const SellingBadge: React.FC<{
config: { damping: 200, mass: 0.5 }, config: { damping: 200, mass: 0.5 },
}); });
const rise = interpolate(enter, [0, 1], [22, 0]); 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( const appear = interpolate(
frame, frame,
[start, start + 8, toFrame - 10, toFrame], [start, fadeIn, fadeOut, end],
[0, 1, 1, 0], [0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }, { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
); );

View File

@ -1,15 +1,21 @@
// 한글 폰트 로딩 (@remotion/google-fonts, korean subset) // 한글 폰트 로딩 (@remotion/google-fonts, korean subset)
// Hook = Black Han Sans (굵고 강렬한 바이럴 헤드라인) // Hook = Black Han Sans (굵고 강렬한 바이럴 헤드라인)
// Body/Brand = Nanum Myeongjo (우아한 명조 — 머뭄의 정적 브랜드 톤) // 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 loadHook } from "@remotion/google-fonts/BlackHanSans";
import { loadFont as loadSerif } from "@remotion/google-fonts/NanumMyeongjo"; import { loadFont as loadSerif } from "@remotion/google-fonts/NanumMyeongjo";
export const hookFont = loadHook("normal", { export const hookFont = loadHook("normal", {
weights: ["400"], weights: ["400"],
subsets: ["korean", "latin"], subsets: ["korean"],
ignoreTooManyRequestsWarning: true,
}).fontFamily; }).fontFamily;
export const serifFont = loadSerif("normal", { export const serifFont = loadSerif("normal", {
weights: ["400", "700", "800"], weights: ["400", "700"], // 800은 NanumMyeongjo에 없음 → 제거
subsets: ["korean", "latin"], subsets: ["korean"],
ignoreTooManyRequestsWarning: true,
}).fontFamily; }).fontFamily;

View File

@ -1,6 +1,70 @@
# ADO2 Higgsfield Shorts server # ADO2 Higgsfield Shorts server — 이 파일을 .env 로 복사해 채우세요 (.env 는 gitignore됨)
# Claude API (spec_builder)
ANTHROPIC_API_KEY=
# Higgsfield CLI must be authenticated separately: `higgsfield auth login` # --- OpenAI API (spec_builder, 필수) ---
# (the server shells out to the CLI; no key needed here) 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=

91
server/app/config.py Normal file
View File

@ -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.<NAME>`.
"""
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")

181
server/app/jobs.py Normal file
View File

@ -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

View File

@ -1,7 +1,8 @@
"""④ FastAPI orchestration — LLM → Higgsfield → Remotion wrapper. """④ FastAPI orchestration — LLM → Higgsfield → Remotion wrapper.
POST /api/generate (multipart: photos[] + interview fields) POST /api/generate (multipart: photos[] + interview fields) job_id 즉시 반환
build_spec (Claude) higgsfield.generate remotion.render mp4 백그라운드 워커: build_spec (OpenAI) higgsfield.generate remotion.render mp4
GET /api/jobs/{id} 작업 상태 폴링 (queued | running | done | error)
GET /api/health GET /api/health
Static: /outputs/<file> (final videos), / (the web app) Static: /outputs/<file> (final videos), / (the web app)
""" """
@ -16,18 +17,18 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.schemas import GenerateRequest, GenerateResult, ScriptResult from app import config as cfg, jobs
from app.pipeline import spec_builder, higgsfield_client, remotion_render from app.schemas import GenerateRequest, JobStatus, ScriptResult
from app.pipeline import spec_builder
ENGINE = Path(__file__).resolve().parents[2] # engine/higgsfield_shorts WEBAPP = cfg.WEBAPP_DIR
WEBAPP = ENGINE / "webapp" OUTPUTS = cfg.OUTPUTS_DIR
OUTPUTS = ENGINE / "server" / "outputs" UPLOADS = cfg.UPLOADS_DIR
UPLOADS = ENGINE / "server" / ".uploads"
OUTPUTS.mkdir(parents=True, exist_ok=True) OUTPUTS.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="ADO2 Higgsfield Shorts") app = FastAPI(title="ADO2 Higgsfield Shorts")
app.add_middleware( 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) @app.post("/api/captions", response_model=ScriptResult)
def captions(req: GenerateRequest): def captions(req: GenerateRequest):
"""인터뷰 답변 → 인트로·셀링포인트·감성스토리·CTA 4블록 (Claude).""" """인터뷰 답변 → 인트로·셀링포인트·감성스토리·CTA 4블록 (OpenAI)."""
try: try:
return spec_builder.build_script(req) return spec_builder.build_script(req)
except Exception as e: except Exception as e:
raise HTTPException(502, f"자막 생성 실패: {e}") from e raise HTTPException(502, f"자막 생성 실패: {e}") from e
@app.post("/api/generate", response_model=GenerateResult) @app.post("/api/generate", response_model=JobStatus, status_code=202)
async def generate( def generate( # 논블로킹: 사진만 저장하고 작업을 큐에 넣은 뒤 job_id 반환
photos: list[UploadFile] = File(...), photos: list[UploadFile] = File(...),
kind: str = Form(...), kind: str = Form(...),
biz_name: str = Form(...), biz_name: str = Form(...),
@ -58,49 +59,36 @@ async def generate(
if len(photos) < 4: if len(photos) < 4:
raise HTTPException(400, "사진 4장 이상이 필요합니다.") raise HTTPException(400, "사진 4장 이상이 필요합니다.")
# 1. Persist uploaded photos # 업로드 사진을 디스크에 저장 (워커 스레드가 경로로 읽음).
job = uuid.uuid4().hex[:8] # UploadFile 의 스트림은 요청 종료 후 닫히므로 반드시 여기서 영속화한다.
job_dir = UPLOADS / job upload_id = uuid.uuid4().hex[:8]
job_dir = UPLOADS / upload_id
job_dir.mkdir(parents=True, exist_ok=True) job_dir.mkdir(parents=True, exist_ok=True)
photo_paths: list[Path] = [] photo_paths: list[Path] = []
for i, up in enumerate(photos): 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: with dest.open("wb") as f:
shutil.copyfileobj(up.file, f) shutil.copyfileobj(up.file, f)
photo_paths.append(dest) photo_paths.append(dest)
req = GenerateRequest(kind=kind, biz_name=biz_name, addr=addr, price=price, selling=selling) req = GenerateRequest(kind=kind, biz_name=biz_name, addr=addr, price=price, selling=selling)
# 2. LLM → spec # 파이프라인은 백그라운드 워커에서 실행 → 즉시 queued 상태를 돌려준다.
try: job_id = jobs.submit(req, photo_paths, dry_run=dry_run)
spec = spec_builder.build_spec(req) status = jobs.get(job_id)
except Exception as e: # surface as a clean 502, don't swallow return JobStatus(**{k: status[k] for k in JobStatus.model_fields})
raise HTTPException(502, f"spec 생성 실패: {e}") from e
# 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 @app.get("/api/jobs/{job_id}", response_model=JobStatus)
try: def job_status(job_id: str):
final = remotion_render.render(spec, base_video) """작업 진행 상태 폴링. 프론트가 주기적으로 호출."""
except Exception as e: status = jobs.get(job_id)
raise HTTPException(502, f"Remotion 합성 실패: {e}") from e if status is None:
raise HTTPException(404, "작업을 찾을 수 없습니다(만료되었거나 잘못된 ID).")
served = OUTPUTS / f"{job}.mp4" return JobStatus(**{k: status[k] for k in JobStatus.model_fields})
shutil.copy(final, served)
return GenerateResult(
video_url=f"/outputs/{job}.mp4",
caption=spec.caption,
profile=spec.profile,
cost_credits=credits,
job_id=job,
)
# Static mounts (after API routes) # Static mounts (after API routes)

View File

@ -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

View File

@ -1,37 +1,67 @@
"""② Higgsfield step — photos + prompt → completed 8s base video. """② Higgsfield step — photos + prompt → completed 8s base video.
Thin wrapper around the verified CLI flow: 가지 백엔드를 지원한다 (cfg.HIGGSFIELD_BACKEND):
higgsfield generate cost/create marketing_studio_video --mode tv_spot ...
dry_run=True returns a bundled demo clip and spends no credits. · "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 from __future__ import annotations
import json import os
import re import re
import subprocess import subprocess
import urllib.request import time
import uuid import uuid
from pathlib import Path from pathlib import Path
import httpx
from app import config as cfg
ENGINE = Path(__file__).resolve().parents[3] # engine/higgsfield_shorts 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" TMP = ENGINE / "server" / ".tmp"
MODEL = "marketing_studio_video" MODEL = cfg.HIGGSFIELD_MODEL
_URL_RE = re.compile(r'"result_url"\s*:\s*"([^"]+)"') _URL_RE = re.compile(r'"result_url"\s*:\s*"([^"]+)"')
_CREDITS_RE = re.compile(r'"credits(?:_exact)?"\s*:\s*([0-9.]+)') _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]: def _base_args(prompt: str, image_paths: list[Path], duration: int) -> list[str]:
args = ["--prompt", prompt] args = ["--prompt", prompt]
for p in image_paths: for p in image_paths:
args += ["--image", str(p)] args += ["--image", str(p)]
args += ["--mode", "tv_spot", "--duration", str(duration), args += ["--mode", cfg.HIGGSFIELD_MODE, "--duration", str(duration),
"--generate_audio", "true", "--aspect_ratio", "9:16"] "--generate_audio", cfg.HIGGSFIELD_GENERATE_AUDIO,
"--aspect_ratio", cfg.HIGGSFIELD_ASPECT_RATIO]
return args 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( out = subprocess.run(
["higgsfield", "generate", "cost", MODEL, *_base_args(prompt, image_paths, duration), "--json"], ["higgsfield", "generate", "cost", MODEL, *_base_args(prompt, image_paths, duration), "--json"],
capture_output=True, text=True, check=True, 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 return float(m.group(1)) if m else 0.0
def generate( def _generate_cli(
prompt: str, prompt: str, image_paths: list[Path], duration: int, wait_timeout: str,
image_paths: list[Path],
duration: int = 8,
dry_run: bool = False,
wait_timeout: str = "15m",
) -> tuple[Path, float]: ) -> tuple[Path, float]:
"""Return (local mp4 path, credits spent)."""
if dry_run:
return DEMO_VIDEO, 0.0
out = subprocess.run( out = subprocess.run(
["higgsfield", "generate", "create", MODEL, ["higgsfield", "generate", "create", MODEL,
*_base_args(prompt, image_paths, duration), *_base_args(prompt, image_paths, duration),
@ -60,11 +82,125 @@ def generate(
m = _URL_RE.search(out.stdout) m = _URL_RE.search(out.stdout)
if not m: if not m:
raise RuntimeError(f"Higgsfield returned no result_url:\n{out.stdout[-2000:]}") raise RuntimeError(f"Higgsfield returned no result_url:\n{out.stdout[-2000:]}")
url = m.group(1)
credits_m = _CREDITS_RE.search(out.stdout) credits_m = _CREDITS_RE.search(out.stdout)
credits = float(credits_m.group(1)) if credits_m else 0.0 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" # ============================ API 백엔드 ============================
urllib.request.urlretrieve(url, dest) #
return dest, credits # DoP image2video 응답은 공식 SDK의 generic submit() 가정(request_id)과 달라
# 'job set' 구조다(실측 확인). 따라서 SDK 의 저수준 클라이언트(인증·base_url)만
# 빌려 직접 흐름을 구현한다:
# POST {app} → {"id": <job_set_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)

View File

@ -12,6 +12,7 @@ import subprocess
import uuid import uuid
from pathlib import Path from pathlib import Path
from app import config as cfg
from app.schemas import VideoSpec from app.schemas import VideoSpec
ENGINE = Path(__file__).resolve().parents[3] # engine/higgsfield_shorts ENGINE = Path(__file__).resolve().parents[3] # engine/higgsfield_shorts
@ -19,7 +20,7 @@ REMOTION = ENGINE / "remotion"
PUBLIC = REMOTION / "public" PUBLIC = REMOTION / "public"
OUT_DIR = REMOTION / "out" OUT_DIR = REMOTION / "out"
FPS = 30 FPS = cfg.REMOTION_FPS
def _probe_frames(video_path: Path, fps: int = FPS) -> tuple[int, int, int]: 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: def _timings(total: int) -> dict:
"""Default 4-beat layout, clamped to the actual video length.""" """4-beat 레이아웃을 실제 프레임 수에 비례 스케일.
def clamp(f: int) -> int:
return max(0, min(f, total)) 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 { return {
"hook": {"fromFrame": 0, "toFrame": clamp(78)}, "hook": {"fromFrame": 0, "toFrame": sc(78)},
"sellingPoint": {"fromFrame": clamp(80), "toFrame": clamp(138)}, "sellingPoint": {"fromFrame": sc(80), "toFrame": sc(138)},
"brandLines": {"fromFrame": clamp(138), "toFrame": clamp(196)}, "brandLines": {"fromFrame": sc(138), "toFrame": sc(196)},
"endCard": {"fromFrame": clamp(total - 49), "toFrame": total}, "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 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) 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 = OUT_DIR / f"props_{uuid.uuid4().hex[:8]}.json"
props_file.write_text(json.dumps(props, ensure_ascii=False), encoding="utf-8") props_file.write_text(json.dumps(props, ensure_ascii=False), encoding="utf-8")
out_file = OUT_DIR / f"final_{uuid.uuid4().hex[:8]}.mp4" out_file = OUT_DIR / f"final_{uuid.uuid4().hex[:8]}.mp4"
subprocess.run( subprocess.run(
["npx", "remotion", "render", "MumumShort", str(out_file), ["npx", "remotion", "render", cfg.REMOTION_COMPOSITION, str(out_file),
f"--props={props_file}"], f"--props={props_file}"],
cwd=str(REMOTION), check=True, 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

View File

@ -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 Uses the OpenAI SDK with Structured Outputs (response_format json_schema, strict)
result is guaranteed to match the Remotion data contract. The guardrail system so the result is guaranteed to match the Remotion data contract. The stable
prompt is cached (stable prefix) to cut cost/latency across requests. guardrail system prompt benefits from OpenAI's automatic prompt caching.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import anthropic from openai import OpenAI
from app import config as cfg
from app.schemas import GenerateRequest, VideoSpec, ScriptResult 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 = """\ SYSTEM_PROMPT = """\
당신은 한국 로컬 비즈니스(숙소·매장·제품) 8 세로형 숏폼 광고 카피 디렉터입니다. 당신은 한국 로컬 비즈니스(숙소·매장·제품) 8 세로형 숏폼 광고 카피 디렉터입니다.
사장/마케터가 직접 답한 정보만으로, 영상 사양(VideoSpec) 설계합니다. 복잡한 분석은 하지 않습니다. 사장/마케터가 직접 답한 정보만으로, 영상 사양(VideoSpec) 설계합니다. 복잡한 분석은 하지 않습니다.
@ -41,10 +42,19 @@ SYSTEM_PROMPT = """\
- place + /활동성/포토스팟, 또는 product 일반 "Rhythm Reveal" - place + /활동성/포토스팟, 또는 product 일반 "Rhythm Reveal"
- 파티/워터파크/대형 액티비티 "Maximum Viral" - 파티/워터파크/대형 액티비티 "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 = { _SPEC_SCHEMA = {
"type": "object", "type": "object",
"additionalProperties": False, "additionalProperties": False,
@ -83,36 +93,50 @@ _SPEC_SCHEMA = {
} }
def build_spec(req: GenerateRequest, client: anthropic.Anthropic | None = None) -> VideoSpec: def _kind_label(kind: str) -> str:
client = client or anthropic.Anthropic() 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"업체/상품명: {req.biz_name}\n"
f"주소/판매URL: {req.addr or '(없음)'}\n" f"주소/판매URL: {req.addr or '(없음)'}\n"
f"가격: {req.price or '(없음)'}\n" f"가격: {req.price or '(없음)'}\n"
f"강력한 한방 셀링포인트: {req.selling}\n\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, model=MODEL,
max_tokens=2048, max_completion_tokens=max_tokens,
thinking={"type": "adaptive"}, response_format={
output_config={ "type": "json_schema",
"effort": "medium", "json_schema": {"name": schema_name, "strict": True, "schema": schema},
"format": {"type": "json_schema", "name": "video_spec", "schema": _SPEC_SCHEMA},
}, },
system=[{ messages=[
"type": "text", {"role": "system", "content": system},
"text": SYSTEM_PROMPT, {"role": "user", "content": user},
"cache_control": {"type": "ephemeral"}, ],
}],
messages=[{"role": "user", "content": user_msg}],
) )
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블록 ---------- # ---------- 자막 스크립트 4블록 ----------
@ -146,26 +170,10 @@ _SCRIPT_SCHEMA = {
} }
def build_script(req: GenerateRequest, client: anthropic.Anthropic | None = None) -> ScriptResult: def build_script(req: GenerateRequest, client: OpenAI | None = None) -> ScriptResult:
client = client or anthropic.Anthropic() client = client or OpenAI()
user_msg = ( data = _complete(
f"유형: {'장소' if req.kind == 'place' else '메시지(축하·안부·홍보)' if req.kind == 'message' else '물건(제품·음식 등 정적 아이템)'}\n" client, SCRIPT_SYSTEM, _user_msg(req, "위 정보로 4블록 자막 스크립트를 만들어줘."),
f"업체/상품명: {req.biz_name}\n" "script", _SCRIPT_SCHEMA, max_tokens=1024,
f"주소/판매URL: {req.addr or '(없음)'}\n"
f"가격: {req.price or '(없음)'}\n"
f"강력한 한방 셀링포인트: {req.selling}\n\n"
"위 정보로 4블록 자막 스크립트를 만들어줘."
) )
resp = client.messages.create( return ScriptResult.model_validate(data)
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))

View File

@ -62,3 +62,20 @@ class GenerateResult(BaseModel):
profile: str profile: str
cost_credits: float = 0.0 cost_credits: float = 0.0
job_id: Optional[str] = None 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

View File

@ -7,9 +7,19 @@ dependencies = [
"fastapi>=0.115", "fastapi>=0.115",
"uvicorn[standard]>=0.30", "uvicorn[standard]>=0.30",
"python-multipart>=0.0.9", "python-multipart>=0.0.9",
"anthropic>=0.69", "openai>=1.50",
"pydantic>=2.7", "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] [tool.uvicorn]
# run: uv run uvicorn app.main:app --reload --port 8000 # run: uv run uvicorn app.main:app --reload --port 10001

View File

@ -215,10 +215,11 @@ const Icon = {
}; };
/* ===== Config ===== */ /* ===== Config ===== */
// 같은 출처(FastAPI가 이 페이지를 서빙)면 "" 로 두면 /api/generate 로 전송. // 백엔드 주소. 같은 출처(FastAPI가 이 페이지를 서빙)면 "" 로 두면 /api/generate 로 전송.
// 백엔드 미연결 시 fetch 실패 → 자동으로 데모 결과로 폴백. // 프론트/백엔드를 분리 배포할 땐 빌드/배포 시점에 window.__API_BASE__ 를 head 에 주입
const API_BASE = ""; // 예) window.__API_BASE__ = "https://api.example.com";
const DEMO_VIDEO = "./demo/mumum.mp4"; // 생성 실패/서버 미연결 시 데모 영상은 표시하지 않고 에러 카드만 보여준다.
const API_BASE = (typeof window !== "undefined" && window.__API_BASE__) || "";
const STEPS = [ const STEPS = [
{ k:"setup", n:"1", b:"사진 업로드", s:"4~5장" }, { k:"setup", n:"1", b:"사진 업로드", s:"4~5장" },
@ -245,7 +246,8 @@ function App(){
const [price, setPrice] = useState(""); const [price, setPrice] = useState("");
const [selling, setSelling] = useState(""); // 강력한 한방 셀링포인트 const [selling, setSelling] = useState(""); // 강력한 한방 셀링포인트
const [prog, setProg] = useState([0,0,0]); 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 [srvCaption, setSrvCaption] = useState(null);
const [srvProfile, setSrvProfile] = useState(null); const [srvProfile, setSrvProfile] = useState(null);
const [editedCaption, setEditedCaption] = useState(""); const [editedCaption, setEditedCaption] = useState("");
@ -255,7 +257,10 @@ function App(){
const [scriptEditing, setScriptEditing] = useState({}); const [scriptEditing, setScriptEditing] = useState({});
const [scriptLoading, setScriptLoading] = useState(false); const [scriptLoading, setScriptLoading] = useState(false);
const fileRef = useRef(); const fileRef = useRef();
const doneRef = useRef({ bars:false, fetch:false }); const pollRef = useRef(null); // 진행 중인 폴링 타이머 (언마운트/리셋 시 정리)
// 언마운트 시 폴링 타이머 정리
useEffect(()=>()=>{ if(pollRef.current) clearTimeout(pollRef.current); }, []);
// 톤 자동 매칭 (인텔리전스 IP를 가볍게 노출 — 유형 기반) // 톤 자동 매칭 (인텔리전스 IP를 가볍게 노출 — 유형 기반)
const tone = kind === "product" const tone = kind === "product"
@ -282,29 +287,61 @@ function App(){
} }
function removePhoto(i){ setPhotos(p=>p.filter((_,idx)=>idx!==i)); } function removePhoto(i){ setPhotos(p=>p.filter((_,idx)=>idx!==i)); }
function finishIfReady(){ // 작업 상태(stage_index 0..2)를 3개 진행바 배열로 변환.
if(doneRef.current.bars && doneRef.current.fetch) setStep("result"); // 이전 단계 = 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(){ function startGenerate(){
stopPolling();
setStep("generating"); 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); setSrvProfile(tone.profile);
setEditedCaption(caption); // 로컬 폴백; 서버 응답 오면 교체 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(); const fd = new FormData();
fd.append("kind", kind); fd.append("kind", kind);
fd.append("biz_name", bizName); fd.append("biz_name", bizName);
@ -313,20 +350,20 @@ function App(){
if(price) fd.append("price", price); if(price) fd.append("price", price);
photos.forEach(p=> p.file && fd.append("photos", p.file, p.name)); 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 }) fetch(`${API_BASE}/api/generate`, { method:"POST", body:fd })
.then(r=> r.ok ? r.json() : Promise.reject(new Error("HTTP "+r.status))) .then(r=> r.ok ? r.json() : Promise.reject(new Error("HTTP "+r.status)))
.then(res=>{ .then(st=>{
setVideoUrl((API_BASE||"") + res.video_url); if(!st.job_id) throw new Error("no job_id");
if(res.caption){ setSrvCaption(res.caption); setEditedCaption(res.caption); } pollJob(st.job_id);
if(res.profile) setSrvProfile(res.profile);
}) })
.catch(()=>{ .catch(()=>{ // 서버 연결 실패 → 데모 안 띄우고 에러 카드
setVideoUrl(DEMO_VIDEO); setVideoUrl(null);
setNote("백엔드 미연결 — 데모 결과를 표시합니다. (서버 실행 시 실제 생성)"); setErrMsg("서버에 연결하지 못했습니다. 잠시 후 다시 시도해 주세요.");
}) setProg([100,100,100]); setStep("result");
.finally(()=>{ doneRef.current.fetch=true; finishIfReady(); }); });
} }
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(){ function copyCaption(){
navigator.clipboard.writeText(editedCaption).then(()=>{ setCopied(true); setTimeout(()=>setCopied(false), 1800); }); navigator.clipboard.writeText(editedCaption).then(()=>{ setCopied(true); setTimeout(()=>setCopied(false), 1800); });
@ -549,8 +586,19 @@ function App(){
</div> </div>
)} )}
{/* STEP 4 — RESULT */} {/* STEP 4 — RESULT (성공: 영상 / 실패: 에러 카드) */}
{step==="result" && ( {step==="result" && errMsg && (
<div className="card">
<h2 className="res-h">영상 생성에 실패했습니다</h2>
<p className="res-sub">아래 사유를 확인하고 다시 시도해 주세요. (데모 영상은 표시하지 않습니다)</p>
<div className="caption-box" style={{borderColor:"rgba(255,120,120,0.4)"}}>{errMsg}</div>
<div className="res-actions">
<button className="btn btn-primary" onClick={startGenerate}>다시 시도</button>
<button className="btn btn-ghost" onClick={reset}>처음으로</button>
</div>
</div>
)}
{step==="result" && !errMsg && videoUrl && (
<div className="card"> <div className="card">
<div className="result-grid"> <div className="result-grid">
<div className="phone"> <div className="phone">