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
hbyang 2026-06-01 14:41:33 +09:00
parent 154bf5b992
commit 31afa04aa9
7 changed files with 342 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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