From 31afa04aa915e1282e122f857865d2c82a6c4b83 Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 1 Jun 2026 14:41:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20+=20fal=20Seedance=202.0?= =?UTF-8?q?=20+=20Azure=20Blob=20=EA=B0=A4=EB=9F=AC=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [비동기 처리] - 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 --- server/.env.example | 8 ++ server/app/config.py | 11 +++ server/app/jobs.py | 32 ++++++- server/app/main.py | 61 +++++++++++++- server/app/pipeline/azure_storage.py | 112 +++++++++++++++++++++++++ server/app/schemas.py | 10 +++ webapp/index.html | 120 +++++++++++++++++++++++++-- 7 files changed, 342 insertions(+), 12 deletions(-) create mode 100644 server/app/pipeline/azure_storage.py diff --git a/server/.env.example b/server/.env.example index 94f1fca..fd38abc 100644 --- a/server/.env.example +++ b/server/.env.example @@ -68,3 +68,11 @@ REMOTION_COMPOSITION=MumumShort # WEBAPP_DIR= # OUTPUTS_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= diff --git a/server/app/config.py b/server/app/config.py index ab77666..03f8360 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -81,6 +81,17 @@ _fal_key = os.getenv("FAL_API_KEY") if _fal_key and not os.getenv("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_FPS = int(os.getenv("REMOTION_FPS", "30")) REMOTION_COMPOSITION = os.getenv("REMOTION_COMPOSITION", "MumumShort") diff --git a/server/app/jobs.py b/server/app/jobs.py index 18b0642..fdc0e1e 100644 --- a/server/app/jobs.py +++ b/server/app/jobs.py @@ -13,6 +13,7 @@ """ from __future__ import annotations +import json import shutil import threading import time @@ -23,7 +24,7 @@ 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 +from app.pipeline import spec_builder, higgsfield_client, fal_client, remotion_render, azure_storage 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, 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: # 어떤 단계에서 왜 실패했는지 uvicorn 콘솔에 전체 스택을 남긴다(디버깅 필수). print(f"[job {job_id}] FAILED at stage={get(job_id).get('stage') if get(job_id) else '?'}: {e}") diff --git a/server/app/main.py b/server/app/main.py index ea9939e..70973b7 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -3,11 +3,13 @@ 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/outputs 완료된 영상 목록 (갤러리) GET /api/health Static: /outputs/ (final videos), / (the web app) """ from __future__ import annotations +import json import shutil import uuid from pathlib import Path @@ -18,8 +20,8 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from app import config as cfg, jobs -from app.schemas import GenerateRequest, JobStatus, ScriptResult -from app.pipeline import spec_builder +from app.schemas import GenerateRequest, JobStatus, ScriptResult, VideoMeta +from app.pipeline import spec_builder, azure_storage WEBAPP = cfg.WEBAPP_DIR OUTPUTS = cfg.OUTPUTS_DIR @@ -37,6 +39,61 @@ def health(): 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) def captions(req: GenerateRequest): """인터뷰 답변 → 인트로·셀링포인트·감성스토리·CTA 4블록 (OpenAI).""" diff --git a/server/app/pipeline/azure_storage.py b/server/app/pipeline/azure_storage.py new file mode 100644 index 0000000..1618d43 --- /dev/null +++ b/server/app/pipeline/azure_storage.py @@ -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 diff --git a/server/app/schemas.py b/server/app/schemas.py index 950692b..ef6787d 100644 --- a/server/app/schemas.py +++ b/server/app/schemas.py @@ -64,6 +64,16 @@ class GenerateResult(BaseModel): 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: 비동기 작업 상태 (폴링) ---------- class JobStatus(BaseModel): job_id: str diff --git a/webapp/index.html b/webapp/index.html index a2bdd7d..cf958a4 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -163,6 +163,16 @@ /* Brand logo */ .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) */ .field-hero label{color:var(--ado-mint);} .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 [script, setScript] = useState(null); // {intro,selling,story,cta} 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 fileRef = useRef(); const pollRef = useRef(null); // 진행 중인 폴링 타이머 (언마운트/리셋 시 정리) @@ -369,6 +382,22 @@ function App(){ 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 정적 배포에서도 동작) function localScript(){ if(kind === "message"){ @@ -417,10 +446,83 @@ function App(){
ADO2.AI -
{stepIdx+1} / 4
+
+ {!galleryOpen &&
{stepIdx+1} / 4
} + +
- {step==="setup" && ( + + {/* 갤러리 오버레이 */} + {galleryOpen && !galleryVideo && ( +
+
+

생성된 영상

+

지금까지 만들어진 영상을 다운로드하거나 다시 볼 수 있습니다.

+
+ {gallery === null && ( +
+
불러오는 중… +
+ )} + {gallery && gallery.length === 0 && ( +
+ 아직 완성된 영상이 없습니다. +
+ )} + {gallery && gallery.length > 0 && ( +
+ {gallery.map(v=>( +
setGalleryVideo(v)}> +
+
+
+
{v.biz_name || v.job_id}
+
{fmtDate(v.created_at)}
+
+
+ ))} +
+ )} +
+ )} + + {/* 갤러리 상세 (영상 선택 시) */} + {galleryOpen && galleryVideo && ( +
+ +
+
+ +
+
+

{galleryVideo.biz_name || galleryVideo.job_id}

+

{fmtDate(galleryVideo.created_at)}

+ {galleryVideo.profile && ( +
+ {galleryVideo.profile} +
+ )} + {galleryVideo.caption && ( + <> +
업로드 캡션
+
{galleryVideo.caption}
+ + )} + +
+
+
+ )} + + {/* 메인 생성 플로우 (갤러리 닫혀있을 때만) */} + {!galleryOpen && step==="setup" && (
ADO2 Hookit · 8초 숏폼

사진 몇 장과 한마디면,
8초 홍보 영상이 됩니다

@@ -428,17 +530,17 @@ function App(){
)} -
+ {!galleryOpen &&
{STEPS.map((s,i)=>(
{i : s.n}
{s.b}{s.s}
))} -
+
} {/* STEP 1 — UPLOAD */} - {step==="setup" && ( + {!galleryOpen && step==="setup" && (

사진 업로드

홍보할 장소나 물건 사진 4~5장. 전경 1장 · 디테일 2~3장을 섞으면 영상의 서사가 살아납니다.

@@ -476,7 +578,7 @@ function App(){ )} {/* STEP 2 — INTERVIEW (사람이 직접 답 = 인텔리전스) */} - {step==="interview" && ( + {!galleryOpen && step==="interview" && (

몇 가지만 알려주세요

사장님·마케터가 가장 잘 아는 정보로 카피를 만듭니다.

@@ -560,7 +662,7 @@ function App(){ )} {/* STEP 3 — GENERATING */} - {step==="generating" && ( + {!galleryOpen && step==="generating" && (

영상 생성 중

한 번의 생성으로 트랜지션·자막·사운드까지 완성됩니다.

@@ -587,7 +689,7 @@ function App(){ )} {/* STEP 4 — RESULT (성공: 영상 / 실패: 에러 카드) */} - {step==="result" && errMsg && ( + {!galleryOpen && step==="result" && errMsg && (

영상 생성에 실패했습니다

아래 사유를 확인하고 다시 시도해 주세요. (데모 영상은 표시하지 않습니다)

@@ -598,7 +700,7 @@ function App(){
)} - {step==="result" && !errMsg && videoUrl && ( + {!galleryOpen && step==="result" && !errMsg && videoUrl && (