feat: 비동기 파이프라인 + fal Seedance 2.0 + Azure Blob 갤러리
[비동기 처리]
- POST /api/generate → 즉시 job_id 반환 (202), 백그라운드 ThreadPoolExecutor
- GET /api/jobs/{id} 상태 폴링 (queued→running→done/error)
- MAX_CONCURRENT_JOBS 환경변수로 동시 처리 수 제어
[영상 생성 백엔드]
- VIDEO_BACKEND=fal: fal.ai Seedance 2.0 reference-to-video (멀티이미지 9장 + 9:16 세로)
- VIDEO_BACKEND=higgsfield: Higgsfield (dop/seedance v1/kling, 단일 이미지)
- 파일명 ASCII 정규화 (한글 파일명 → fal 업로드 codec 에러 방지)
[Remotion 안정화]
- BrandLines/SellingBadge/HookTitle interpolate 범위 역전 크래시 수정
- _timings() 를 고정값 clamp → 영상 길이 비례 스케일로 변경
- 폰트 NanumMyeongjo weight 800 제거, latin subset 제거 (요청 수 ~50% 감소)
- render() 반환값에 임시 파일 목록 추가, 완료/실패 시 자동 정리
[Azure Blob Storage]
- 완성 영상·메타데이터 JSON 자동 업로드 (azure_storage.py)
- /api/outputs: Azure List Blobs 우선, 로컬 fallback
- video_url을 Azure 공개 URL로 갱신 (SAS 불필요)
[갤러리 UI]
- 상단 "생성된 영상" 버튼 → 9:16 썸네일 그리드 + 상세 재생/다운로드
- 생성 실패 시 데모 폴백 제거 → 에러 카드 표시
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
parent
154bf5b992
commit
31afa04aa9
|
|
@ -68,3 +68,11 @@ REMOTION_COMPOSITION=MumumShort
|
||||||
# WEBAPP_DIR=
|
# WEBAPP_DIR=
|
||||||
# OUTPUTS_DIR=
|
# OUTPUTS_DIR=
|
||||||
# UPLOADS_DIR=
|
# UPLOADS_DIR=
|
||||||
|
|
||||||
|
# --- Azure Blob Storage (완성 영상 업로드 + 갤러리 조회) ---
|
||||||
|
# 비워두면 로컬 파일시스템 fallback.
|
||||||
|
# BASE_URL: 컨테이너 내 가상 폴더 경로. 끝에 / 포함.
|
||||||
|
# 예) https://account.blob.core.windows.net/container/prefix/
|
||||||
|
# SAS_TOKEN: 컨테이너 범위 SAS. 필수 권한: sp=racwdl (읽기·추가·생성·쓰기·삭제·목록).
|
||||||
|
AZURE_BLOB_BASE_URL=
|
||||||
|
AZURE_BLOB_SAS_TOKEN=
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,17 @@ _fal_key = os.getenv("FAL_API_KEY")
|
||||||
if _fal_key and not os.getenv("FAL_KEY"):
|
if _fal_key and not os.getenv("FAL_KEY"):
|
||||||
os.environ["FAL_KEY"] = _fal_key
|
os.environ["FAL_KEY"] = _fal_key
|
||||||
|
|
||||||
|
# ---- Azure Blob Storage (완성 영상 영속 저장 + 갤러리 조회) ----
|
||||||
|
# BASE_URL: 컨테이너 내 가상 폴더 경로. 끝에 / 포함.
|
||||||
|
# 예) https://account.blob.core.windows.net/container/prefix/
|
||||||
|
# SAS_TOKEN: 컨테이너 범위 SAS (sp=racwdl 이상 필요).
|
||||||
|
# 비어있으면 로컬 파일시스템 fallback.
|
||||||
|
AZURE_BLOB_BASE_URL = os.getenv("AZURE_BLOB_BASE_URL", "").rstrip("/") + "/"
|
||||||
|
AZURE_BLOB_SAS_TOKEN = os.getenv("AZURE_BLOB_SAS_TOKEN", "")
|
||||||
|
|
||||||
|
def azure_enabled() -> bool:
|
||||||
|
return bool(AZURE_BLOB_BASE_URL.strip("/") and AZURE_BLOB_SAS_TOKEN)
|
||||||
|
|
||||||
# ---- Remotion (Node render) ----
|
# ---- Remotion (Node render) ----
|
||||||
REMOTION_FPS = int(os.getenv("REMOTION_FPS", "30"))
|
REMOTION_FPS = int(os.getenv("REMOTION_FPS", "30"))
|
||||||
REMOTION_COMPOSITION = os.getenv("REMOTION_COMPOSITION", "MumumShort")
|
REMOTION_COMPOSITION = os.getenv("REMOTION_COMPOSITION", "MumumShort")
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
@ -23,7 +24,7 @@ from pathlib import Path
|
||||||
|
|
||||||
from app import config as cfg
|
from app import config as cfg
|
||||||
from app.schemas import GenerateRequest
|
from app.schemas import GenerateRequest
|
||||||
from app.pipeline import spec_builder, higgsfield_client, fal_client, remotion_render
|
from app.pipeline import spec_builder, higgsfield_client, fal_client, remotion_render, azure_storage
|
||||||
|
|
||||||
OUTPUTS = cfg.OUTPUTS_DIR
|
OUTPUTS = cfg.OUTPUTS_DIR
|
||||||
|
|
||||||
|
|
@ -140,6 +141,35 @@ def _run(job_id: str, req: GenerateRequest, photo_paths: list[Path], dry_run: bo
|
||||||
job_id, status="done", stage=None, stage_index=2, progress=100,
|
job_id, status="done", stage=None, stage_index=2, progress=100,
|
||||||
video_url=f"/outputs/{job_id}.mp4",
|
video_url=f"/outputs/{job_id}.mp4",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 메타데이터 구성
|
||||||
|
meta = {
|
||||||
|
"job_id": job_id,
|
||||||
|
"video_url": f"/outputs/{job_id}.mp4", # Azure 업로드 성공 시 아래서 교체
|
||||||
|
"biz_name": req.biz_name,
|
||||||
|
"caption": spec.caption,
|
||||||
|
"profile": spec.profile,
|
||||||
|
"created_at": _now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Azure Blob Storage 업로드 (설정된 경우)
|
||||||
|
if cfg.azure_enabled():
|
||||||
|
try:
|
||||||
|
azure_url = azure_storage.upload_mp4(job_id, served)
|
||||||
|
meta["video_url"] = azure_url # 갤러리·폴링 모두 Azure URL 사용
|
||||||
|
_set(job_id, video_url=azure_url)
|
||||||
|
# JSON 메타데이터도 Azure 에 올림
|
||||||
|
azure_storage.upload_meta(job_id, meta)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[job {job_id}] Azure 업로드 실패(로컬 서빙 유지): {e}")
|
||||||
|
|
||||||
|
# 로컬 JSON 영속화 (Docker 볼륨, 서버 재시작 후에도 유지)
|
||||||
|
try:
|
||||||
|
(OUTPUTS / f"{job_id}.json").write_text(
|
||||||
|
json.dumps(meta, ensure_ascii=False), encoding="utf-8"
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 어떤 단계에서 왜 실패했는지 uvicorn 콘솔에 전체 스택을 남긴다(디버깅 필수).
|
# 어떤 단계에서 왜 실패했는지 uvicorn 콘솔에 전체 스택을 남긴다(디버깅 필수).
|
||||||
print(f"[job {job_id}] FAILED at stage={get(job_id).get('stage') if get(job_id) else '?'}: {e}")
|
print(f"[job {job_id}] FAILED at stage={get(job_id).get('stage') if get(job_id) else '?'}: {e}")
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@
|
||||||
POST /api/generate (multipart: photos[] + interview fields) → job_id 즉시 반환
|
POST /api/generate (multipart: photos[] + interview fields) → job_id 즉시 반환
|
||||||
백그라운드 워커: build_spec (OpenAI) → higgsfield.generate → remotion.render → mp4
|
백그라운드 워커: build_spec (OpenAI) → higgsfield.generate → remotion.render → mp4
|
||||||
GET /api/jobs/{id} 작업 상태 폴링 (queued | running | done | error)
|
GET /api/jobs/{id} 작업 상태 폴링 (queued | running | done | error)
|
||||||
|
GET /api/outputs 완료된 영상 목록 (갤러리)
|
||||||
GET /api/health
|
GET /api/health
|
||||||
Static: /outputs/<file> (final videos), / (the web app)
|
Static: /outputs/<file> (final videos), / (the web app)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -18,8 +20,8 @@ from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from app import config as cfg, jobs
|
from app import config as cfg, jobs
|
||||||
from app.schemas import GenerateRequest, JobStatus, ScriptResult
|
from app.schemas import GenerateRequest, JobStatus, ScriptResult, VideoMeta
|
||||||
from app.pipeline import spec_builder
|
from app.pipeline import spec_builder, azure_storage
|
||||||
|
|
||||||
WEBAPP = cfg.WEBAPP_DIR
|
WEBAPP = cfg.WEBAPP_DIR
|
||||||
OUTPUTS = cfg.OUTPUTS_DIR
|
OUTPUTS = cfg.OUTPUTS_DIR
|
||||||
|
|
@ -37,6 +39,61 @@ def health():
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/outputs", response_model=list[VideoMeta])
|
||||||
|
def list_outputs():
|
||||||
|
"""완료된 영상 목록을 최신순으로 반환.
|
||||||
|
|
||||||
|
Azure 설정이 있으면 Azure Blob 목록을 조회하고,
|
||||||
|
로컬 JSON 에서 메타데이터(업체명·캡션·프로파일)를 보완한다.
|
||||||
|
Azure 미설정 시 로컬 outputs/ 디렉터리를 직접 스캔한다.
|
||||||
|
"""
|
||||||
|
# ── Azure 우선 ──
|
||||||
|
if cfg.azure_enabled():
|
||||||
|
try:
|
||||||
|
blobs = azure_storage.list_videos() # [{job_id, video_url, created_at}]
|
||||||
|
results: list[VideoMeta] = []
|
||||||
|
for b in blobs:
|
||||||
|
job_id = b["job_id"]
|
||||||
|
meta_file = OUTPUTS / f"{job_id}.json"
|
||||||
|
if meta_file.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||||
|
data["video_url"] = b["video_url"] # Azure URL 우선
|
||||||
|
results.append(VideoMeta(**data))
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
results.append(VideoMeta(
|
||||||
|
job_id=job_id,
|
||||||
|
video_url=b["video_url"],
|
||||||
|
created_at=b["created_at"],
|
||||||
|
))
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
# Azure 조회 실패 시 로컬 fallback
|
||||||
|
print(f"[outputs] Azure 목록 조회 실패, 로컬 fallback: {e}")
|
||||||
|
|
||||||
|
# ── 로컬 fallback ──
|
||||||
|
OUTPUTS.mkdir(parents=True, exist_ok=True)
|
||||||
|
results = []
|
||||||
|
for mp4 in sorted(OUTPUTS.glob("*.mp4"), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||||
|
job_id = mp4.stem
|
||||||
|
meta_file = OUTPUTS / f"{job_id}.json"
|
||||||
|
if meta_file.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||||
|
results.append(VideoMeta(**data))
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
results.append(VideoMeta(
|
||||||
|
job_id=job_id,
|
||||||
|
video_url=f"/outputs/{job_id}.mp4",
|
||||||
|
created_at=mp4.stat().st_mtime,
|
||||||
|
))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/captions", response_model=ScriptResult)
|
@app.post("/api/captions", response_model=ScriptResult)
|
||||||
def captions(req: GenerateRequest):
|
def captions(req: GenerateRequest):
|
||||||
"""인터뷰 답변 → 인트로·셀링포인트·감성스토리·CTA 4블록 (OpenAI)."""
|
"""인터뷰 답변 → 인트로·셀링포인트·감성스토리·CTA 4블록 (OpenAI)."""
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
"""Azure Blob Storage 연동 — 완성 영상·메타데이터 업로드 + 갤러리 목록 조회.
|
||||||
|
|
||||||
|
SDK 없이 httpx 로 Azure Blob REST API 를 직접 호출한다(이미 의존성).
|
||||||
|
|
||||||
|
BASE_URL: https://account.blob.core.windows.net/container/prefix/
|
||||||
|
→ 업로드: PUT BASE_URL{blob}?{sas}
|
||||||
|
→ 목록: GET https://account.blob.core.windows.net/container
|
||||||
|
?restype=container&comp=list&prefix={prefix}&{sas}
|
||||||
|
→ 공개URL: BASE_URL{blob} (SAS 불필요, 공개 컨테이너)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app import config as cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _put_url(filename: str) -> str:
|
||||||
|
"""업로드 SAS URL — BASE_URL/{filename}?{sas_token}."""
|
||||||
|
return f"{cfg.AZURE_BLOB_BASE_URL}{filename}?{cfg.AZURE_BLOB_SAS_TOKEN}"
|
||||||
|
|
||||||
|
|
||||||
|
def _list_url() -> str:
|
||||||
|
"""컨테이너 List Blobs SAS URL (prefix 필터 포함)."""
|
||||||
|
parsed = urlparse(cfg.AZURE_BLOB_BASE_URL)
|
||||||
|
# parsed.path = /container/prefix/ → parts = ["container", "prefix/"]
|
||||||
|
path_parts = parsed.path.lstrip("/").split("/", 1)
|
||||||
|
container = path_parts[0]
|
||||||
|
prefix = path_parts[1] if len(path_parts) > 1 else ""
|
||||||
|
base = f"{parsed.scheme}://{parsed.netloc}/{container}"
|
||||||
|
return (
|
||||||
|
f"{base}?restype=container&comp=list"
|
||||||
|
f"&prefix={prefix}&{cfg.AZURE_BLOB_SAS_TOKEN}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _public_url(filename: str) -> str:
|
||||||
|
"""SAS 없는 공개 접근 URL."""
|
||||||
|
return f"{cfg.AZURE_BLOB_BASE_URL}{filename}"
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────── 업로드 ────────────────────────────
|
||||||
|
|
||||||
|
def upload_mp4(job_id: str, mp4_path: Path) -> str:
|
||||||
|
"""mp4 파일을 Azure 에 업로드하고 공개 URL 을 반환한다."""
|
||||||
|
filename = f"{job_id}.mp4"
|
||||||
|
with open(mp4_path, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
resp = httpx.put(
|
||||||
|
_put_url(filename), content=data,
|
||||||
|
headers={"x-ms-blob-type": "BlockBlob", "Content-Type": "video/mp4"},
|
||||||
|
timeout=300.0,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return _public_url(filename)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_meta(job_id: str, meta: dict) -> None:
|
||||||
|
"""메타데이터 JSON 을 Azure 에 업로드한다(캡션·프로파일 등)."""
|
||||||
|
filename = f"{job_id}.json"
|
||||||
|
data = json.dumps(meta, ensure_ascii=False).encode("utf-8")
|
||||||
|
resp = httpx.put(
|
||||||
|
_put_url(filename), content=data,
|
||||||
|
headers={"x-ms-blob-type": "BlockBlob", "Content-Type": "application/json; charset=utf-8"},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────── 목록 조회 ────────────────────────────
|
||||||
|
|
||||||
|
def list_videos() -> list[dict]:
|
||||||
|
"""Azure 에서 mp4 블롭 목록을 최신순으로 반환.
|
||||||
|
|
||||||
|
각 항목: {job_id, video_url(공개), created_at(Unix ts)}
|
||||||
|
"""
|
||||||
|
resp = httpx.get(_list_url(), timeout=30.0)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
root = ET.fromstring(resp.text)
|
||||||
|
items: list[dict] = []
|
||||||
|
for blob in root.iter("Blob"):
|
||||||
|
name_el = blob.find("Name")
|
||||||
|
if name_el is None or not (name_el.text or "").endswith(".mp4"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
job_id = Path(name_el.text).stem # "prefix/abc123.mp4" → "abc123"
|
||||||
|
|
||||||
|
ts = 0.0
|
||||||
|
props = blob.find("Properties")
|
||||||
|
if props is not None:
|
||||||
|
lm = props.find("Last-Modified")
|
||||||
|
if lm is not None and lm.text:
|
||||||
|
try:
|
||||||
|
ts = parsedate_to_datetime(lm.text).timestamp()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"job_id": job_id,
|
||||||
|
"video_url": _public_url(f"{job_id}.mp4"),
|
||||||
|
"created_at": ts,
|
||||||
|
})
|
||||||
|
|
||||||
|
items.sort(key=lambda x: x["created_at"], reverse=True)
|
||||||
|
return items
|
||||||
|
|
@ -64,6 +64,16 @@ class GenerateResult(BaseModel):
|
||||||
job_id: Optional[str] = None
|
job_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Outbound: 갤러리용 완료 영상 메타데이터 ----------
|
||||||
|
class VideoMeta(BaseModel):
|
||||||
|
job_id: str
|
||||||
|
video_url: str
|
||||||
|
biz_name: Optional[str] = None
|
||||||
|
caption: Optional[str] = None
|
||||||
|
profile: Optional[str] = None
|
||||||
|
created_at: float = 0.0 # Unix timestamp
|
||||||
|
|
||||||
|
|
||||||
# ---------- Outbound: 비동기 작업 상태 (폴링) ----------
|
# ---------- Outbound: 비동기 작업 상태 (폴링) ----------
|
||||||
class JobStatus(BaseModel):
|
class JobStatus(BaseModel):
|
||||||
job_id: str
|
job_id: str
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,16 @@
|
||||||
/* Brand logo */
|
/* Brand logo */
|
||||||
.brand-logo{height:30px;width:auto;display:block;}
|
.brand-logo{height:30px;width:auto;display:block;}
|
||||||
|
|
||||||
|
/* ===== Gallery ===== */
|
||||||
|
.gallery-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px;}
|
||||||
|
@media(max-width:760px){.gallery-grid{grid-template-columns:repeat(2,1fr);}}
|
||||||
|
@media(max-width:480px){.gallery-grid{grid-template-columns:1fr;}}
|
||||||
|
.gallery-card{cursor:pointer;border-radius:20px;overflow:hidden;background:var(--bg-2);border:1px solid var(--stroke-1);transition:.15s;}
|
||||||
|
.gallery-card:hover{border-color:var(--ado-mint);transform:translateY(-2px);box-shadow:0 8px 28px rgba(0,0,0,0.35);}
|
||||||
|
.gallery-thumb{position:relative;aspect-ratio:9/16;background:var(--bg-3);overflow:hidden;}
|
||||||
|
.gallery-play{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.28);opacity:0;transition:.15s;color:#fff;}
|
||||||
|
.gallery-card:hover .gallery-play{opacity:1;}
|
||||||
|
|
||||||
/* Hero selling-point field (emphasized) */
|
/* Hero selling-point field (emphasized) */
|
||||||
.field-hero label{color:var(--ado-mint);}
|
.field-hero label{color:var(--ado-mint);}
|
||||||
.field-hero textarea{min-height:64px;border-color:rgba(148,251,224,0.35);} /* 절반 축소 */
|
.field-hero textarea{min-height:64px;border-color:rgba(148,251,224,0.35);} /* 절반 축소 */
|
||||||
|
|
@ -255,6 +265,9 @@ function App(){
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [script, setScript] = useState(null); // {intro,selling,story,cta}
|
const [script, setScript] = useState(null); // {intro,selling,story,cta}
|
||||||
const [scriptEditing, setScriptEditing] = useState({});
|
const [scriptEditing, setScriptEditing] = useState({});
|
||||||
|
const [gallery, setGallery] = useState(null); // null=미로드 / []~[...]=로드됨
|
||||||
|
const [galleryOpen, setGalleryOpen] = useState(false);
|
||||||
|
const [galleryVideo, setGalleryVideo] = useState(null); // 선택된 영상 {job_id,video_url,...}
|
||||||
const [scriptLoading, setScriptLoading] = useState(false);
|
const [scriptLoading, setScriptLoading] = useState(false);
|
||||||
const fileRef = useRef();
|
const fileRef = useRef();
|
||||||
const pollRef = useRef(null); // 진행 중인 폴링 타이머 (언마운트/리셋 시 정리)
|
const pollRef = useRef(null); // 진행 중인 폴링 타이머 (언마운트/리셋 시 정리)
|
||||||
|
|
@ -369,6 +382,22 @@ function App(){
|
||||||
navigator.clipboard.writeText(editedCaption).then(()=>{ setCopied(true); setTimeout(()=>setCopied(false), 1800); });
|
navigator.clipboard.writeText(editedCaption).then(()=>{ setCopied(true); setTimeout(()=>setCopied(false), 1800); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openGallery(){
|
||||||
|
setGalleryOpen(true);
|
||||||
|
setGalleryVideo(null);
|
||||||
|
fetch(`${API_BASE}/api/outputs`)
|
||||||
|
.then(r=> r.ok ? r.json() : Promise.reject())
|
||||||
|
.then(list=> setGallery(list))
|
||||||
|
.catch(()=> setGallery([]));
|
||||||
|
}
|
||||||
|
function closeGallery(){ setGalleryOpen(false); setGalleryVideo(null); }
|
||||||
|
|
||||||
|
function fmtDate(ts){
|
||||||
|
if(!ts) return "";
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
return `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,"0")}.${String(d.getDate()).padStart(2,"0")} ${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
// 백엔드 없을 때 입력값 기반 폴백 (Vercel 정적 배포에서도 동작)
|
// 백엔드 없을 때 입력값 기반 폴백 (Vercel 정적 배포에서도 동작)
|
||||||
function localScript(){
|
function localScript(){
|
||||||
if(kind === "message"){
|
if(kind === "message"){
|
||||||
|
|
@ -417,10 +446,83 @@ function App(){
|
||||||
<div>
|
<div>
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
<img className="brand-logo" src="./assets/ado2-logo-white.png" alt="ADO2.AI" />
|
<img className="brand-logo" src="./assets/ado2-logo-white.png" alt="ADO2.AI" />
|
||||||
<div className="step-count"><b>{stepIdx+1}</b> / 4</div>
|
<div style={{display:"flex",alignItems:"center",gap:16}}>
|
||||||
|
{!galleryOpen && <div className="step-count"><b>{stepIdx+1}</b> / 4</div>}
|
||||||
|
<button className="btn btn-ghost" style={{height:40,padding:"0 18px",fontSize:14}} onClick={galleryOpen ? closeGallery : openGallery}>
|
||||||
|
{galleryOpen ? "← 돌아가기" : "생성된 영상"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="wrap">
|
<div className="wrap">
|
||||||
{step==="setup" && (
|
|
||||||
|
{/* 갤러리 오버레이 */}
|
||||||
|
{galleryOpen && !galleryVideo && (
|
||||||
|
<div>
|
||||||
|
<div style={{marginBottom:32}}>
|
||||||
|
<h2 style={{font:"800 32px/1.15 var(--font)",margin:"0 0 8px",letterSpacing:"-0.018em"}}>생성된 영상</h2>
|
||||||
|
<p style={{font:"500 17px/1.55 var(--font)",color:"var(--text-teal-1)",margin:0}}>지금까지 만들어진 영상을 다운로드하거나 다시 볼 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
{gallery === null && (
|
||||||
|
<div style={{textAlign:"center",padding:"60px 0",color:"var(--text-teal-3)"}}>
|
||||||
|
<div className="spinner" style={{margin:"0 auto 16px"}}></div>불러오는 중…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{gallery && gallery.length === 0 && (
|
||||||
|
<div className="card" style={{textAlign:"center",padding:"60px 0",color:"var(--text-teal-3)"}}>
|
||||||
|
아직 완성된 영상이 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{gallery && gallery.length > 0 && (
|
||||||
|
<div className="gallery-grid">
|
||||||
|
{gallery.map(v=>(
|
||||||
|
<div key={v.job_id} className="gallery-card" onClick={()=>setGalleryVideo(v)}>
|
||||||
|
<div className="gallery-thumb">
|
||||||
|
<video src={(API_BASE||"")+v.video_url} muted playsInline preload="metadata" style={{width:"100%",height:"100%",objectFit:"cover",borderRadius:16,display:"block"}}/>
|
||||||
|
<div className="gallery-play"><Icon.Play size={22}/></div>
|
||||||
|
</div>
|
||||||
|
<div style={{padding:"14px 4px 4px"}}>
|
||||||
|
<div style={{font:"700 16px/1.3 var(--font)",color:"var(--text-mute-2)",marginBottom:4,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"}}>{v.biz_name || v.job_id}</div>
|
||||||
|
<div style={{font:"500 13px/1 var(--font)",color:"var(--text-teal-3)"}}>{fmtDate(v.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 갤러리 상세 (영상 선택 시) */}
|
||||||
|
{galleryOpen && galleryVideo && (
|
||||||
|
<div className="card">
|
||||||
|
<button onClick={()=>setGalleryVideo(null)} className="btn btn-ghost" style={{marginBottom:24,height:40,padding:"0 18px",fontSize:14}}>← 목록으로</button>
|
||||||
|
<div className="result-grid">
|
||||||
|
<div className="phone">
|
||||||
|
<video src={(API_BASE||"")+galleryVideo.video_url} controls playsInline autoPlay></video>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="res-h">{galleryVideo.biz_name || galleryVideo.job_id}</h2>
|
||||||
|
<p className="res-sub">{fmtDate(galleryVideo.created_at)}</p>
|
||||||
|
{galleryVideo.profile && (
|
||||||
|
<div className="res-badges" style={{marginBottom:20}}>
|
||||||
|
<span className="tone-chip"><i></i>{galleryVideo.profile}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{galleryVideo.caption && (
|
||||||
|
<>
|
||||||
|
<div className="cap-head"><span className="lbl">업로드 캡션</span></div>
|
||||||
|
<div className="caption-box">{galleryVideo.caption}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="res-actions">
|
||||||
|
<a className="btn btn-primary" href={(API_BASE||"")+galleryVideo.video_url} download><Icon.Download/> 다운로드</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 메인 생성 플로우 (갤러리 닫혀있을 때만) */}
|
||||||
|
{!galleryOpen && step==="setup" && (
|
||||||
<div className="hero">
|
<div className="hero">
|
||||||
<div className="eyebrow">ADO2 Hookit · 8초 숏폼</div>
|
<div className="eyebrow">ADO2 Hookit · 8초 숏폼</div>
|
||||||
<h1 className="hero-title">사진 몇 장과 한마디면,<br/><em>8초 홍보 영상</em>이 됩니다</h1>
|
<h1 className="hero-title">사진 몇 장과 한마디면,<br/><em>8초 홍보 영상</em>이 됩니다</h1>
|
||||||
|
|
@ -428,17 +530,17 @@ function App(){
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="stepper">
|
{!galleryOpen && <div className="stepper">
|
||||||
{STEPS.map((s,i)=>(
|
{STEPS.map((s,i)=>(
|
||||||
<div key={s.k} className={"step"+(i===stepIdx?" active":"")+(i<stepIdx?" done":"")}>
|
<div key={s.k} className={"step"+(i===stepIdx?" active":"")+(i<stepIdx?" done":"")}>
|
||||||
<div className="num">{i<stepIdx ? <Icon.Check size={20}/> : s.n}</div>
|
<div className="num">{i<stepIdx ? <Icon.Check size={20}/> : s.n}</div>
|
||||||
<div><b>{s.b}</b><span>{s.s}</span></div>
|
<div><b>{s.b}</b><span>{s.s}</span></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* STEP 1 — UPLOAD */}
|
{/* STEP 1 — UPLOAD */}
|
||||||
{step==="setup" && (
|
{!galleryOpen && step==="setup" && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>사진 업로드</h2>
|
<h2>사진 업로드</h2>
|
||||||
<p className="sub">홍보할 장소나 물건 사진 4~5장. 전경 1장 · 디테일 2~3장을 섞으면 영상의 서사가 살아납니다.</p>
|
<p className="sub">홍보할 장소나 물건 사진 4~5장. 전경 1장 · 디테일 2~3장을 섞으면 영상의 서사가 살아납니다.</p>
|
||||||
|
|
@ -476,7 +578,7 @@ function App(){
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* STEP 2 — INTERVIEW (사람이 직접 답 = 인텔리전스) */}
|
{/* STEP 2 — INTERVIEW (사람이 직접 답 = 인텔리전스) */}
|
||||||
{step==="interview" && (
|
{!galleryOpen && step==="interview" && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>몇 가지만 알려주세요</h2>
|
<h2>몇 가지만 알려주세요</h2>
|
||||||
<p className="sub">사장님·마케터가 가장 잘 아는 정보로 카피를 만듭니다.</p>
|
<p className="sub">사장님·마케터가 가장 잘 아는 정보로 카피를 만듭니다.</p>
|
||||||
|
|
@ -560,7 +662,7 @@ function App(){
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* STEP 3 — GENERATING */}
|
{/* STEP 3 — GENERATING */}
|
||||||
{step==="generating" && (
|
{!galleryOpen && step==="generating" && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>영상 생성 중</h2>
|
<h2>영상 생성 중</h2>
|
||||||
<p className="sub">한 번의 생성으로 트랜지션·자막·사운드까지 완성됩니다.</p>
|
<p className="sub">한 번의 생성으로 트랜지션·자막·사운드까지 완성됩니다.</p>
|
||||||
|
|
@ -587,7 +689,7 @@ function App(){
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* STEP 4 — RESULT (성공: 영상 / 실패: 에러 카드) */}
|
{/* STEP 4 — RESULT (성공: 영상 / 실패: 에러 카드) */}
|
||||||
{step==="result" && errMsg && (
|
{!galleryOpen && step==="result" && errMsg && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2 className="res-h">영상 생성에 실패했습니다</h2>
|
<h2 className="res-h">영상 생성에 실패했습니다</h2>
|
||||||
<p className="res-sub">아래 사유를 확인하고 다시 시도해 주세요. (데모 영상은 표시하지 않습니다)</p>
|
<p className="res-sub">아래 사유를 확인하고 다시 시도해 주세요. (데모 영상은 표시하지 않습니다)</p>
|
||||||
|
|
@ -598,7 +700,7 @@ function App(){
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{step==="result" && !errMsg && videoUrl && (
|
{!galleryOpen && 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