seedance 적용.
parent
4deefff061
commit
154bf5b992
|
|
@ -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
|
||||||
|
|
@ -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}"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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" },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue