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) => {
|
||||
const start = fromFrame + i * LINE_STAGGER;
|
||||
// interpolate는 배열이 단조증가해야 한다.
|
||||
// 영상이 짧아 toFrame 이 start+24 이하로 내려오면 범위가 뒤집혀 크래시 발생.
|
||||
const fadeIn = start + 12;
|
||||
const fadeOut = Math.max(fadeIn + 1, toFrame - 12);
|
||||
const end = Math.max(fadeOut + 1, toFrame);
|
||||
const opacity = interpolate(
|
||||
frame,
|
||||
[start, start + 12, toFrame - 12, toFrame],
|
||||
[start, fadeIn, fadeOut, end],
|
||||
[0, 1, 1, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -25,9 +25,12 @@ export const HookTitle: React.FC<{
|
|||
});
|
||||
const translateY = interpolate(enter, [0, 1], [-60, 0]);
|
||||
// 퇴장 페이드
|
||||
const fadeIn = fromFrame + 8;
|
||||
const fadeOut = Math.max(fadeIn + 1, toFrame - 12);
|
||||
const end = Math.max(fadeOut + 1, toFrame);
|
||||
const opacity = interpolate(
|
||||
frame,
|
||||
[fromFrame, fromFrame + 8, toFrame - 12, toFrame],
|
||||
[fromFrame, fadeIn, fadeOut, end],
|
||||
[0, 1, 1, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -37,9 +37,12 @@ export const SellingBadge: React.FC<{
|
|||
config: { damping: 200, mass: 0.5 },
|
||||
});
|
||||
const rise = interpolate(enter, [0, 1], [22, 0]);
|
||||
const fadeIn = start + 8;
|
||||
const fadeOut = Math.max(fadeIn + 1, toFrame - 10);
|
||||
const end = Math.max(fadeOut + 1, toFrame);
|
||||
const appear = interpolate(
|
||||
frame,
|
||||
[start, start + 8, toFrame - 10, toFrame],
|
||||
[start, fadeIn, fadeOut, end],
|
||||
[0, 1, 1, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
// 한글 폰트 로딩 (@remotion/google-fonts, korean subset)
|
||||
// Hook = Black Han Sans (굵고 강렬한 바이럴 헤드라인)
|
||||
// Body/Brand = Nanum Myeongjo (우아한 명조 — 머뭄의 정적 브랜드 톤)
|
||||
//
|
||||
// 주의: korean subset 자체가 unicode-range 파일이 많아 요청 수가 많다.
|
||||
// weights 를 최소화(NanumMyeongjo는 400/700만 실제 존재)해 요청 수를 줄임.
|
||||
// latin 서브셋 제거 — 콘텐츠가 한국어 전용이므로 불필요.
|
||||
import { loadFont as loadHook } from "@remotion/google-fonts/BlackHanSans";
|
||||
import { loadFont as loadSerif } from "@remotion/google-fonts/NanumMyeongjo";
|
||||
|
||||
export const hookFont = loadHook("normal", {
|
||||
weights: ["400"],
|
||||
subsets: ["korean", "latin"],
|
||||
subsets: ["korean"],
|
||||
ignoreTooManyRequestsWarning: true,
|
||||
}).fontFamily;
|
||||
|
||||
export const serifFont = loadSerif("normal", {
|
||||
weights: ["400", "700", "800"],
|
||||
subsets: ["korean", "latin"],
|
||||
weights: ["400", "700"], // 800은 NanumMyeongjo에 없음 → 제거
|
||||
subsets: ["korean"],
|
||||
ignoreTooManyRequestsWarning: true,
|
||||
}).fontFamily;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,70 @@
|
|||
# ADO2 Higgsfield Shorts server
|
||||
# Claude API (spec_builder)
|
||||
ANTHROPIC_API_KEY=
|
||||
# ADO2 Higgsfield Shorts server — 이 파일을 .env 로 복사해 채우세요 (.env 는 gitignore됨)
|
||||
|
||||
# Higgsfield CLI must be authenticated separately: `higgsfield auth login`
|
||||
# (the server shells out to the CLI; no key needed here)
|
||||
# --- OpenAI API (spec_builder, 필수) ---
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_MODEL=gpt-4o
|
||||
|
||||
# --- HTTP ---
|
||||
PORT=10001
|
||||
# CORS 허용 출처. 콤마구분. 운영 시 실제 도메인으로 제한 (예: https://ado2short.o2osolution.ai)
|
||||
CORS_ORIGINS=*
|
||||
|
||||
# --- 비동기 작업 큐 ---
|
||||
# 동시에 실제 생성을 돌릴 작업 수. 초과 요청은 큐에서 대기(status=queued).
|
||||
# Higgsfield/Remotion이 무겁고 외부 단가가 있으므로 보수적으로 둔다.
|
||||
MAX_CONCURRENT_JOBS=2
|
||||
# 완료/실패한 작업 상태를 메모리에 보관할 시간(초). 지나면 폴링 시 청소.
|
||||
JOB_RETENTION_SECONDS=3600
|
||||
|
||||
# --- 비디오 생성 백엔드 선택 ---
|
||||
# fal : fal.ai Seedance 2.0 reference-to-video (멀티이미지 9장 + 9:16 세로). 멀티이미지엔 이쪽 권장.
|
||||
# higgsfield : Higgsfield(dop/seedance v1/kling). 단일 이미지만.
|
||||
VIDEO_BACKEND=higgsfield
|
||||
|
||||
# --- fal.ai (Seedance 2.0, VIDEO_BACKEND=fal 일 때) ---
|
||||
# 키 발급: https://fal.ai → API Keys. 형식은 keyid:secret.
|
||||
FAL_KEY=
|
||||
FAL_MODEL_ID=bytedance/seedance-2.0/reference-to-video
|
||||
FAL_MAX_IMAGES=9 # Seedance 2.0 멀티이미지 최대 9장
|
||||
FAL_ASPECT_RATIO=9:16 # auto/16:9/9:16/4:3/3:4/1:1/21:9
|
||||
FAL_RESOLUTION=720p # 480p/720p/1080p
|
||||
FAL_DURATION=8 # "auto" 또는 4~15(초)
|
||||
|
||||
# --- Higgsfield (공통, VIDEO_BACKEND=higgsfield 일 때) ---
|
||||
# 백엔드: cli(디바이스 로그인) | api(공식 SDK). 컨테이너에서는 api 권장.
|
||||
HIGGSFIELD_BACKEND=cli
|
||||
HIGGSFIELD_ASPECT_RATIO=9:16
|
||||
HIGGSFIELD_DURATION=8
|
||||
HIGGSFIELD_WAIT_TIMEOUT=15m
|
||||
|
||||
# --- Higgsfield: CLI 백엔드 ---
|
||||
# 인증: `higgsfield auth login` (~/.higgsfield, 컨테이너에선 별도 주입 필요)
|
||||
HIGGSFIELD_MODEL=marketing_studio_video
|
||||
HIGGSFIELD_MODE=tv_spot
|
||||
HIGGSFIELD_GENERATE_AUDIO=true
|
||||
|
||||
# --- Higgsfield: API 백엔드 (공식 higgsfield-client SDK) ---
|
||||
# 키 발급: https://cloud.higgsfield.ai → API 섹션. 형식은 key:secret.
|
||||
HIGGSFIELD_API_KEY=
|
||||
HIGGSFIELD_API_SECRET=
|
||||
# (또는 SDK 네이티브 변수로 직접 줘도 됨: HF_KEY="key:secret")
|
||||
#
|
||||
# 비디오 application 선택 (platform.higgsfield.ai 실측):
|
||||
# · DoP : APP=/v1/image2video/dop, VARIANT=dop-turbo|dop-lite|dop-preview
|
||||
# input_images(복수). aspect_ratio 파라미터 무시 → 입력 이미지 비율 따라감(세로 보장 X).
|
||||
# · Seedance : APP=/v1/image2video/seedance, VARIANT=seedance_pro|seedance_lite
|
||||
# input_image(단수, 1장). aspect_ratio 9:16 네이티브 지원(세로 보장 O), duration 3~12.
|
||||
# 세로 9:16 숏폼에는 Seedance 권장.
|
||||
HIGGSFIELD_API_APP=/v1/image2video/seedance
|
||||
HIGGSFIELD_API_VARIANT=seedance_pro
|
||||
# Seedance/DoP image2video 모두 입력 이미지 1장만 사용.
|
||||
HIGGSFIELD_API_MAX_IMAGES=1
|
||||
|
||||
# --- Remotion ---
|
||||
REMOTION_FPS=30
|
||||
REMOTION_COMPOSITION=MumumShort
|
||||
|
||||
# --- Paths (Docker에서 오버라이드. 비우면 레포 기본 경로 사용) ---
|
||||
# WEBAPP_DIR=
|
||||
# OUTPUTS_DIR=
|
||||
# UPLOADS_DIR=
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
POST /api/generate (multipart: photos[] + interview fields)
|
||||
→ build_spec (Claude) → higgsfield.generate → remotion.render → mp4
|
||||
POST /api/generate (multipart: photos[] + interview fields) → job_id 즉시 반환
|
||||
백그라운드 워커: build_spec (OpenAI) → higgsfield.generate → remotion.render → mp4
|
||||
GET /api/jobs/{id} 작업 상태 폴링 (queued | running | done | error)
|
||||
GET /api/health
|
||||
Static: /outputs/<file> (final videos), / (the web app)
|
||||
"""
|
||||
|
|
@ -16,18 +17,18 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.schemas import GenerateRequest, GenerateResult, ScriptResult
|
||||
from app.pipeline import spec_builder, higgsfield_client, remotion_render
|
||||
from app import config as cfg, jobs
|
||||
from app.schemas import GenerateRequest, JobStatus, ScriptResult
|
||||
from app.pipeline import spec_builder
|
||||
|
||||
ENGINE = Path(__file__).resolve().parents[2] # engine/higgsfield_shorts
|
||||
WEBAPP = ENGINE / "webapp"
|
||||
OUTPUTS = ENGINE / "server" / "outputs"
|
||||
UPLOADS = ENGINE / "server" / ".uploads"
|
||||
WEBAPP = cfg.WEBAPP_DIR
|
||||
OUTPUTS = cfg.OUTPUTS_DIR
|
||||
UPLOADS = cfg.UPLOADS_DIR
|
||||
OUTPUTS.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
app = FastAPI(title="ADO2 Higgsfield Shorts")
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
|
||||
CORSMiddleware, allow_origins=cfg.CORS_ORIGINS, allow_methods=["*"], allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -38,15 +39,15 @@ def health():
|
|||
|
||||
@app.post("/api/captions", response_model=ScriptResult)
|
||||
def captions(req: GenerateRequest):
|
||||
"""인터뷰 답변 → 인트로·셀링포인트·감성스토리·CTA 4블록 (Claude)."""
|
||||
"""인터뷰 답변 → 인트로·셀링포인트·감성스토리·CTA 4블록 (OpenAI)."""
|
||||
try:
|
||||
return spec_builder.build_script(req)
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"자막 생성 실패: {e}") from e
|
||||
|
||||
|
||||
@app.post("/api/generate", response_model=GenerateResult)
|
||||
async def generate(
|
||||
@app.post("/api/generate", response_model=JobStatus, status_code=202)
|
||||
def generate( # 논블로킹: 사진만 저장하고 작업을 큐에 넣은 뒤 job_id 반환
|
||||
photos: list[UploadFile] = File(...),
|
||||
kind: str = Form(...),
|
||||
biz_name: str = Form(...),
|
||||
|
|
@ -58,49 +59,36 @@ async def generate(
|
|||
if len(photos) < 4:
|
||||
raise HTTPException(400, "사진 4장 이상이 필요합니다.")
|
||||
|
||||
# 1. Persist uploaded photos
|
||||
job = uuid.uuid4().hex[:8]
|
||||
job_dir = UPLOADS / job
|
||||
# 업로드 사진을 디스크에 저장 (워커 스레드가 경로로 읽음).
|
||||
# UploadFile 의 스트림은 요청 종료 후 닫히므로 반드시 여기서 영속화한다.
|
||||
upload_id = uuid.uuid4().hex[:8]
|
||||
job_dir = UPLOADS / upload_id
|
||||
job_dir.mkdir(parents=True, exist_ok=True)
|
||||
photo_paths: list[Path] = []
|
||||
for i, up in enumerate(photos):
|
||||
dest = job_dir / f"{i:02d}_{Path(up.filename or 'img').name}"
|
||||
# 원본 파일명에 한글·유니코드 문자가 있으면 fal 업로드 클라이언트가 ASCII 에러를 냄.
|
||||
# 확장자만 보존하고 나머지는 순수 숫자로 대체한다.
|
||||
suffix = Path(up.filename or "img.jpg").suffix.lower() or ".jpg"
|
||||
dest = job_dir / f"{i:02d}{suffix}"
|
||||
with dest.open("wb") as f:
|
||||
shutil.copyfileobj(up.file, f)
|
||||
photo_paths.append(dest)
|
||||
|
||||
req = GenerateRequest(kind=kind, biz_name=biz_name, addr=addr, price=price, selling=selling)
|
||||
|
||||
# 2. LLM → spec
|
||||
try:
|
||||
spec = spec_builder.build_spec(req)
|
||||
except Exception as e: # surface as a clean 502, don't swallow
|
||||
raise HTTPException(502, f"spec 생성 실패: {e}") from e
|
||||
# 파이프라인은 백그라운드 워커에서 실행 → 즉시 queued 상태를 돌려준다.
|
||||
job_id = jobs.submit(req, photo_paths, dry_run=dry_run)
|
||||
status = jobs.get(job_id)
|
||||
return JobStatus(**{k: status[k] for k in JobStatus.model_fields})
|
||||
|
||||
# 3. Higgsfield → base video
|
||||
try:
|
||||
base_video, credits = higgsfield_client.generate(
|
||||
spec.higgsfield_prompt, photo_paths, dry_run=dry_run
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Higgsfield 생성 실패: {e}") from e
|
||||
|
||||
# 4. Remotion → final with overlays
|
||||
try:
|
||||
final = remotion_render.render(spec, base_video)
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Remotion 합성 실패: {e}") from e
|
||||
|
||||
served = OUTPUTS / f"{job}.mp4"
|
||||
shutil.copy(final, served)
|
||||
|
||||
return GenerateResult(
|
||||
video_url=f"/outputs/{job}.mp4",
|
||||
caption=spec.caption,
|
||||
profile=spec.profile,
|
||||
cost_credits=credits,
|
||||
job_id=job,
|
||||
)
|
||||
@app.get("/api/jobs/{job_id}", response_model=JobStatus)
|
||||
def job_status(job_id: str):
|
||||
"""작업 진행 상태 폴링. 프론트가 주기적으로 호출."""
|
||||
status = jobs.get(job_id)
|
||||
if status is None:
|
||||
raise HTTPException(404, "작업을 찾을 수 없습니다(만료되었거나 잘못된 ID).")
|
||||
return JobStatus(**{k: status[k] for k in JobStatus.model_fields})
|
||||
|
||||
|
||||
# Static mounts (after API routes)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
Thin wrapper around the verified CLI flow:
|
||||
higgsfield generate cost/create marketing_studio_video --mode tv_spot ...
|
||||
dry_run=True returns a bundled demo clip and spends no credits.
|
||||
두 가지 백엔드를 지원한다 (cfg.HIGGSFIELD_BACKEND):
|
||||
|
||||
· "cli" — 기존 검증 경로. `higgsfield` CLI 를 subprocess 로 호출.
|
||||
~/.higgsfield 디바이스 로그인 인증 필요(컨테이너 비친화).
|
||||
· "api" — 공식 Python SDK(higgsfield-client). HF_KEY 환경변수 인증.
|
||||
컨테이너/서버리스 친화. 공개 문서 기준 DoP image2video 사용.
|
||||
|
||||
dry_run=True 면 양쪽 모두 건너뛰고 번들된 데모 클립을 반환(크레딧 0).
|
||||
|
||||
API 백엔드는 DoP image2video(POST {app} → job set, GET /v1/job-sets/{id} 폴링,
|
||||
jobs[0].results.raw.url 추출)로 실측 검증됨. 단가/오디오 특성이 CLI(marketing_studio,
|
||||
tv_spot)와 다르므로 app/variant 는 env(cfg.HIGGSFIELD_API_APP/VARIANT)로 교체 가능.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from app import config as cfg
|
||||
|
||||
ENGINE = Path(__file__).resolve().parents[3] # engine/higgsfield_shorts
|
||||
DEMO_VIDEO = ENGINE / "webapp" / "demo" / "mumum.mp4"
|
||||
DEMO_VIDEO = cfg.WEBAPP_DIR / "demo" / "mumum.mp4"
|
||||
TMP = ENGINE / "server" / ".tmp"
|
||||
|
||||
MODEL = "marketing_studio_video"
|
||||
MODEL = cfg.HIGGSFIELD_MODEL
|
||||
_URL_RE = re.compile(r'"result_url"\s*:\s*"([^"]+)"')
|
||||
_CREDITS_RE = re.compile(r'"credits(?:_exact)?"\s*:\s*([0-9.]+)')
|
||||
|
||||
|
||||
def _download(url: str) -> Path:
|
||||
# httpx + 브라우저 UA 로 받음. (CDN이 urllib 기본 UA를 403 차단 — 실측 확인)
|
||||
TMP.mkdir(parents=True, exist_ok=True)
|
||||
dest = TMP / f"base_{uuid.uuid4().hex[:8]}.mp4"
|
||||
with httpx.stream("GET", url, follow_redirects=True, timeout=120.0,
|
||||
headers={"User-Agent": "Mozilla/5.0"}) as r:
|
||||
r.raise_for_status()
|
||||
with dest.open("wb") as f:
|
||||
for chunk in r.iter_bytes():
|
||||
f.write(chunk)
|
||||
return dest
|
||||
|
||||
|
||||
# ============================ CLI 백엔드 ============================
|
||||
|
||||
def _base_args(prompt: str, image_paths: list[Path], duration: int) -> list[str]:
|
||||
args = ["--prompt", prompt]
|
||||
for p in image_paths:
|
||||
args += ["--image", str(p)]
|
||||
args += ["--mode", "tv_spot", "--duration", str(duration),
|
||||
"--generate_audio", "true", "--aspect_ratio", "9:16"]
|
||||
args += ["--mode", cfg.HIGGSFIELD_MODE, "--duration", str(duration),
|
||||
"--generate_audio", cfg.HIGGSFIELD_GENERATE_AUDIO,
|
||||
"--aspect_ratio", cfg.HIGGSFIELD_ASPECT_RATIO]
|
||||
return args
|
||||
|
||||
|
||||
def cost(prompt: str, image_paths: list[Path], duration: int = 8) -> float:
|
||||
def cost(prompt: str, image_paths: list[Path], duration: int = cfg.HIGGSFIELD_DURATION) -> float:
|
||||
"""CLI 백엔드 전용 — 생성 전 크레딧 선확인."""
|
||||
out = subprocess.run(
|
||||
["higgsfield", "generate", "cost", MODEL, *_base_args(prompt, image_paths, duration), "--json"],
|
||||
capture_output=True, text=True, check=True,
|
||||
|
|
@ -40,17 +70,9 @@ def cost(prompt: str, image_paths: list[Path], duration: int = 8) -> float:
|
|||
return float(m.group(1)) if m else 0.0
|
||||
|
||||
|
||||
def generate(
|
||||
prompt: str,
|
||||
image_paths: list[Path],
|
||||
duration: int = 8,
|
||||
dry_run: bool = False,
|
||||
wait_timeout: str = "15m",
|
||||
def _generate_cli(
|
||||
prompt: str, image_paths: list[Path], duration: int, wait_timeout: str,
|
||||
) -> tuple[Path, float]:
|
||||
"""Return (local mp4 path, credits spent)."""
|
||||
if dry_run:
|
||||
return DEMO_VIDEO, 0.0
|
||||
|
||||
out = subprocess.run(
|
||||
["higgsfield", "generate", "create", MODEL,
|
||||
*_base_args(prompt, image_paths, duration),
|
||||
|
|
@ -60,11 +82,125 @@ def generate(
|
|||
m = _URL_RE.search(out.stdout)
|
||||
if not m:
|
||||
raise RuntimeError(f"Higgsfield returned no result_url:\n{out.stdout[-2000:]}")
|
||||
url = m.group(1)
|
||||
credits_m = _CREDITS_RE.search(out.stdout)
|
||||
credits = float(credits_m.group(1)) if credits_m else 0.0
|
||||
return _download(m.group(1)), credits
|
||||
|
||||
TMP.mkdir(parents=True, exist_ok=True)
|
||||
dest = TMP / f"base_{uuid.uuid4().hex[:8]}.mp4"
|
||||
urllib.request.urlretrieve(url, dest)
|
||||
return dest, credits
|
||||
|
||||
# ============================ API 백엔드 ============================
|
||||
#
|
||||
# DoP image2video 응답은 공식 SDK의 generic submit() 가정(request_id)과 달라
|
||||
# 'job set' 구조다(실측 확인). 따라서 SDK 의 저수준 클라이언트(인증·base_url)만
|
||||
# 빌려 직접 흐름을 구현한다:
|
||||
# POST {app} → {"id": <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
|
||||
from pathlib import Path
|
||||
|
||||
from app import config as cfg
|
||||
from app.schemas import VideoSpec
|
||||
|
||||
ENGINE = Path(__file__).resolve().parents[3] # engine/higgsfield_shorts
|
||||
|
|
@ -19,7 +20,7 @@ REMOTION = ENGINE / "remotion"
|
|||
PUBLIC = REMOTION / "public"
|
||||
OUT_DIR = REMOTION / "out"
|
||||
|
||||
FPS = 30
|
||||
FPS = cfg.REMOTION_FPS
|
||||
|
||||
|
||||
def _probe_frames(video_path: Path, fps: int = FPS) -> tuple[int, int, int]:
|
||||
|
|
@ -38,14 +39,23 @@ def _probe_frames(video_path: Path, fps: int = FPS) -> tuple[int, int, int]:
|
|||
|
||||
|
||||
def _timings(total: int) -> dict:
|
||||
"""Default 4-beat layout, clamped to the actual video length."""
|
||||
def clamp(f: int) -> int:
|
||||
return max(0, min(f, total))
|
||||
"""4-beat 레이아웃을 실제 프레임 수에 비례 스케일.
|
||||
|
||||
240프레임(8초 @30fps)을 기준으로 설계됐으나, 영상이 그보다 짧거나 길어도
|
||||
각 구간 비율이 유지되도록 sc()로 선형 변환한다.
|
||||
고정값 clamp 방식은 짧은 영상에서 brandLines 창이 너무 좁아져
|
||||
interpolate 범위가 뒤집히는 크래시를 유발했음.
|
||||
"""
|
||||
REF = 240 # 기준 총 프레임 (8s @30fps)
|
||||
|
||||
def sc(f: int) -> int:
|
||||
return max(0, min(round(f * total / REF), total))
|
||||
|
||||
return {
|
||||
"hook": {"fromFrame": 0, "toFrame": clamp(78)},
|
||||
"sellingPoint": {"fromFrame": clamp(80), "toFrame": clamp(138)},
|
||||
"brandLines": {"fromFrame": clamp(138), "toFrame": clamp(196)},
|
||||
"endCard": {"fromFrame": clamp(total - 49), "toFrame": total},
|
||||
"hook": {"fromFrame": 0, "toFrame": sc(78)},
|
||||
"sellingPoint": {"fromFrame": sc(80), "toFrame": sc(138)},
|
||||
"brandLines": {"fromFrame": sc(138), "toFrame": sc(196)},
|
||||
"endCard": {"fromFrame": max(0, total - sc(49)), "toFrame": total},
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -74,17 +84,24 @@ def build_props(spec: VideoSpec, base_video: Path) -> tuple[dict, str]:
|
|||
return props, job
|
||||
|
||||
|
||||
def render(spec: VideoSpec, base_video: Path) -> Path:
|
||||
def render(spec: VideoSpec, base_video: Path) -> tuple[Path, list[Path]]:
|
||||
"""렌더 후 호출자가 지울 임시 파일 목록도 함께 반환한다.
|
||||
|
||||
반환값: (최종 mp4, [삭제 대상 임시 파일])
|
||||
호출자는 최종 mp4를 outputs/ 로 복사한 뒤 반환된 목록을 삭제해야 한다.
|
||||
"""
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
props, _ = build_props(spec, base_video)
|
||||
props, pub_job_name = build_props(spec, base_video)
|
||||
|
||||
props_file = OUT_DIR / f"props_{uuid.uuid4().hex[:8]}.json"
|
||||
props_file.write_text(json.dumps(props, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
out_file = OUT_DIR / f"final_{uuid.uuid4().hex[:8]}.mp4"
|
||||
subprocess.run(
|
||||
["npx", "remotion", "render", "MumumShort", str(out_file),
|
||||
["npx", "remotion", "render", cfg.REMOTION_COMPOSITION, str(out_file),
|
||||
f"--props={props_file}"],
|
||||
cwd=str(REMOTION), check=True,
|
||||
)
|
||||
return out_file
|
||||
# 임시 파일: remotion/public/ 의 베이스 복사본 + props JSON (out_file 은 별도 반환)
|
||||
temps = [PUBLIC / pub_job_name, props_file]
|
||||
return out_file, temps
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
"""① LLM step — interview answers → VideoSpec (JSON).
|
||||
"""① LLM step — interview answers → VideoSpec / ScriptResult (JSON).
|
||||
|
||||
Uses the Anthropic Python SDK (claude-opus-4-7) with structured outputs so the
|
||||
result is guaranteed to match the Remotion data contract. The guardrail system
|
||||
prompt is cached (stable prefix) to cut cost/latency across requests.
|
||||
Uses the OpenAI SDK with Structured Outputs (response_format json_schema, strict)
|
||||
so the result is guaranteed to match the Remotion data contract. The stable
|
||||
guardrail system prompt benefits from OpenAI's automatic prompt caching.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import anthropic
|
||||
from openai import OpenAI
|
||||
|
||||
from app import config as cfg
|
||||
from app.schemas import GenerateRequest, VideoSpec, ScriptResult
|
||||
|
||||
MODEL = "claude-opus-4-7"
|
||||
MODEL = cfg.OPENAI_MODEL
|
||||
|
||||
# Stable, cacheable guardrail + role prompt.
|
||||
# Stable guardrail + role prompt (automatic prompt caching applies to this prefix).
|
||||
SYSTEM_PROMPT = """\
|
||||
당신은 한국 로컬 비즈니스(숙소·매장·제품)의 8초 세로형 숏폼 광고 카피 디렉터입니다.
|
||||
사장/마케터가 직접 답한 정보만으로, 영상 사양(VideoSpec)을 설계합니다. 복잡한 분석은 하지 않습니다.
|
||||
|
|
@ -41,10 +42,19 @@ SYSTEM_PROMPT = """\
|
|||
- place + 뷰/활동성/포토스팟, 또는 product 일반 → "Rhythm Reveal"
|
||||
- 파티/워터파크/대형 액티비티 → "Maximum Viral"
|
||||
|
||||
[higgsfield_prompt] 영문. marketing_studio_video tv_spot용. 빠르고 역동적인 카메라(quick punchy moves, crash zoom, kinetic glide). 유형 반영: place=공간 투어, product=제품 쇼케이스. 반드시 "keep structure realistic, do not distort" 포함.
|
||||
[higgsfield_prompt] 영문. marketing_studio_video 용 고품질 프롬프트. 아래 구조를 반드시 따를 것.
|
||||
|
||||
① 포맷 명시: "VERTICAL 9:16 portrait format, mobile short-form."
|
||||
② 카메라 무브 (place 유형): 드론/핸드헬드 하이브리드로 실내외를 역동적으로 순회.
|
||||
예시 패턴: "smooth drone glide through entrance → quick crash-zoom onto hero detail → kinetic handheld reveal of interior space → pull-back drone shot of exterior"
|
||||
product 유형: "macro crash-zoom onto product → slow orbit reveal → snappy cut to detail closeup"
|
||||
③ 분위기: 시간대(twilight/golden hour/daylight)·색감(teal-and-orange grade 또는 유형에 맞는 톤)·질감(subtle film grain, quiet luxury)을 구체적으로 명시.
|
||||
④ 반드시 포함할 문구: "photorealistic, no AI artifacts, keep architecture and objects structurally accurate, do not warp or distort walls floors or furniture, no morphing, no hallucinated details."
|
||||
⑤ 마지막 문장: "Hook viewer in first second. Beat-driven energetic edit."
|
||||
유형(place/product/message)과 업체 분위기에 맞게 세부 내용을 구체화할 것. 절대 짧거나 추상적인 문장으로 끝내지 말 것.
|
||||
"""
|
||||
|
||||
# Structured-output JSON schema (additionalProperties:false everywhere).
|
||||
# Structured-output JSON schema (strict: additionalProperties:false + all props required).
|
||||
_SPEC_SCHEMA = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
|
|
@ -83,36 +93,50 @@ _SPEC_SCHEMA = {
|
|||
}
|
||||
|
||||
|
||||
def build_spec(req: GenerateRequest, client: anthropic.Anthropic | None = None) -> VideoSpec:
|
||||
client = client or anthropic.Anthropic()
|
||||
def _kind_label(kind: str) -> str:
|
||||
return {
|
||||
"place": "장소",
|
||||
"message": "메시지(축하·안부·홍보)",
|
||||
"product": "물건(제품·음식 등 정적 아이템)",
|
||||
}.get(kind, kind)
|
||||
|
||||
user_msg = (
|
||||
f"유형: {'장소' if req.kind == 'place' else '메시지(축하·안부·홍보)' if req.kind == 'message' else '물건(제품·음식 등 정적 아이템)'}\n"
|
||||
|
||||
def _user_msg(req: GenerateRequest, tail: str) -> str:
|
||||
return (
|
||||
f"유형: {_kind_label(req.kind)}\n"
|
||||
f"업체/상품명: {req.biz_name}\n"
|
||||
f"주소/판매URL: {req.addr or '(없음)'}\n"
|
||||
f"가격: {req.price or '(없음)'}\n"
|
||||
f"강력한 한방 셀링포인트: {req.selling}\n\n"
|
||||
"위 정보로 VideoSpec을 설계해줘."
|
||||
f"{tail}"
|
||||
)
|
||||
|
||||
resp = client.messages.create(
|
||||
|
||||
def _complete(client: OpenAI, system: str, user: str, schema_name: str,
|
||||
schema: dict, max_tokens: int) -> dict:
|
||||
"""OpenAI Structured Outputs 호출 → 검증된 JSON(dict) 반환."""
|
||||
resp = client.chat.completions.create(
|
||||
model=MODEL,
|
||||
max_tokens=2048,
|
||||
thinking={"type": "adaptive"},
|
||||
output_config={
|
||||
"effort": "medium",
|
||||
"format": {"type": "json_schema", "name": "video_spec", "schema": _SPEC_SCHEMA},
|
||||
max_completion_tokens=max_tokens,
|
||||
response_format={
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": schema_name, "strict": True, "schema": schema},
|
||||
},
|
||||
system=[{
|
||||
"type": "text",
|
||||
"text": SYSTEM_PROMPT,
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}],
|
||||
messages=[{"role": "user", "content": user_msg}],
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
)
|
||||
return json.loads(resp.choices[0].message.content)
|
||||
|
||||
text = "".join(b.text for b in resp.content if b.type == "text")
|
||||
return VideoSpec.model_validate(json.loads(text))
|
||||
|
||||
def build_spec(req: GenerateRequest, client: OpenAI | None = None) -> VideoSpec:
|
||||
client = client or OpenAI()
|
||||
data = _complete(
|
||||
client, SYSTEM_PROMPT, _user_msg(req, "위 정보로 VideoSpec을 설계해줘."),
|
||||
"video_spec", _SPEC_SCHEMA, max_tokens=2048,
|
||||
)
|
||||
return VideoSpec.model_validate(data)
|
||||
|
||||
|
||||
# ---------- 자막 스크립트 4블록 ----------
|
||||
|
|
@ -146,26 +170,10 @@ _SCRIPT_SCHEMA = {
|
|||
}
|
||||
|
||||
|
||||
def build_script(req: GenerateRequest, client: anthropic.Anthropic | None = None) -> ScriptResult:
|
||||
client = client or anthropic.Anthropic()
|
||||
user_msg = (
|
||||
f"유형: {'장소' if req.kind == 'place' else '메시지(축하·안부·홍보)' if req.kind == 'message' else '물건(제품·음식 등 정적 아이템)'}\n"
|
||||
f"업체/상품명: {req.biz_name}\n"
|
||||
f"주소/판매URL: {req.addr or '(없음)'}\n"
|
||||
f"가격: {req.price or '(없음)'}\n"
|
||||
f"강력한 한방 셀링포인트: {req.selling}\n\n"
|
||||
"위 정보로 4블록 자막 스크립트를 만들어줘."
|
||||
def build_script(req: GenerateRequest, client: OpenAI | None = None) -> ScriptResult:
|
||||
client = client or OpenAI()
|
||||
data = _complete(
|
||||
client, SCRIPT_SYSTEM, _user_msg(req, "위 정보로 4블록 자막 스크립트를 만들어줘."),
|
||||
"script", _SCRIPT_SCHEMA, max_tokens=1024,
|
||||
)
|
||||
resp = client.messages.create(
|
||||
model=MODEL,
|
||||
max_tokens=1024,
|
||||
thinking={"type": "adaptive"},
|
||||
output_config={
|
||||
"effort": "low",
|
||||
"format": {"type": "json_schema", "name": "script", "schema": _SCRIPT_SCHEMA},
|
||||
},
|
||||
system=[{"type": "text", "text": SCRIPT_SYSTEM, "cache_control": {"type": "ephemeral"}}],
|
||||
messages=[{"role": "user", "content": user_msg}],
|
||||
)
|
||||
text = "".join(b.text for b in resp.content if b.type == "text")
|
||||
return ScriptResult.model_validate(json.loads(text))
|
||||
return ScriptResult.model_validate(data)
|
||||
|
|
|
|||
|
|
@ -62,3 +62,20 @@ class GenerateResult(BaseModel):
|
|||
profile: str
|
||||
cost_credits: float = 0.0
|
||||
job_id: Optional[str] = None
|
||||
|
||||
|
||||
# ---------- Outbound: 비동기 작업 상태 (폴링) ----------
|
||||
class JobStatus(BaseModel):
|
||||
job_id: str
|
||||
# queued: 큐 대기 / running: 생성 중 / done: 완료 / error: 실패
|
||||
status: Literal["queued", "running", "done", "error"]
|
||||
stage: Optional[str] = Field(None, description="현재 단계: spec | higgsfield | remotion")
|
||||
stage_index: int = Field(0, description="단계 인덱스 0..2 (프론트 진행바 매핑)")
|
||||
progress: int = Field(0, description="전체 진행률 0..100 (대략)")
|
||||
# 완료 시 채워지는 결과 필드 (GenerateResult 와 동일 의미)
|
||||
video_url: Optional[str] = None
|
||||
caption: Optional[str] = None
|
||||
profile: Optional[str] = None
|
||||
cost_credits: float = 0.0
|
||||
# 실패 시 사용자에게 보여줄 메시지
|
||||
error: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -7,9 +7,19 @@ dependencies = [
|
|||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"python-multipart>=0.0.9",
|
||||
"anthropic>=0.69",
|
||||
"openai>=1.50",
|
||||
"pydantic>=2.7",
|
||||
"higgsfield-client>=0.1", # 공식 SDK — HIGGSFIELD_BACKEND=api 일 때 사용
|
||||
"fal-client>=0.5", # fal.ai SDK — VIDEO_BACKEND=fal (Seedance 2.0 멀티이미지)
|
||||
"httpx>=0.27", # 결과 영상 다운로드 (CDN UA 차단 회피)
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["app", "app.pipeline"]
|
||||
|
||||
[tool.uvicorn]
|
||||
# run: uv run uvicorn app.main:app --reload --port 8000
|
||||
# run: uv run uvicorn app.main:app --reload --port 10001
|
||||
|
|
|
|||
|
|
@ -215,10 +215,11 @@ const Icon = {
|
|||
};
|
||||
|
||||
/* ===== Config ===== */
|
||||
// 같은 출처(FastAPI가 이 페이지를 서빙)면 "" 로 두면 /api/generate 로 전송.
|
||||
// 백엔드 미연결 시 fetch 실패 → 자동으로 데모 결과로 폴백.
|
||||
const API_BASE = "";
|
||||
const DEMO_VIDEO = "./demo/mumum.mp4";
|
||||
// 백엔드 주소. 같은 출처(FastAPI가 이 페이지를 서빙)면 "" 로 두면 /api/generate 로 전송.
|
||||
// 프론트/백엔드를 분리 배포할 땐 빌드/배포 시점에 window.__API_BASE__ 를 head 에 주입
|
||||
// 예) window.__API_BASE__ = "https://api.example.com";
|
||||
// 생성 실패/서버 미연결 시 데모 영상은 표시하지 않고 에러 카드만 보여준다.
|
||||
const API_BASE = (typeof window !== "undefined" && window.__API_BASE__) || "";
|
||||
|
||||
const STEPS = [
|
||||
{ k:"setup", n:"1", b:"사진 업로드", s:"4~5장" },
|
||||
|
|
@ -245,7 +246,8 @@ function App(){
|
|||
const [price, setPrice] = useState("");
|
||||
const [selling, setSelling] = useState(""); // 강력한 한방 셀링포인트
|
||||
const [prog, setProg] = useState([0,0,0]);
|
||||
const [videoUrl, setVideoUrl] = useState(DEMO_VIDEO);
|
||||
const [videoUrl, setVideoUrl] = useState(null); // 성공 시에만 채움 (데모 폴백 없음)
|
||||
const [errMsg, setErrMsg] = useState(""); // 생성 실패 메시지 (있으면 결과 화면이 에러 카드로 전환)
|
||||
const [srvCaption, setSrvCaption] = useState(null);
|
||||
const [srvProfile, setSrvProfile] = useState(null);
|
||||
const [editedCaption, setEditedCaption] = useState("");
|
||||
|
|
@ -255,7 +257,10 @@ function App(){
|
|||
const [scriptEditing, setScriptEditing] = useState({});
|
||||
const [scriptLoading, setScriptLoading] = useState(false);
|
||||
const fileRef = useRef();
|
||||
const doneRef = useRef({ bars:false, fetch:false });
|
||||
const pollRef = useRef(null); // 진행 중인 폴링 타이머 (언마운트/리셋 시 정리)
|
||||
|
||||
// 언마운트 시 폴링 타이머 정리
|
||||
useEffect(()=>()=>{ if(pollRef.current) clearTimeout(pollRef.current); }, []);
|
||||
|
||||
// 톤 자동 매칭 (인텔리전스 IP를 가볍게 노출 — 유형 기반)
|
||||
const tone = kind === "product"
|
||||
|
|
@ -282,29 +287,61 @@ function App(){
|
|||
}
|
||||
function removePhoto(i){ setPhotos(p=>p.filter((_,idx)=>idx!==i)); }
|
||||
|
||||
function finishIfReady(){
|
||||
if(doneRef.current.bars && doneRef.current.fetch) setStep("result");
|
||||
// 작업 상태(stage_index 0..2)를 3개 진행바 배열로 변환.
|
||||
// 이전 단계 = 100%, 현재 단계 = 진행 중(서버 progress 또는 점진 증가), 이후 단계 = 0%
|
||||
function barsForStatus(st, prevBars){
|
||||
const idx = st.stage_index || 0;
|
||||
return [0,1,2].map(i=>{
|
||||
if(st.status === "done") return 100;
|
||||
if(i < idx) return 100;
|
||||
if(i > idx) return 0;
|
||||
// 현재 진행 중인 바: 95%까지 조금씩 차오르게(완료 신호는 폴링이 줌)
|
||||
const cur = (prevBars && prevBars[i]) || 0;
|
||||
return Math.min(95, Math.max(cur + Math.round(Math.random()*8+4), 8));
|
||||
});
|
||||
}
|
||||
|
||||
function stopPolling(){ if(pollRef.current){ clearTimeout(pollRef.current); pollRef.current = null; } }
|
||||
|
||||
// 작업 완료/실패 시 결과 화면으로
|
||||
function finishWith(st){
|
||||
if(st.video_url) setVideoUrl((API_BASE||"") + st.video_url);
|
||||
if(st.caption){ setSrvCaption(st.caption); setEditedCaption(st.caption); }
|
||||
if(st.profile) setSrvProfile(st.profile);
|
||||
setProg([100,100,100]);
|
||||
setStep("result");
|
||||
}
|
||||
|
||||
// job_id 를 주기적으로 폴링하며 진행바를 갱신
|
||||
function pollJob(jobId){
|
||||
fetch(`${API_BASE}/api/jobs/${jobId}`)
|
||||
.then(r=> r.ok ? r.json() : Promise.reject(new Error("HTTP "+r.status)))
|
||||
.then(st=>{
|
||||
setProg(prev=> barsForStatus(st, prev));
|
||||
if(st.caption && !srvCaption){ setSrvCaption(st.caption); setEditedCaption(st.caption); }
|
||||
if(st.profile) setSrvProfile(st.profile);
|
||||
if(st.status === "done"){ finishWith(st); return; }
|
||||
if(st.status === "error"){
|
||||
// 실패 시 데모 영상 표시 안 함 — 에러 카드만 보여준다.
|
||||
setVideoUrl(null);
|
||||
setErrMsg(st.error || "알 수 없는 오류로 생성에 실패했습니다.");
|
||||
setProg([100,100,100]); setStep("result");
|
||||
return;
|
||||
}
|
||||
pollRef.current = setTimeout(()=>pollJob(jobId), 2000); // 2초 간격 폴링
|
||||
})
|
||||
.catch(()=>{ // 폴링 실패 → 잠시 후 재시도 (네트워크 일시 단절 대비)
|
||||
pollRef.current = setTimeout(()=>pollJob(jobId), 3000);
|
||||
});
|
||||
}
|
||||
|
||||
function startGenerate(){
|
||||
stopPolling();
|
||||
setStep("generating");
|
||||
setProg([0,0,0]); setNote(""); setSrvCaption(null); setCopied(false);
|
||||
setProg([6,0,0]); setNote(""); setErrMsg(""); setVideoUrl(null); setSrvCaption(null); setCopied(false);
|
||||
setSrvProfile(tone.profile);
|
||||
setEditedCaption(caption); // 로컬 폴백; 서버 응답 오면 교체
|
||||
doneRef.current = { bars:false, fetch:false };
|
||||
|
||||
// 진행 바 애니메이션 (시각용)
|
||||
[0,1,2].forEach((agent)=>{
|
||||
let p = 0; const base = agent*1600;
|
||||
const tick = ()=>{
|
||||
p += Math.random()*22+10; if(p>100) p=100;
|
||||
setProg(prev=>{ const n=[...prev]; n[agent]=Math.round(p); return n; });
|
||||
if(p<100) setTimeout(tick, 260);
|
||||
else if(agent===2){ doneRef.current.bars=true; finishIfReady(); }
|
||||
};
|
||||
setTimeout(tick, base+300);
|
||||
});
|
||||
|
||||
// 실제 생성 요청 (실패 시 데모 폴백)
|
||||
const fd = new FormData();
|
||||
fd.append("kind", kind);
|
||||
fd.append("biz_name", bizName);
|
||||
|
|
@ -313,20 +350,20 @@ function App(){
|
|||
if(price) fd.append("price", price);
|
||||
photos.forEach(p=> p.file && fd.append("photos", p.file, p.name));
|
||||
|
||||
// 1) 작업 제출 → job_id 즉시 수신, 2) job_id 폴링
|
||||
fetch(`${API_BASE}/api/generate`, { method:"POST", body:fd })
|
||||
.then(r=> r.ok ? r.json() : Promise.reject(new Error("HTTP "+r.status)))
|
||||
.then(res=>{
|
||||
setVideoUrl((API_BASE||"") + res.video_url);
|
||||
if(res.caption){ setSrvCaption(res.caption); setEditedCaption(res.caption); }
|
||||
if(res.profile) setSrvProfile(res.profile);
|
||||
.then(st=>{
|
||||
if(!st.job_id) throw new Error("no job_id");
|
||||
pollJob(st.job_id);
|
||||
})
|
||||
.catch(()=>{
|
||||
setVideoUrl(DEMO_VIDEO);
|
||||
setNote("백엔드 미연결 — 데모 결과를 표시합니다. (서버 실행 시 실제 생성)");
|
||||
})
|
||||
.finally(()=>{ doneRef.current.fetch=true; finishIfReady(); });
|
||||
.catch(()=>{ // 서버 연결 실패 → 데모 안 띄우고 에러 카드
|
||||
setVideoUrl(null);
|
||||
setErrMsg("서버에 연결하지 못했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
setProg([100,100,100]); setStep("result");
|
||||
});
|
||||
}
|
||||
function reset(){ setStep("setup"); setPhotos([]); setKind(""); setBizName(""); setAddr(""); setPrice(""); setSelling(""); setProg([0,0,0]); setNote(""); setSrvCaption(null); setSrvProfile(null); setEditedCaption(""); setCopied(false); setVideoUrl(DEMO_VIDEO); setScript(null); setScriptEditing({}); }
|
||||
function reset(){ stopPolling(); setStep("setup"); setPhotos([]); setKind(""); setBizName(""); setAddr(""); setPrice(""); setSelling(""); setProg([0,0,0]); setNote(""); setErrMsg(""); setSrvCaption(null); setSrvProfile(null); setEditedCaption(""); setCopied(false); setVideoUrl(null); setScript(null); setScriptEditing({}); }
|
||||
|
||||
function copyCaption(){
|
||||
navigator.clipboard.writeText(editedCaption).then(()=>{ setCopied(true); setTimeout(()=>setCopied(false), 1800); });
|
||||
|
|
@ -549,8 +586,19 @@ function App(){
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* STEP 4 — RESULT */}
|
||||
{step==="result" && (
|
||||
{/* STEP 4 — 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="result-grid">
|
||||
<div className="phone">
|
||||
|
|
|
|||
Loading…
Reference in New Issue