Initial commit: ADO2 Hookit — 8초 후킹 숏폼 생성 파이프라인

3-엔진 오케스트레이션(Claude → Higgsfield → Remotion) PoC + 셀프 웹앱.
백엔드 개발자 핸드오프 문서(HANDOFF.md) 포함.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
main
o2odev 2026-05-29 12:03:48 +09:00
commit 4deefff061
50 changed files with 5962 additions and 0 deletions

22
.env.example Normal file
View File

@ -0,0 +1,22 @@
# Higgsfield Shorts Engine — Environment Variables
# 이 파일을 .env 로 복사한 뒤 실제 값으로 채우세요. (.env 는 .gitignore 됨)
# --- Higgsfield API ---
HIGGSFIELD_API_KEY=
HIGGSFIELD_BASE_URL=https://api.higgsfield.ai/v1
# --- ADO2 Tenant Context (선택) ---
# 멀티테넌트 운영 시 기본 tenant 지정
ADO2_DEFAULT_TENANT_ID=
ADO2_DEFAULT_SHOOT_ID=
# --- Cost & Quota Guardrails ---
HF_MAX_COST_PER_VIDEO_USD=2.0
HF_MONTHLY_BUDGET_USD=80
# --- Storage (Phase 2 — S3 결과 업로드) ---
S3_BUCKET_OUTPUTS=
S3_REGION=ap-northeast-2
# --- Observability ---
LOG_LEVEL=INFO

46
.gitignore vendored Normal file
View File

@ -0,0 +1,46 @@
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
pnpm-debug.log*
.npm/
# Environment
.env
.env.local
.env.*.local
# Python (Phase 2 — when src/ Python client is added)
.venv/
__pycache__/
*.egg-info/
*.pyc
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Media (대용량 — S3에만 보관)
inputs/*/*
outputs/*/*
!inputs/.gitkeep
!outputs/.gitkeep
!inputs/.gitignore
!outputs/.gitignore
# Remotion render outputs (재생성 가능)
remotion/out/
# Server runtime (업로드/렌더 결과)
server/.uploads/
server/outputs/
# Vercel (배포 메타 — 로컬 링크 정보)
.vercel/
# OS
.DS_Store
# IDE
.vscode/
.idea/
*.swp

222
HANDOFF.md Normal file
View File

@ -0,0 +1,222 @@
# ADO2 Hookit — 백엔드 개발자 핸드오프
> **무엇인가**: 사진 4장 + 5개 인터뷰 답변 → 8초 세로형(9:16) 후킹 숏폼을 자동 생성하는 파이프라인.
> "Intentional Unreal" 전략 — 실제 사진 기반에 AI 카메라 무브를 입혀 스크롤을 멈추게 하고, 디스클로저로 신뢰를 지킨다.
> ADO2 플랫폼의 **어텐션 트랙** 엔진 (`engine/higgsfield_shorts`).
이 문서는 PoC를 **운영 가능한 서비스**로 넘기기 위한 핸드오프입니다. 현 상태, 실행법, 데이터 계약, 그리고 운영화에 반드시 필요한 작업(❗)을 정리했습니다.
---
## 1. 아키텍처 — 3 엔진 오케스트레이션
```
인터뷰 답변(5개) + 사진(4장+)
① spec_builder.py Claude(claude-opus-4-7) → VideoSpec(JSON) / ScriptResult(JSON)
│ · 구조화 출력(json_schema) + 프롬프트 캐싱 + 가드레일
② higgsfield_client.py Higgsfield CLI shell-out → 완성형 8초 base.mp4 (오디오 포함)
│ · model=marketing_studio_video, mode=tv_spot, 9:16, 40cr
③ remotion_render.py VideoSpec + base.mp4 → props.json → `npx remotion render` → final.mp4
│ · 자막/타이틀/셀링배지/엔드카드 오버레이 합성
/outputs/<job>.mp4 + caption(업로드용 해시태그)
```
핵심 설계 원칙:
- **각 단계는 순수 함수**(입력 → path/객체 반환). 비동기 잡 큐로 바꾸기 쉬움.
- **데이터 주도 Remotion**: 컷/자막/타이밍을 props JSON으로 주입 → 콘텐츠가 바뀌어도 컴포넌트 불변.
- **dry_run 모드**: 크레딧 0으로 데모 클립 반환 (Higgsfield 호출 스킵). 개발·테스트용.
---
## 2. 디렉터리 구조
```
engine/higgsfield_shorts/
├── server/ # FastAPI 백엔드 (운영 대상 ★)
│ ├── app/
│ │ ├── main.py # 라우트: /api/health, /api/captions, /api/generate
│ │ ├── schemas.py # Pydantic 데이터 계약 (LLM·Remotion 공유)
│ │ └── pipeline/
│ │ ├── spec_builder.py # ① Claude → VideoSpec / ScriptResult
│ │ ├── higgsfield_client.py # ② Higgsfield CLI 래퍼
│ │ └── remotion_render.py # ③ Remotion 렌더 래퍼
│ ├── pyproject.toml # Python deps (fastapi, anthropic, pydantic…)
│ └── .env.example # ANTHROPIC_API_KEY
├── remotion/ # Remotion 영상 합성 프로젝트 (Node)
│ ├── src/
│ │ ├── Root.tsx # Composition 등록 (MumumShort)
│ │ ├── MumumShort.tsx # 메인 컴포지션 (props 기반)
│ │ ├── data/types.ts # ShortProps 타입 (= schemas.py VideoSpec와 1:1)
│ │ ├── data/mumum.ts # defaultProps (Studio 미리보기용 샘플)
│ │ └── components/ # HookTitle/Subtitle/SellingBadge/BrandLines/EndCard
│ ├── public/ # base 영상이 여기로 복사됨 (staticFile 해석)
│ └── package.json # remotion 4.0.468, react 19
├── webapp/ # 프론트엔드 (단일 HTML, Vercel 정적 배포)
│ ├── index.html # React(CDN) 단일 파일 — 4스텝 UI
│ ├── vercel.json
│ └── demo/mumum.mp4 # dry_run·데모용 완성 영상
├── _brief/03_energy-matching.md # 에너지 자동 매칭 결정표 (3 프로파일)
├── prompts/camera-vocabulary.md # 카메라 어휘 + 키프레임 기법
├── concepts/ # 5개 영상 컨셉 정의
└── docs/pipeline-integration.md # ADO2 본체 통합 노트
```
---
## 3. 사전 요구사항 (개발 환경)
| 항목 | 버전/조건 | 비고 |
|---|---|---|
| Python | ≥ 3.12 | `uv` 권장 |
| Node.js | ≥ 22 | Remotion 4.x 요구 |
| ffmpeg / ffprobe | 설치 필요 | `remotion_render._probe_frames`가 ffprobe 사용 |
| Higgsfield CLI | `@higgsfield/cli@^0.1.40` | `higgsfield auth login` 으로 **디바이스 로그인 인증 선행** |
| ANTHROPIC_API_KEY | 필수 | `server/.env` 에 설정 (Claude 호출) |
> ⚠️ Higgsfield는 **API 키가 아니라 CLI 디바이스 로그인**으로 인증합니다(`~/.higgsfield/`).
> 서버는 `subprocess`로 CLI를 호출하므로, **서버 실행 계정이 로그인되어 있어야** 실제 생성이 동작합니다.
> 컨테이너/서버리스 환경에서는 이 인증 상태 주입이 별도 과제입니다(§7 참고).
---
## 4. 로컬 실행
```bash
# 0) 인증 (1회)
higgsfield auth login
# 1) Remotion 의존성 (1회)
cd remotion && npm install && cd ..
# 2) 서버 의존성 + 환경변수
cd server
cp .env.example .env # ANTHROPIC_API_KEY 채우기
uv sync # 또는: pip install -e .
# 3) 서버 기동
uv run uvicorn app.main:app --reload --port 8000
# → http://localhost:8000/ (웹앱)
# → http://localhost:8000/api/health
# (선택) Remotion 미리보기 스튜디오
cd ../remotion && npm run studio
```
프론트엔드만 따로 확인하려면 `webapp/`을 정적 서빙 (`python3 -m http.server`). `index.html``API_BASE`를 백엔드 주소로 설정하면 실 호출, 비우면 데모 모드.
---
## 5. API 계약
### `GET /api/health`
```json
{ "ok": true }
```
### `POST /api/captions` — 자막 스크립트 4블록 생성 (JSON)
요청 (`GenerateRequest`):
```json
{
"kind": "place", // "place" | "product" | "message"
"biz_name": "Stay, 머뭄",
"addr": "전북 군산 …", // optional
"price": "1박 18만원", // optional
"selling": "프라이빗 독채, 넓은 정원, 깊은 욕조"
}
```
응답 (`ScriptResult`):
```json
{ "intro": "...", "selling": "...", "story": "...", "cta": "..." }
```
### `POST /api/generate` — 최종 영상 생성 (multipart/form-data)
필드: `photos[]`(파일, **4장 이상**), `kind`, `biz_name`, `selling`, `addr?`, `price?`, `dry_run?(bool)`
응답 (`GenerateResult`):
```json
{
"video_url": "/outputs/<job>.mp4",
"caption": "업로드용 캡션 + 해시태그",
"profile": "Still Cinema",
"cost_credits": 40.0,
"job_id": "ab12cd34"
}
```
데이터 계약 정의: `server/app/schemas.py` (VideoSpec / ScriptResult / GenerateRequest / GenerateResult).
Remotion 측 동일 계약: `remotion/src/data/types.ts`**두 파일은 1:1로 동기화 유지 필수**.
---
## 6. 가드레일 (반드시 유지 — 비즈니스 규칙)
`spec_builder.py`의 시스템 프롬프트에 인코딩됨. 프롬프트 수정 시 절대 훼손 금지:
1. **식민지 유산·역사정치 용어 금지** — "적산가옥/일제/식민지/근대사" 등 → 미학·경험 어휘로 대체.
("적산가옥"은 어원상 부정적 함의 → 숙박 카피에서 영구 차단)
2. **거짓·과장 금지** — 사용자가 주지 않은 거리/효능/수치를 지어내지 않음.
3. **자막은 흰색 모노크롬** — 카피에 색 지시어 금지. 강조는 굵기·크기·그림자로.
4. **AI 디스클로저 고정** — 엔드카드에 "실제 사진 기반, AI 카메라 효과를 적용한 영상입니다."
---
## 7. 운영화(Production) 작업 — ❗ 백엔드 핵심 과제
현재는 **동작하는 PoC**입니다. 실 트래픽 전 다음을 처리해야 합니다:
1. **❗ 동기 → 비동기 잡 전환**
`/api/generate`가 Higgsfield 대기(최대 15분) + Remotion 렌더(수십 초)를 **한 요청 안에서 블로킹**함.
`POST /api/generate``job_id` 즉시 반환, `GET /api/jobs/{id}` 폴링 or 웹소켓.
→ Celery/RQ/ARQ + Redis(이미 ADO2 스택에 있음) 권장.
2. **❗ Higgsfield 인증의 서버 이식**
CLI 디바이스 로그인(`~/.higgsfield/`)이 로컬 사용자에 묶여 있음. 컨테이너에서 동작하려면
인증 토큰/세션을 시크릿으로 주입하거나, Higgsfield **REST API 직접 호출**로 전환 검토
(현재는 CLI를 `subprocess`로 감싼 것 — `.env.example``HIGGSFIELD_API_KEY`는 미사용 placeholder).
3. **❗ 스토리지** — `server/outputs/`, `remotion/public/`(잡별 base 복사)는 로컬 디스크.
→ S3(`S3_BUCKET_OUTPUTS`, `ap-northeast-2`) 업로드 + presigned URL 반환. 잡 종료 후 로컬 정리.
4. **멀티테넌트 / RLS** — ADO2 본체 규칙(모든 쿼리 `tenant_id` 기반). 잡·결과물에 tenant context 주입.
5. **비용 가드레일** — 생성 전 `higgsfield_client.cost()`로 크레딧 선확인, 월 예산(`HF_MONTHLY_BUDGET_USD`) 차단 로직.
6. **에러 처리** — 현재 각 단계 실패를 502로 surface. 재시도/부분 실패(예: Higgsfield 성공·Remotion 실패 시 base 보존) 정책 필요.
7. **동시성** — Remotion 렌더는 CPU/메모리 집약적. 렌더 워커 풀 분리 권장.
8. **CORS** — 현재 `allow_origins=["*"]`. 운영 도메인으로 제한.
9. **에너지 자동 매칭 자동화** — 현 PoC는 프로파일을 LLM이 텍스트로 선택. `_brief/03_energy-matching.md`
결정표를 코드(`energy_matcher`)로 옮겨 결정론적 프로파일링 → Remotion 트랜지션/리듬 기본값 주입(Phase 2).
---
## 8. 비용 모델 (참고)
- 1 크레딧 ≈ $0.051(세포함) ≈ ₩76 (환율 1500 기준)
- 영상 1편 = **40cr = 약 ₩3,060** (marketing_studio_video tv_spot)
- 풀로디드(LLM 호출 포함) ≈ **₩3,160 / 편**
---
## 9. 배포 현황
- **프론트엔드(webapp)**: Vercel 정적 배포 — `https://ado2short.o2osolution.ai` (커스텀 도메인 DNS 연결 진행 중).
Vercel은 정적 호스팅이므로 **백엔드 미포함** → 데모 모드만 동작. 실 생성은 별도 백엔드 호스트 필요.
- **백엔드(server)**: 아직 배포처 없음. → §7 운영화 후 컨테이너 배포 대상.
---
## 10. 다음 액션 (백엔드 개발자 시작점)
1. 로컬 실행(§4) → `dry_run=true``/api/generate` 한 번 통과시켜 파이프라인 감 잡기.
2. §7-1(비동기 잡) + §7-2(Higgsfield 인증 이식)이 **블로커**. 이 둘부터 설계.
3. `schemas.py``remotion/src/data/types.ts` 동기화 규칙 확인 (계약 변경 시 양쪽).
4. 가드레일(§6)은 제품 신뢰의 핵심 — 프롬프트 리팩터 시 회귀 테스트 추가 권장.
문의: 크리에이티브/프롬프트 로직은 `_brief/`·`prompts/`·`concepts/` 참조.

189
README.md Normal file
View File

@ -0,0 +1,189 @@
# Higgsfield Shorts Engine
> ADO2 마케팅 자동화 파이프라인의 **어텐션 트랙(Attention Track)** 영상 생성 엔진.
> 펜션 사진 3~5장 → 8초 임팩트 숏츠/릴스 (Higgsfield API 기반).
---
## 1. Project Goal
**한 줄 목표**
> 펜션 마케팅의 신뢰 기반 콘텐츠를 깨지 않고, **의도적 비현실(Intentional Unreal)** 영상으로 SNS 피드의 Pattern Interrupt를 만들어 평균 시청유지율을 2배 이상 끌어올린다.
**왜 지금인가 (Why Now)**
- 기존 Creatomate 슬라이드쇼는 "실사 보존" 원칙 덕분에 신뢰는 높지만, Instagram/TikTok 피드에서 평균 1.5초 안에 스킵됨
- 펜션 SNS 피드의 90%는 실사 사진/영상 → 차별화 여지 없음
- Higgsfield의 카메라 무브·드림시퀀스·시네마틱 모션은 "AI가 만든 티"가 나도 오히려 **궁금증 → 스와이프 정지 → 프로필 진입** 의 깔때기를 형성
**전략적 포지셔닝**
| 트랙 | 엔진 | 역할 | 사용자 인식 |
|---|---|---|---|
| Trust Track | `engine/semantic_video/` + Creatomate | 실사 슬라이드쇼·정보 전달 | "이 펜션은 진짜 이렇게 생겼다" |
| **Attention Track** | **`engine/higgsfield_shorts/` (본 모듈)** | **8초 시네마틱·드림시퀀스** | **"뭐지? 가보고 싶다"** |
두 트랙은 **상호 보완** 관계. 어텐션 트랙으로 유입 → 트러스트 트랙으로 전환(예약).
---
## 2. Success Criteria (KPI)
### Primary (어텐션 성과)
- **평균 시청 유지율** ≥ 65% (8초 기준, 5.2초 이상 시청)
- **Hook Rate** (3초 시청률) ≥ 35% (업계 평균 15~20%)
- **저장률** ≥ 2% (Instagram Reels 기준)
### Secondary (전환 깔때기)
- 영상 → 프로필 클릭률 ≥ 4%
- 프로필 → 예약 페이지 이동 ≥ 1%
### Production (운영 효율)
- 사진 5장 입력 → 8초 영상 출력까지 **자동 파이프라인 ≤ 5분**
- 1편당 Higgsfield API 비용 ≤ $2 (목표 단가)
- 클라이언트 1곳당 월 12편 생산 가능 (주 3편 ×4주)
### Phase 1 KPI (6주, 2026-05-28 ~ 2026-07-09)
- **3개 펜션 클라이언트로 파일럿 운영**
- **편당 평균 도달 ≥ 10,000회 / 평균 시청유지율 ≥ 65% 검증**
- **컨셉 라이브러리 5종 검증 → 2종 표준화**
---
## 3. Scope
### In Scope (Phase 1)
- 펜션 정사진 3~5장 → 8초 9:16 (Reels/Shorts) 영상 생성
- Higgsfield API 연동 (Image-to-Video, Camera Control)
- 컨셉 라이브러리 (시네마틱·드림시퀀스·하이퍼리얼·트레일러·글리치 등)
- 한국어 자막/타이포그래피 모션 (Remotion 후처리)
- Creatomate Trust Track 자산과의 메타데이터 연결 (같은 펜션 = 같은 자산 ID)
### Out of Scope (Phase 1)
- 16:9 가로 영상 (Phase 2: 유튜브 시청자대상)
- 30초/60초 장형 (Phase 2: 광고 캠페인)
- 사람(인물) 영상 생성 — 초상권/딥페이크 리스크
- 음악 자동 매칭 — Phase 1은 큐레이션된 라이브러리에서 수동 선택
### Hard Constraints (절대 위반 금지)
- ❌ **펜션의 위치·구조·시설을 왜곡하지 않는다** (예: 산속 펜션을 바닷가로 합성 X)
- ❌ **존재하지 않는 어메니티를 합성하지 않는다** (수영장 없는데 수영장 X)
- ✅ **분위기·시간대·날씨·카메라 무브는 자유롭게 과장 가능** (이게 어텐션 핵심)
- ✅ **추상화·드림시퀀스·픽션화는 OK** — 단, 영상 후반/캡션에 펜션명 명시
> Why: 펜션은 신뢰 기반 마케팅이 필수. "분위기 과장"은 광고 관행 안에 있지만, "사실 왜곡"은 부정경쟁방지법·표시광고법 위반.
---
## 4. Architecture (How it plugs in)
```
[Tenant Photo Library]
┌────────────────────────────────────┐
│ ADO2 Agent Orchestrator │
│ ├─ Content Agent: 컨셉 선택 │
│ └─ Media Agent: 사진 큐레이션 │
└──────────────┬─────────────────────┘
┌──────┴──────┐
▼ ▼
┌──────────────┐ ┌──────────────────────┐
│ Trust Track │ │ Attention Track │
│ semantic_ │ │ higgsfield_shorts │
│ video + │ │ (this engine) │
│ Creatomate │ │ │
└──────┬───────┘ └──────────┬───────────┘
│ │
│ ┌─────────────────┘
▼ ▼
┌────────────────────┐
│ Distribution Agent │ → Instagram / TikTok / Naver
└────────────────────┘
```
**모듈 내부 흐름**
```
inputs/{tenant}/{shoot_id}/*.jpg
src/curation/ # 3~5장 선별 (구도·색감·서사 점수)
src/concepts/ # 컨셉 라이브러리에서 매칭
src/prompts/ # Higgsfield 프롬프트 빌더
src/rendering/ # Higgsfield API 호출 + 비동기 큐
src/post/ # Remotion 자막·로고·CTA 합성
outputs/{tenant}/{shoot_id}/{concept}.mp4
```
---
## 5. Folder Structure
```
engine/higgsfield_shorts/
├── README.md # 본 문서 (Project Charter)
├── _brief/
│ ├── 01_creative-direction.md # 크리에이티브 디렉션 (가짜OK 전략)
│ ├── 02_goals-kpis.md # 상세 KPI 및 측정 방법
│ └── 03_scope-constraints.md # 법적·브랜드 제약
├── concepts/ # 컨셉 라이브러리
│ ├── README.md
│ ├── 01_cinematic-trailer.md
│ ├── 02_dream-sequence.md
│ ├── 03_hyperreal-luxury.md
│ ├── 04_time-warp.md
│ └── 05_anime-painterly.md
├── prompts/ # Higgsfield 프롬프트 템플릿
│ └── README.md
├── inputs/ # 테스트용 펜션 사진 셋
│ └── .gitkeep
├── outputs/ # 렌더링 결과
│ └── .gitkeep
├── configs/ # Higgsfield API·렌더 파라미터
│ └── higgsfield.yaml
├── docs/
│ └── pipeline-integration.md # ADO2 파이프라인 연동 스펙
└── tests/
└── README.md
```
---
## 6. Phase 1 Roadmap (6 weeks)
| Week | 마일스톤 |
|---|---|
| W1 (5/28-6/3) | 컨셉 라이브러리 5종 작성 + Higgsfield API 계정 셋업 + 첫 사진셋 수집 |
| W2 (6/4-6/10) | 프롬프트 템플릿 v0.1 + 수동 렌더 3편 (컨셉별 PoC) |
| W3 (6/11-6/17) | 파일럿 펜션 3곳 선정 + 사진 큐레이션 SOP |
| W4 (6/18-6/24) | 9편 발행 (펜션 3 × 컨셉 3) + 측정 |
| W5 (6/25-7/1) | 결과 분석 + 컨셉 2종 표준화 |
| W6 (7/2-7/9) | 자동화 파이프라인 v0.1 (Content Agent 통합) |
---
## 7. Stakeholders
- **Creative Director / Head of Marketing Ops**: 컨셉 디렉션, KPI 책임 (= 사용자)
- **Engineering**: 엔진 구현, API 연동
- **Pilot Clients**: 펜션 3곳 (5월 KPI Success Case와 연계)
---
## 8. Open Questions
이 프로젝트가 출발하려면 다음 결정이 필요합니다 (`_brief/01_creative-direction.md` 참고):
1. **Phase 1에서 우선 검증할 컨셉 2~3종은?** (라이브러리 5종 중)
2. **사진 큐레이션 기준** — AI 자동 큐레이션 vs 사람 큐레이션 vs 하이브리드?
3. **펜션명/CTA 노출 시점** — 영상 마지막 1초 vs 캡션에만 vs 둘 다?
4. **음악 라이브러리 선택** — Epidemic Sound vs Artlist vs SUNO 자동 생성?

View File

@ -0,0 +1,98 @@
---
title: Creative Direction — Intentional Unreal
owner: Creative Director / Head of Marketing Ops
status: v0.1 draft (2026-05-28)
---
# Creative Direction: Intentional Unreal
## 1. The Core Insight
펜션 SNS 피드의 평균 콘텐츠는 다음과 같다:
- 실사 사진 슬라이드쇼
- 인테리어 클로즈업
- 노을·바베큐·반려동물 같은 클리셰
- 캡션은 "○○펜션입니다 / 예약문의 DM"
**→ 결과: 사용자 엄지손가락이 0.8초 안에 다음 영상으로 이동.**
**우리의 전략: 의도적 비현실 (Intentional Unreal)**
"진짜처럼 보이려 노력하는 AI 영상"이 아니라, "AI가 만들었다는 게 명백히 드러나지만 펜션의 본질은 살아 있는" 영상.
> 비유: 영화 *Wes Anderson*의 펜션 광고. 누가 봐도 픽션인데, 그 픽션이 공간의 매력을 더 강화한다.
## 2. Three Creative Principles
### Principle 1 — "분위기는 과장, 사실은 보존"
- ✅ 노을을 보라색으로 과장, 안개를 깔고, 비현실적 카메라 무브 OK
- ❌ 펜션 위치·구조·시설 왜곡 NO (산속 펜션을 바닷가로 X)
- ❌ 없는 어메니티 합성 NO (수영장 합성 X)
### Principle 2 — "8초 안에 3개의 시각적 펀치"
8초는 짧다. 다음 3비트가 반드시 있어야 한다:
1. **Hook (0-2초)** — 가장 비현실적/놀라운 프레임. 엄지를 멈추게 한다.
2. **Reveal (3-6초)** — 펜션의 진짜 매력 (공간감·디테일). 호기심을 만족시킨다.
3. **Brand (7-8초)** — 펜션명 + 위치 (예: "OO펜션 · 강원 양양"). 기억에 박는다.
### Principle 3 — "AI 티가 나도 좋다, 단 부끄럽지 않게"
- 캡션에 "*AI로 재해석한 ○○펜션의 시그니처 무드*" 같은 메타 카피를 적극 노출
- 사용자가 "어, 이거 AI지?"라고 알아채는 순간이 **참여(댓글·저장)** 로 전환되는 트리거
- 단, 저급한 AI 아티팩트(왜곡된 손가락·뭉개진 얼굴)는 자동 컷
## 3. Why This Works for Pension Marketing
| 통념 | 우리의 가설 |
|---|---|
| "펜션은 신뢰가 생명이라 실사여야" | 신뢰는 **트러스트 트랙**(Creatomate 실사)이 담당. 어텐션은 별도 트랙. |
| "AI 티 나면 안 좋아할 것" | AI 티 자체가 차별화. 평균 피드 90%가 실사라 오히려 **시각적 희소성** 발생. |
| "정확한 정보 전달이 우선" | 8초 숏츠는 정보가 아니라 **감정**을 전달. 정보는 프로필·예약페이지가 담당. |
## 4. Trust Track ↔ Attention Track 결합 방식
같은 펜션 = 같은 메타데이터 ID로 묶어, 다음 사용자 여정을 만든다:
```
[Reels에서 Higgsfield 어텐션 영상 시청]
↓ (호기심)
[프로필 진입]
[프로필에 Creatomate 트러스트 영상 핀 고정]
↓ (확인·안심)
[Bio 링크 → 예약 페이지]
```
→ 신뢰 손상 없이 어텐션만 흡수.
## 5. Phase 1 Concept Selection (Decision)
라이브러리 5종 중 **W1-W2에 우선 검증할 3종**을 다음과 같이 선정 (가설 기반):
| 컨셉 | 심리 트리거 | 펜션 핏 | 우선순위 |
|---|---|---|---|
| **Cinematic Trailer** | 호기심 (영화같다) | 全 펜션 범용 | ★★★ |
| **Dream Sequence** | 향수·로망 | 자연·뷰형 펜션 | ★★★ |
| **Hyperreal Luxury** | 욕망 (와 멋지다) | 풀빌라·프리미엄 | ★★ |
| Time Warp | 놀라움 | 한옥·레트로 컨셉 | ★ |
| Anime Painterly | 웃음·공유욕 | 특이/테마 펜션 | ★ |
W4 결과에 따라 W5에 2종 표준화.
## 6. Guardrails (해서는 안 되는 것)
- 인물(고객) 생성 — 초상권/딥페이크
- 경쟁사 비교 — 의료광고법 유사 리스크 (숙박은 표시광고법)
- 가격 표기 — 영상은 분위기, 가격은 캡션/링크
- 별점·후기 합성 — 거짓 표시
- 실재 인물(연예인) 닮은꼴 — 퍼블리시티권
## 7. Open Decisions
다음은 운영 시작 전 결정 필요:
1. **사진 큐레이션** — 자동(CLIP 기반 점수) vs 수동 vs 하이브리드
- 가설: W1-W2는 수동, W3부터 하이브리드, W6에 자동화 도입
2. **자막/타이포그래피 톤** — 미니멀 vs 시네마틱 vs 한글 캘리그래피
- 가설: 시네마틱(고딕 + 영문 보조) 기본, 펜션 결에 따라 조정
3. **음악 매칭** — 컨셉별 BGM 라이브러리 사전 큐레이션
- 가설: 컨셉별 5트랙씩 25트랙 큐레이션 (Epidemic Sound)

View File

@ -0,0 +1,105 @@
---
title: Energy Auto-Matching Logic
owner: Creative Director / Head of Marketing Ops
status: v0.1 (2026-05-28)
phase: PoC #2 — 문서 결정표 (Phase 2에서 src/energy_matcher 로 코드화)
---
# Energy Auto-Matching Logic (제품 IP)
브랜드 인텔리전스 → `energy_score` (0~100) → 3개 Energy Profile 중 1개 자동 선택.
각 프로파일은 **고유한 카메라 어휘·컷 리듬·트랜지션·음악**을 강제하여 영상 문법을 결정한다.
> 핵심: 영상 톤을 사람이 매번 고르지 않는다. 인텔리전스가 산출한다. 펜션이 바뀌면 점수가 바뀌고, 점수가 프로파일을, 프로파일이 영상 문법을 결정한다.
---
## 1. energy_score 산출
4개 신호의 가중 합 (각 -25 ~ +25, 합산 후 0~100 정규화).
```
raw = amenity_signal + persona_signal + selling_point_signal + photo_signal
energy_score = clamp( 50 + raw, 0, 100 )
```
### 1.1 amenity_signal (-25 ~ +25)
| 어메니티 신호 | 점수 |
|---|---|
| 수영장(특히 대형)·워터파크 | +25 |
| 파티룸·스파·자쿠지·루프탑 | +15 |
| 액티비티(글램핑·카라반·뷰포인트) | +10 |
| 일반 데크·바베큐 | 0 |
| 미니멀·독채·프라이빗 | -15 |
| 정숙·디지털디톡스·웰니스 | -25 |
### 1.2 persona_signal (-25 ~ +25)
| 1차 페르소나 | 점수 |
|---|---|
| 20대 그룹·파티·액티비티 | +25 |
| 감성사진 크리에이터(20-34) | +12 |
| 커플여행(20-30대) | 0 |
| 힐링커플(30대) | -12 |
| 번아웃 회복 1인 | -25 |
### 1.3 selling_point_signal (-25 ~ +25)
인텔리전스 `selling_points` Top3의 카테고리로 판정.
| 셀링포인트 우세 | 점수 |
|---|---|
| 스펙터클·뷰·포토스팟·액티비티 | +20 |
| 입지·접근성·숏브레이크 | +5 |
| 브랜드컨셉·야간감성 | -5 |
| 힐링·프라이버시·정숙 | -20 |
### 1.4 photo_signal (-25 ~ +25)
입력 사진셋의 시각 특성 (사람 판단 또는 CLIP 분류).
| 사진 특성 | 점수 |
|---|---|
| 주간·다채로운 색·물/풍경 와이드 | +20 |
| 액티비티·사람·움직임 | +12 |
| 중립 | 0 |
| 야간·저조도·정적 | -12 |
| 미니멀·실내·단색 톤 | -20 |
---
## 2. 3 Energy Profiles
| 항목 | **A. Still Cinema** | **B. Rhythm Reveal** | **C. Maximum Viral** |
|---|---|---|---|
| energy_score | **035** | **3670** | **71100** |
| 정체성 | 정적·다이내믹 하이브리드 | 레퍼런스 매칭 | 공격적 바이럴 |
| 카메라 어휘 | 느린 push_in·dolly 위주 **+ 액센트 1~2개**(push_crash, speed_ramp) | orbit·crane·dolly_through·fpv + push 혼합 | push_crash·dolly_zoom·fpv_drone·공격적 orbit |
| 컷 길이 | 1.5~2.5s | 0.8~1.5s | 0.4~0.9s |
| 컷 수(8s) | 4~5 | 6~9 | 8~14 |
| 트랜지션 | 매치컷·소프트 디졸브·젠틀 휩 | 휩팬·스피드램프컷·라이트플래시·매치컷 | 줌펀치·글리치·플래시·J컷 |
| 비트싱크 | 느슨(액센트만) | 중간(주요 컷) | 하드(전 컷) |
| 음악 | 앰비언트 슬로빌드 | 트렌드 업비트 | 바이럴 오디오 |
| 자막 모션 | 페이드+미세 상승 | 슬라이드+팝 | 글리치+스냅 |
| 대표 케이스 | 머뭄, 한옥스테이, 료칸형, 웰니스 | 70m풀 글램핑, 뷰맛집, 풀빌라 | 워터파크·파티펜션·대형글램핑 |
**공통 원칙**: Profile A라도 액센트 무브를 최소 1개 의무화 → 절대 단조롭지 않게. (PoC #1 실패 교훈)
---
## 3. 적용 절차 (PoC = 수작업 결정표)
1. 인텔리전스 JSON에서 amenities/personas/selling_points 추출
2. photo_signal은 사진셋 육안 판정
3. 4신호 점수 → energy_score 계산
4. 점수 구간 → Profile 확정
5. Profile의 카메라어휘·컷리듬·트랜지션을 샷리스트·Remotion data에 주입
### Phase 2 자동화 (후속)
`engine/higgsfield_shorts/src/energy_matcher` 모듈:
- input: 인텔리전스 JSON + 사진 CLIP 분류 결과
- output: `{ energy_score, profile, camera_vocabulary[], cut_rhythm, transitions[], music_brief }`
- Content Agent가 호출 → 샷리스트 자동 생성 → Higgsfield 생성 → Remotion data 주입
---
## 4. 가드레일
- 모든 프로파일 공통: 펜션 구조·위치·어메니티 사실 왜곡 금지 (분위기·카메라무브만 과장)
- 숙박 카피 민감성: 식민지 유산 어휘(적산가옥/일제 등) 금지 → 미학 어휘 (feedback_lodging_copy_sensitivity 참조)
- AI 디스클로저: 엔드카드에 "AI로 재해석…" 의무 노출

View File

@ -0,0 +1,58 @@
---
concept_id: cinematic-trailer
psychology: 호기심
priority: 1
status: v0.1
---
# 01. Cinematic Trailer
## Pitch
> "이게 펜션 영상이라고? 영화 예고편인줄."
펜션 사진 3~5장을 헐리우드 트레일러 문법으로 재구성. 슬로우 푸시인 → 미스터리한 디테일 → 와이드 리빌 → 펜션명 타이포그래피.
## Psychology
- **트리거**: 호기심 (이게 뭐지?)
- **타겟 감정 곡선**: 긴장 → 호기심 → 만족 → 기억
## Visual Language
- 색감: 시네마틱 LUT (Teal & Orange, 또는 cold blue)
- 구도: 16:9 시네마스코프 마스크를 9:16 안에 박스로 (위아래 검정 바)
- 모션: 슬로우 푸시인·크레인 다운·미세한 핸드헬드
- 타이포: 영문 serif (예: Cormorant) + 한글 명조
## 8-Second Beat Structure
| 비트 | 시간 | 사진 | 모션 | 자막 |
|---|---|---|---|---|
| Hook | 0-2s | 디테일 클로즈업 (창문·문손잡이·조명) | 슬로우 푸시인 | (무자막) |
| Build | 2-4s | 인테리어 와이드 | 크레인 다운 | "어떤 밤은 / 평생 기억에 남는다" |
| Reveal | 4-6.5s | 외관 풀샷 또는 야경 | 슬로우 풀백 | (펜션 분위기 강조 키워드 1개) |
| Brand | 6.5-8s | 시그니처 컷 + 정적 프레임 | 미세 줌인 | "○○펜션 · 강원 양양" |
## Higgsfield Parameters
```yaml
camera_move: dolly_in_slow # 1.5초당 0.3m
motion_strength: 4 # 1-10 scale, 중간 강도
style_keywords:
- "cinematic teal and orange"
- "anamorphic lens flare"
- "shallow depth of field"
- "film grain subtle"
duration_per_clip: 2.0 # 4클립 × 2초 = 8초
aspect_ratio: "9:16"
fps: 30
```
## Pension Fit
- ✅ 모든 펜션 범용 (특히 야간·인테리어 강한 곳)
- ✅ 풀빌라·독채형 펜션 최적
- ⚠️ 캠핑형·글램핑은 다른 컨셉 권장
## Hard Constraints
- 색감 과장 OK, 구조 변형 NO
- 야간 LUT 적용 시 실제 야간 사진 1장 이상 포함 필수
## A/B Test Hypothesis
- vs Dream Sequence: 호기심 트리거가 더 강함 (Hook Rate +15%p 예상)
- vs Hyperreal Luxury: 도달은 비슷, 저장률 -2%p (욕망 트리거가 저장 유발에 강함)

View File

@ -0,0 +1,63 @@
---
concept_id: dream-sequence
psychology: 향수·로망
priority: 1
status: v0.1
---
# 02. Dream Sequence
## Pitch
> "꿈에서 본 것 같은 풍경. 가본 적 없는데 그리워지는 곳."
펜션을 백일몽의 한 장면으로 변환. 안개·파스텔 톤·느린 호흡·플로팅 카메라. "기억 속 풍경"의 감각을 만들어 **저장률**을 노린다.
## Psychology
- **트리거**: 향수 + 로망 + "언젠가 가야지"
- **타겟 감정 곡선**: 몽환 → 그리움 → 결심 → 저장
## Visual Language
- 색감: 파스텔 핑크·라벤더·소프트 옐로우, 헤이즈 필터
- 구도: 비대칭·여백 많음·인물 없음
- 모션: 떠다니는(floating) 카메라, 호버링, 매우 느린 패럴랙스
- 타이포: 한글 명조 + 영문 italic, 화면 가장자리 배치
## 8-Second Beat Structure
| 비트 | 시간 | 사진 | 모션 | 자막 |
|---|---|---|---|---|
| Drift | 0-2.5s | 자연 디테일 (잎·물·구름) | 떠다니는 패럴랙스 | (없음) |
| Memory | 2.5-5s | 인테리어 한 구석 | 미세한 호흡 모션 | "기억나? / 가본 적 없는데" |
| Yearning | 5-7s | 외관 또는 풍경 와이드 | 느린 페이드 | "그리운 곳" |
| Brand | 7-8s | 정적 프레임 | (정지) | "○○펜션" |
## Higgsfield Parameters
```yaml
camera_move: floating_hover
motion_strength: 2 # 매우 부드럽게
style_keywords:
- "soft pastel haze"
- "dreamcore aesthetic"
- "lavender and peach tones"
- "ethereal lighting"
- "slight motion blur"
duration_per_clip: 2.0
aspect_ratio: "9:16"
fps: 30
post_fx:
- bloom_low
- chromatic_aberration_subtle
```
## Pension Fit
- ✅ 자연·뷰형 펜션 (산·바다·호수)
- ✅ 한옥·옛집 컨셉
- ⚠️ 도심형·모던 펜션은 Hyperreal Luxury 권장
## Hard Constraints
- 색감 변환 자유, 단 풍경 실루엣은 보존
- 안개 합성 OK, 단 실제 자연 요소(나무·산)는 변형 X
## A/B Test Hypothesis
- 저장률 1위 후보 (욕망보다 향수가 저장 행동을 더 유발)
- 시청 유지율은 Cinematic Trailer보다 -5%p 예상 (느림 → 일부 스킵)
- 댓글 "여기 어디예요?" 비율 ★★★

View File

@ -0,0 +1,64 @@
---
concept_id: hyperreal-luxury
psychology: 욕망
priority: 2
status: v0.1
---
# 03. Hyperreal Luxury
## Pitch
> "잡지에서나 보던 화보. 근데 이거 진짜 있다고?"
색감·반사·질감을 과포화시켜 럭셔리 매거진 화보 톤으로 변환. 물 위 반사, 와인잔의 결, 침구 텍스처를 클로즈업으로 강조.
## Psychology
- **트리거**: 욕망 ("나도 저기서 시간 보내고 싶다")
- **타겟 감정 곡선**: 시각적 압도 → 욕망 → 자기보상 욕구 → 예약 충동
## Visual Language
- 색감: 고채도·하이콘트라스트, 골드/딥블루/에메랄드
- 구도: 클로즈업·디테일 중심·구성적
- 모션: 정밀한 dolly, 마크로 푸시인, 빛 반사 강조
- 타이포: 영문 sans-serif (DIDOT 계열) + 한글 고딕
## 8-Second Beat Structure
| 비트 | 시간 | 사진 | 모션 | 자막 |
|---|---|---|---|---|
| Desire | 0-2s | 디테일 매크로 (텍스처·반사) | 정밀 푸시인 | "Stay where it matters" |
| Indulge | 2-4.5s | 욕조·침구·다이닝 | 슬라이드 | (무자막) |
| Escape | 4.5-7s | 인테리어 와이드 | 와이드 풀백 | "당신만을 위한" |
| Brand | 7-8s | 시그니처 컷 | 정지 | "○○풀빌라" |
## Higgsfield Parameters
```yaml
camera_move: precision_push_dolly
motion_strength: 5
style_keywords:
- "luxury magazine editorial"
- "high contrast saturated"
- "gold and emerald palette"
- "macro detail"
- "polished reflective surfaces"
duration_per_clip: 2.0
aspect_ratio: "9:16"
fps: 30
post_fx:
- clarity_high
- vibrance_high
```
## Pension Fit
- ✅ 풀빌라·프리미엄 독채
- ✅ 스파·자쿠지 보유 펜션
- ❌ 글램핑·캠핑·게스트하우스
## Hard Constraints
- 디테일 색감 과장 OK
- 없는 어메니티(수영장·자쿠지) 합성 절대 금지
- 가격대 오해를 유발하는 럭셔리 표현 시, 캡션에 실제 가격대 명시 필수
## A/B Test Hypothesis
- 객단가 높은 펜션에서 ROAS 최고
- 도달은 Cinematic Trailer 대비 -10% (정적 톤 → 알고리즘 약점)
- 그러나 **전환율(예약)** 은 ★★★ — 욕망 트리거가 행동으로 가장 빠르게 변환

60
concepts/04_time-warp.md Normal file
View File

@ -0,0 +1,60 @@
---
concept_id: time-warp
psychology: 놀라움
priority: 3
status: v0.1
---
# 04. Time Warp
## Pitch
> "낮 → 노을 → 밤 → 새벽. 8초 안에 하루를 전부."
같은 펜션의 같은 앵글을 시간대별로 변환해 빠르게 전환. 시간 변화의 시각적 충격으로 어텐션 확보.
## Psychology
- **트리거**: 놀라움 + "와 시간이 흐른다"
- **타겟 감정 곡선**: 인지부조화 → 이해 → 감탄 → 공유 욕구
## Visual Language
- 색감: 시간대별 점진 변화 (낮 백색광 → 노을 오렌지 → 야간 블루 → 새벽 보라)
- 구도: 동일 앵글 유지 (변하는 건 빛만)
- 모션: 미세한 호흡, 시간 경과 효과
- 타이포: 시계/시간 표시 모션그래픽
## 8-Second Beat Structure
| 비트 | 시간 | 사진 변환 | 자막 |
|---|---|---|---|
| Day | 0-2s | 정오 톤 | "12:00" |
| Sunset | 2-4s | 골든아워 | "18:30" |
| Night | 4-6s | 야간 블루 | "23:00" |
| Dawn | 6-8s | 새벽 미스트 | "05:45 / ○○펜션의 하루" |
## Higgsfield Parameters
```yaml
camera_move: locked_off_breathing # 카메라는 거의 정지, 빛만 변화
motion_strength: 1 # 카메라 모션 최소
style_keywords:
- "time of day variation"
- "golden hour to blue hour"
- "circadian lighting"
- "long exposure sky"
duration_per_clip: 2.0
aspect_ratio: "9:16"
fps: 30
mode: "relighting" # 같은 사진의 라이팅만 변환
```
## Pension Fit
- ✅ 풍경 좋은 자연형 펜션
- ✅ 한옥·레트로 컨셉 (시간성과 잘 어울림)
- ⚠️ 풍경 없이 인테리어만으로는 효과 약함
## Hard Constraints
- 같은 펜션 같은 앵글 — 다른 펜션 합성 절대 금지
- 시간대별 자연광 시뮬레이션은 OK, 인공조명 추가는 신중
## A/B Test Hypothesis
- 공유율 1위 후보 ("이거 봐봐" 트리거 강함)
- Hook Rate는 Cinematic Trailer 대비 -5%p (정적 톤 단점)
- 외부링크 클릭률 ★★ (호기심 → 검색 유발)

View File

@ -0,0 +1,62 @@
---
concept_id: anime-painterly
psychology: 웃음·공유욕
priority: 3
status: v0.1
---
# 05. Anime Painterly
## Pitch
> "지브리 영화 속 펜션. 토토로가 살 것 같은."
펜션 사진을 일러스트레이션/지브리 페인터리 스타일로 변환. 가장 명백한 "AI 티"를 의도적으로 노출, 친근감·공유욕을 노린다.
## Psychology
- **트리거**: 웃음 + 친근감 + 공유 욕구 ("이거 너무 귀여워")
- **타겟 감정 곡선**: 놀라움 → 미소 → 공유
## Visual Language
- 색감: 채도 높은 페인터리 톤, 손그림 텍스처
- 구도: 동화책 일러스트 구도
- 모션: 2D 페럴랙스, 종이 결 노이즈
- 타이포: 손글씨체 한글 + 영문 brushscript
## 8-Second Beat Structure
| 비트 | 시간 | 변환 | 자막 |
|---|---|---|---|
| Intro | 0-2s | 외관 페인터리 | "그림 같은 곳" |
| Detail | 2-4.5s | 인테리어 일러스트 | (작은 디테일 강조) |
| Wonder | 4.5-7s | 풍경 와이드 페인팅 | "동화 같은 하룻밤" |
| Brand | 7-8s | 펜션명 손글씨 | "○○펜션" |
## Higgsfield Parameters
```yaml
camera_move: 2d_parallax
motion_strength: 3
style_keywords:
- "studio ghibli painterly"
- "watercolor illustration"
- "hand-drawn aesthetic"
- "children's book art"
- "warm earthy palette"
duration_per_clip: 2.0
aspect_ratio: "9:16"
fps: 24 # 애니메이션 톤을 위해 24fps
mode: "style_transfer_strong"
```
## Pension Fit
- ✅ 특이/테마 펜션 (한옥·트리하우스·돔)
- ✅ 반려동물·가족 타겟 펜션
- ⚠️ 럭셔리·미니멀 펜션과는 톤 충돌
## Hard Constraints
- 스타일 변환 가장 강함 → 캡션에 "AI 일러스트 재해석" 명시 필수
- 실재 인물(투숙객) 일러스트화 금지
## A/B Test Hypothesis
- 공유율 ★★★ ("DM으로 친구한테 보내고 싶음" 트리거)
- 직접 전환율 ★ (욕망 트리거 약함)
- 그러나 브랜드 인지·바이럴 성과 ★★★
- 적합한 펜션은 30% 미만 → 선별 운영 필요

30
concepts/README.md Normal file
View File

@ -0,0 +1,30 @@
# Concept Library
8초 임팩트 영상의 **컨셉 = 심리 트리거 + 시각 언어 + Higgsfield 파라미터 프리셋**.
## 컨셉 3축 구조
모든 컨셉 파일은 다음 3축으로 정의된다:
| 축 | 정의 |
|---|---|
| **Psychology** | 시청자에게 일으킬 감정 (호기심·욕망·놀라움·향수·웃음) |
| **Visual Language** | 색감·구도·모션 스타일 |
| **Higgsfield Params** | 카메라 무브(orbit/dolly/push-in/crane), 모션 강도(1-10), 스타일 키워드 |
## Phase 1 라이브러리 (5종)
| # | 컨셉 | 심리 트리거 | 우선순위 |
|---|---|---|---|
| 01 | [Cinematic Trailer](01_cinematic-trailer.md) | 호기심 | ★★★ |
| 02 | [Dream Sequence](02_dream-sequence.md) | 향수·로망 | ★★★ |
| 03 | [Hyperreal Luxury](03_hyperreal-luxury.md) | 욕망 | ★★ |
| 04 | [Time Warp](04_time-warp.md) | 놀라움 | ★ |
| 05 | [Anime Painterly](05_anime-painterly.md) | 웃음·공유욕 | ★ |
## 사용 방법 (Content Agent 통합 시)
1. Content Agent가 펜션 사진셋 5장을 분석
2. 사진 특성(자연/도시·낮/밤·미니멀/디테일) → 컨셉 매칭 점수 계산
3. 상위 2개 컨셉으로 각 1편씩 생성 → A/B 발행
4. 7일 후 성과 데이터로 컨셉별 가중치 업데이트

41
configs/higgsfield.yaml Normal file
View File

@ -0,0 +1,41 @@
# Higgsfield API configuration for ADO2 marketing pipeline
# Env vars: HIGGSFIELD_API_KEY, HIGGSFIELD_BASE_URL
api:
base_url: "${HIGGSFIELD_BASE_URL:-https://api.higgsfield.ai/v1}"
api_key_env: "HIGGSFIELD_API_KEY"
timeout_seconds: 180
max_retries: 3
retry_backoff_seconds: [5, 15, 45]
defaults:
aspect_ratio: "9:16" # Phase 1: Reels/Shorts only
duration_seconds: 8
fps: 30
resolution: "1080x1920"
output_format: "mp4"
output_codec: "h264"
cost_guardrails:
max_cost_per_video_usd: 2.0
monthly_budget_per_tenant_usd: 80 # 월 40편 기준
alert_threshold_pct: 80
quality_gates:
# 자동 컷오프 기준 (생성 후 검수)
reject_if:
- face_artifact_score_gt: 0.6 # 얼굴 왜곡 자동 컷
- hand_artifact_score_gt: 0.7
- text_artifact_present: true # 자동 생성된 가짜 텍스트
- structural_drift_gt: 0.4 # 펜션 구조 변형 의심
concurrency:
max_parallel_renders: 3
queue_priority:
- tenant_priority_high: 1
- tenant_priority_normal: 2
observability:
log_level: "INFO"
metrics_endpoint: "${PROMETHEUS_PUSHGATEWAY_URL:-}"
trace_sample_rate: 0.1

View File

@ -0,0 +1,79 @@
# Pipeline Integration
ADO2 Agent 파이프라인에 본 엔진을 통합하는 인터페이스 스펙.
## 입력 인터페이스
```python
# agents/src/integrations/higgsfield_shorts.py 에서 호출
from engine.higgsfield_shorts import generate_short
result = await generate_short(
tenant_id="acme-pension",
shoot_id="2026-06-15_summer-shoot",
photos=[
"s3://ado2/media/acme/p1.jpg", # 3~5장
"s3://ado2/media/acme/p2.jpg",
"s3://ado2/media/acme/p3.jpg",
],
concept_id="cinematic-trailer", # concepts/ 라이브러리에서 선택
brand={
"name": "○○펜션",
"location": "강원 양양",
"cta_url": "https://booking.example.com/acme",
},
options={
"aspect_ratio": "9:16",
"duration": 8,
"caption_lang": "ko",
},
)
# result.video_url, result.thumbnail_url, result.cost_usd, result.metrics
```
## 출력 메타데이터
```json
{
"video_url": "s3://ado2/output/acme/2026-06-15/cinematic-trailer.mp4",
"thumbnail_url": "s3://ado2/output/acme/2026-06-15/cinematic-trailer.jpg",
"trust_track_link": "s3://ado2/output/acme/2026-06-15/trust-slideshow.mp4",
"concept_id": "cinematic-trailer",
"duration_seconds": 8,
"cost_usd": 1.42,
"quality_score": 0.87,
"render_time_seconds": 240,
"metadata_for_distribution": {
"suggested_caption": "...",
"suggested_hashtags": [...],
"suggested_post_time": "...",
"ai_disclosure_required": true
}
}
```
## MarketingState 통합
`agents/src/common/state.py``MarketingState`에 다음 필드 추가:
```python
class MarketingState(TypedDict):
# ... existing fields
higgsfield_renders: list[HiggsfieldRender] # 어텐션 트랙 결과들
trust_track_renders: list[CreatomateRender] # 트러스트 트랙 결과들
paired_assets: dict[str, str] # shoot_id -> trust_video_id 매핑
```
## Distribution Agent 핸드오프
Distribution Agent는 두 트랙을 **페어 배포** 한다:
1. **Reels/Shorts 피드** ← Higgsfield (어텐션)
2. **프로필 핀 게시물** ← Creatomate (트러스트)
3. **캡션 자동 생성** 시 AI 디스클로저 라인 포함 ("AI로 재해석한 ○○펜션의 무드")
## Phase 2 (자동화 도입 후)
- Content Agent가 사진셋 자동 분석 → 컨셉 자동 선택
- 1개 사진셋 → 2개 컨셉으로 A/B 자동 생성
- 7일 후 Analytics Agent가 컨셉별 성과 학습 → 컨셉 가중치 업데이트

0
inputs/.gitkeep Normal file
View File

0
outputs/.gitkeep Normal file
View File

42
package-lock.json generated Normal file
View File

@ -0,0 +1,42 @@
{
"name": "@ado2/higgsfield-shorts",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ado2/higgsfield-shorts",
"version": "0.1.0",
"devDependencies": {
"@higgsfield/cli": "^0.1.40"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@higgsfield/cli": {
"version": "0.1.40",
"resolved": "https://registry.npmjs.org/@higgsfield/cli/-/cli-0.1.40.tgz",
"integrity": "sha512-SgpShjkFZfMomvXAtLUDAp4n6oI/v7smAMJFmc4pCNfwucXYVbBQiLrYHrsXcaC2rJDhiBxhcrkBu0bStmcc7w==",
"cpu": [
"x64",
"arm64"
],
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"bin": {
"higgs": "bin/higgs.js",
"higgsfield": "bin/higgsfield.js"
},
"engines": {
"node": ">=14"
}
}
}
}

30
package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "@ado2/higgsfield-shorts",
"version": "0.1.0",
"private": true,
"description": "ADO2 어텐션 트랙 영상 엔진 — Higgsfield 기반 8초 펜션 숏츠 (Intentional Unreal 전략)",
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"hf": "higgsfield",
"hf:version": "higgsfield --version",
"hf:help": "higgsfield --help",
"gen:trailer": "higgsfield generate --concept cinematic-trailer",
"gen:dream": "higgsfield generate --concept dream-sequence",
"gen:luxury": "higgsfield generate --concept hyperreal-luxury",
"gen:timewarp": "higgsfield generate --concept time-warp",
"gen:anime": "higgsfield generate --concept anime-painterly"
},
"devDependencies": {
"@higgsfield/cli": "^0.1.40"
},
"ado2": {
"engine": "higgsfield_shorts",
"track": "attention",
"pairedTrack": "engine/semantic_video",
"phase": 1,
"phaseDeadline": "2026-07-09"
}
}

58
prompts/README.md Normal file
View File

@ -0,0 +1,58 @@
# Prompt Templates
Higgsfield Image-to-Video API에 전달할 프롬프트 빌더.
## 구조
Higgsfield 프롬프트는 다음 4부로 구성:
1. **Subject Description** — 사진 내용 (자동 생성, Claude Vision 사용)
2. **Camera Motion** — 컨셉 프리셋에서 가져옴
3. **Style Modifiers** — 컨셉의 `style_keywords` 결합
4. **Negative Prompt** — 펜션 마케팅 가드레일
## 템플릿 예시
```jinja
# Cinematic Trailer 프롬프트
[SUBJECT]: {{ photo_caption }}
[MOTION]: {{ concept.camera_move }}, motion strength {{ concept.motion_strength }}
[STYLE]: {{ concept.style_keywords | join(", ") }}
[NEGATIVE]: distorted faces, extra fingers, fake text, brand logos,
structural deformation, watermarks, low quality
```
## 가드레일 (모든 컨셉 공통 Negative Prompt)
```
distorted human faces,
extra fingers, malformed hands,
fake text or signs,
competitor brand logos,
structural deformation of building,
fake amenities (pool, jacuzzi if not in source),
celebrities or recognizable people,
star ratings, fake reviews,
watermarks, low quality, blurry
```
## 펜션 사진 캡션 생성 (Claude Vision)
각 입력 사진에 대해 Claude Vision으로 다음 메타데이터를 추출:
```yaml
scene_type: "interior" | "exterior" | "nature" | "detail"
time_of_day: "day" | "golden_hour" | "night" | "dawn"
focal_element: "bed | window | view | texture | ..."
mood_keywords: ["cozy", "spacious", "wooden", ...]
structural_features: ["wooden_beams", "floor_to_ceiling_window", ...] # 보존 필수
```
이 메타데이터를 기반으로 프롬프트 빌더가 컨셉 매칭 점수를 계산 + 프롬프트 구성.
## TBD (W2 작성 예정)
- 컨셉별 jinja 템플릿 파일 5개 (`01_cinematic-trailer.j2` 등)
- 한글 자막 자동 생성 프롬프트 (Claude API)
- 음악 매칭 메타데이터 스키마

View File

@ -0,0 +1,90 @@
---
title: Camera Vocabulary & Keyframe Templates
status: v0.1 (2026-05-28)
note: Higgsfield 시그니처 카메라 프리셋은 웹 UI 전용. CLI에선 프롬프트 시네마토그래피 동사로 유도.
---
# Camera Vocabulary & Keyframe Templates
## 1. 카메라 무브 어휘 (CLI 프롬프트 동사)
각 무브의 영문 프롬프트 조각 + 모델 안정성 + 적합 Profile.
| 무브 | 프롬프트 조각 | 안정성 | Profile |
|---|---|---|---|
| `push_in_slow` | "very slow cinematic dolly push-in, smooth and steady" | ★★★★★ | A,B,C |
| `push_crash` (크래시줌) | "sudden fast crash zoom-in toward [subject], punchy and energetic" | ★★★ | A(액센트),B,C |
| `pull_back` | "slow dolly pull-back revealing the wider space" | ★★★★ | A,B |
| `orbit_L` / `orbit_R` | "smooth camera orbit moving [left/right] around [subject], parallax depth" | ★★★★ | B,C (A는 짧게) |
| `arc` | "gentle arc movement sweeping past [subject]" | ★★★ | B |
| `crane_up` / `crane_down` | "slow cinematic crane [up/down], revealing [from/to]" | ★★★ | A(미세),B |
| `dolly_through` | "camera dollying through [doorway/window/frame], entering the space" | ★★★ | A(Hook),B |
| `rack_focus` | "shallow depth of field rack focus shifting from foreground to background" | ★★★★ | A,B |
| `tilt_reveal` | "slow vertical tilt revealing [ceiling beams / full height]" | ★★★ | A,B |
| `dolly_zoom` (버티고) | "dolly zoom vertigo effect, background warping while subject stays" | ★★ | C |
| `fpv_drone` | "fast FPV drone fly-through, dynamic immersive motion" | ★★ | B,C |
| `handheld_drift` | "subtle handheld drift, organic micro-movement" | ★★★★ | A,B |
| `speed_ramp` | "speed ramp from slow motion snapping to real time" | ★★★ | A(액센트),B,C |
**안정성 ★★★ 이하는 키프레임(start/end)과 병행** 권장 → 모델이 궤적을 잡도록.
---
## 2. 키프레임 보간 기법 (`--start-image` / `--end-image`)
`generate create`는 두 키프레임을 받아 그 사이를 보간 → 방향성 있는 카메라무브·씬 모핑.
### 패턴 A — 방향성 무브 (한 씬 내 줌/앵글 변화)
```bash
higgsfield generate create <model> \
--prompt "smooth dolly push-in from wide shot to tight close-up of the shell pendant lamp" \
--start-image "wide_kitchen.jpg" \
--end-image "closeup_shelllamp.png" # 이미지모델로 생성한 클로즈업 스틸
--aspect_ratio "9:16" --duration 5
```
→ 와이드에서 클로즈로 빨려드는 의도된 무브. 단순 push보다 훨씬 정교.
### 패턴 B — 씬 모핑 트랜지션 (컷 N → 컷 N+1)
```bash
# 컷2의 마지막 프레임 = 컷3의 첫 프레임으로 연결
--start-image "cut2_lastframe.png" --end-image "cut3_firstframe.png"
```
→ 두 공간이 매끄럽게 모핑. Remotion 컷 트랜지션과 별개로 "생성형 트랜지션".
### end-frame 생성 (이미지 모델)
목표 프레임이 원본에 없을 때 이미지 모델로 제작:
```bash
higgsfield generate create nano_banana_2 \
--prompt "extreme close-up of the glowing shell-shaped pendant lamp, same room, same light" \
--image "스테이 머뭄 02.jpeg" --wait --json
# 또는 seedream_v4_5
```
→ 반환된 이미지 id를 video 생성의 `--end-image`로 사용.
---
## 3. 프롬프트 조립 공식
```
[CAMERA MOVE] + [SUBJECT/SCENE] + [LIGHT/MOOD] + [STYLE/LUT] + [GRAIN/DOF]
```
예 (Profile A, orbit+crash 액센트):
```
"smooth camera orbit-left around a glowing sculptural shell pendant lamp under
wooden vaulted ceiling beams, then a subtle crash zoom toward the lamp,
warm amber interior light, modern Korean stay kitchen, cinematic teal and
orange grade, shallow depth of field, subtle film grain, slow cinema"
```
### Negative(프롬프트 내 자연어로 — CS 계열은 negative 파라미터 없음)
구조 왜곡·가짜 텍스트·인물·없는 어메니티 회피를 프롬프트 끝에 명시하거나, negative 지원 모델(일부)만 별도 사용.
---
## 4. Profile별 컷 카메라 배정 가이드
- **A. Still Cinema**: [push_in_slow] ×2~3 + [push_crash 또는 speed_ramp] ×1~2 + [crane_down/rack_focus] 마감
- **B. Rhythm Reveal**: [orbit/arc/dolly_through] 교차 + [fpv/crane] 히어로 + [push] 연결
- **C. Maximum Viral**: [push_crash/dolly_zoom] 연타 + [fpv_drone] 1~2 + 하드컷
> 원칙: **연속 두 컷이 같은 무브를 반복하지 않는다.** (단조로움 방지 1순위 규칙)

2907
remotion/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
remotion/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "@ado2/higgsfield-remotion",
"version": "0.1.0",
"private": true,
"description": "ADO2 Higgsfield Shorts — Remotion 자막·타이틀 오버레이 합성",
"scripts": {
"studio": "remotion studio",
"render": "remotion render MumumShort out/mumum-poc2-final.mp4",
"upgrade": "remotion upgrade"
},
"dependencies": {
"@remotion/cli": "4.0.468",
"@remotion/google-fonts": "4.0.468",
"remotion": "4.0.468",
"react": "19.2.0",
"react-dom": "19.2.0"
},
"devDependencies": {
"@types/react": "19.2.0",
"typescript": "5.7.3"
}
}

Binary file not shown.

View File

@ -0,0 +1,6 @@
import { Config } from "@remotion/cli/config";
Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true);
// H.264 + AAC, 모바일 9:16 Shorts/Reels 호환
Config.setCodec("h264");

View File

@ -0,0 +1,58 @@
import React from "react";
import { AbsoluteFill, OffthreadVideo, Sequence, staticFile } from "remotion";
import { ShortProps } from "./data/types";
import { HookTitle } from "./components/HookTitle";
import { BrandLines } from "./components/BrandLines";
import { SellingBadge } from "./components/SellingBadge";
import { EndCard } from "./components/EndCard";
// Higgsfield 완성본을 배경으로 깔고(오디오 포함), 자막/타이틀만 오버레이.
// 모든 콘텐츠는 props로 주입 (펜션/제품마다 교체).
export const MumumShort: React.FC<ShortProps> = ({
videoSrc,
hook,
sellingPoint,
brandLines,
endCard,
}) => {
return (
<AbsoluteFill style={{ backgroundColor: "black" }}>
<OffthreadVideo
src={staticFile(videoSrc)}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
{/* 상단 후킹 타이틀 */}
<Sequence from={hook.fromFrame} durationInFrames={hook.toFrame - hook.fromFrame}>
<HookTitle
eyebrow={hook.eyebrow}
title={hook.title}
fromFrame={0}
toFrame={hook.toFrame - hook.fromFrame}
/>
</Sequence>
{/* 셀링포인트: 3개 독립 박스 */}
<SellingBadge
items={sellingPoint.items}
fromFrame={sellingPoint.fromFrame}
toFrame={sellingPoint.toFrame}
/>
{/* 브랜드 감성 2줄 (상단 중앙) */}
<BrandLines
lines={brandLines.lines}
fromFrame={brandLines.fromFrame}
toFrame={brandLines.toFrame}
/>
{/* 엔드카드 + AI 디스클로저 */}
<EndCard
brand={endCard.brand}
location={endCard.location}
disclosure={endCard.disclosure}
fromFrame={endCard.fromFrame}
/>
</AbsoluteFill>
);
};

25
remotion/src/Root.tsx Normal file
View File

@ -0,0 +1,25 @@
import React from "react";
import { Composition } from "remotion";
import { MumumShort } from "./MumumShort";
import { defaultProps } from "./data/mumum";
export const RemotionRoot: React.FC = () => {
return (
<Composition
id="MumumShort"
component={MumumShort}
defaultProps={defaultProps}
durationInFrames={defaultProps.durationInFrames}
fps={defaultProps.fps}
width={defaultProps.width}
height={defaultProps.height}
// props(영상 크기·길이)로 메타데이터 동적 설정 → 한 컴포지션이 모든 job 처리
calculateMetadata={({ props }) => ({
durationInFrames: props.durationInFrames,
fps: props.fps,
width: props.width,
height: props.height,
})}
/>
);
};

View File

@ -0,0 +1,58 @@
import React from "react";
import { useCurrentFrame, interpolate, AbsoluteFill } from "remotion";
import { serifFont } from "../fonts";
// 브랜드 감성 2줄: 우상단 통일 위치에 한 덩어리로 쌓아 노출.
// 첫 줄 먼저, 둘째 줄 살짝 뒤따라 등장 → 같은 자리에서 읽힘.
export const BrandLines: React.FC<{
lines: string[];
fromFrame: number;
toFrame: number;
}> = ({ lines, fromFrame, toFrame }) => {
const frame = useCurrentFrame();
const LINE_STAGGER = 18; // 줄 간 등장 간격(frame)
return (
<AbsoluteFill
style={{
justifyContent: "flex-start",
alignItems: "center", // 가로 중앙 정렬
flexDirection: "column",
paddingTop: 230, // 상단 (~18%) 높이 유지
gap: 8,
}}
>
{lines.map((line, i) => {
const start = fromFrame + i * LINE_STAGGER;
const opacity = interpolate(
frame,
[start, start + 12, toFrame - 12, toFrame],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
const rise = interpolate(frame, [start, start + 16], [16, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<div
key={i}
style={{
opacity,
transform: `translateY(${rise}px)`,
fontFamily: serifFont,
fontWeight: 700,
fontSize: 46,
color: "#FFFFFF",
textAlign: "center",
letterSpacing: 1,
textShadow: "0 2px 16px rgba(0,0,0,0.85)",
}}
>
{line}
</div>
);
})}
</AbsoluteFill>
);
};

View File

@ -0,0 +1,91 @@
import React from "react";
import {
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
AbsoluteFill,
} from "remotion";
import { serifFont } from "../fonts";
// 엔드카드: 브랜드 + 위치 + AI 디스클로저 (가드레일 필수)
export const EndCard: React.FC<{
brand: string;
location: string;
disclosure: string;
fromFrame: number;
}> = ({ brand, location, disclosure, fromFrame }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const appear = interpolate(frame, [fromFrame, fromFrame + 12], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const brandSpring = spring({
frame: frame - fromFrame,
fps,
config: { damping: 200, mass: 0.5 },
});
const brandScale = interpolate(brandSpring, [0, 1], [0.92, 1]);
return (
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
// 하단을 살짝 어둡게 덮어 브랜드 가독성 확보
background: `rgba(0,0,0,${0.42 * appear})`,
opacity: appear,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
transform: `scale(${brandScale})`,
}}
>
<div
style={{
fontFamily: serifFont,
fontWeight: 800,
fontSize: 76,
color: "#FFFFFF",
letterSpacing: 1,
textShadow: "0 3px 20px rgba(0,0,0,0.7)",
}}
>
{brand}
</div>
<div
style={{
fontFamily: serifFont,
fontWeight: 400,
fontSize: 30,
color: "#FFE9C7",
marginTop: 18,
letterSpacing: 2,
}}
>
{location}
</div>
</div>
<div
style={{
position: "absolute",
bottom: 70,
fontFamily: serifFont,
fontWeight: 400,
fontSize: 19,
color: "rgba(255,255,255,0.72)",
textAlign: "center",
padding: "0 40px",
}}
>
{disclosure}
</div>
</AbsoluteFill>
);
};

View File

@ -0,0 +1,90 @@
import React from "react";
import {
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
AbsoluteFill,
} from "remotion";
import { hookFont } from "../fonts";
export const HookTitle: React.FC<{
eyebrow: string;
title: string;
fromFrame: number;
toFrame: number;
}> = ({ eyebrow, title, fromFrame, toFrame }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// 스프링 슬라이드-다운 등장
const enter = spring({
frame: frame - fromFrame,
fps,
config: { damping: 200, mass: 0.6 },
});
const translateY = interpolate(enter, [0, 1], [-60, 0]);
// 퇴장 페이드
const opacity = interpolate(
frame,
[fromFrame, fromFrame + 8, toFrame - 12, toFrame],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
return (
<AbsoluteFill style={{ opacity }}>
{/* 상단 가독성 그라데이션 */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 580,
background:
"linear-gradient(180deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 100%)",
}}
/>
<div
style={{
position: "absolute",
top: 280,
left: 0,
right: 0,
transform: `translateY(${translateY}px)`,
display: "flex",
flexDirection: "column",
alignItems: "center",
padding: "0 48px",
textAlign: "center",
}}
>
<div
style={{
fontFamily: hookFont,
fontSize: 34,
color: "#FFE9C7",
letterSpacing: 2,
marginBottom: 14,
textShadow: "0 2px 12px rgba(0,0,0,0.7)",
}}
>
{eyebrow}
</div>
<div
style={{
fontFamily: hookFont,
fontSize: 62,
lineHeight: 1.18,
color: "#FFFFFF",
textShadow: "0 3px 18px rgba(0,0,0,0.85)",
WebkitTextStroke: "1.5px rgba(0,0,0,0.35)",
}}
>
{title}
</div>
</div>
</AbsoluteFill>
);
};

View File

@ -0,0 +1,69 @@
import React from "react";
import {
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
AbsoluteFill,
} from "remotion";
import { serifFont } from "../fonts";
// 셀링포인트: 3개 독립 pill 박스를 세로로 쌓고 순차 등장.
// 컬러 지양 → 반투명 다크 pill + 흰색 텍스트. 앞 구간 단독 노출(시선 집중).
export const SellingBadge: React.FC<{
items: string[];
fromFrame: number;
toFrame: number;
}> = ({ items, fromFrame, toFrame }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const STAGGER = 9; // 박스 간 등장 간격(frame)
return (
<AbsoluteFill
style={{
justifyContent: "flex-start",
alignItems: "center",
flexDirection: "column",
paddingTop: 200, // 상단 배치 (~16%) — 이미지 가림 최소화
gap: 18,
}}
>
{items.map((item, i) => {
const start = fromFrame + i * STAGGER;
const enter = spring({
frame: frame - start,
fps,
config: { damping: 200, mass: 0.5 },
});
const rise = interpolate(enter, [0, 1], [22, 0]);
const appear = interpolate(
frame,
[start, start + 8, toFrame - 10, toFrame],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
return (
<div
key={i}
style={{
opacity: appear,
transform: `translateY(${rise}px)`,
fontFamily: serifFont,
fontWeight: 700,
fontSize: 38,
color: "#FFFFFF",
letterSpacing: 0.5,
whiteSpace: "nowrap",
// 배경 제거 → 블러리 소프트 쉐도우로 가독성 확보
textShadow:
"0 2px 22px rgba(0,0,0,0.95), 0 0 14px rgba(0,0,0,0.85), 0 0 40px rgba(0,0,0,0.6)",
}}
>
{item}
</div>
);
})}
</AbsoluteFill>
);
};

View File

@ -0,0 +1,53 @@
import React from "react";
import {
useCurrentFrame,
interpolate,
AbsoluteFill,
} from "remotion";
import { serifFont } from "../fonts";
// 브랜드 자막: 페이드 + 미세 상승 (Profile A 모션 프리셋)
export const Subtitle: React.FC<{
text: string;
fromFrame: number;
toFrame: number;
}> = ({ text, fromFrame, toFrame }) => {
const frame = useCurrentFrame();
const opacity = interpolate(
frame,
[fromFrame, fromFrame + 10, toFrame - 10, toFrame],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
const rise = interpolate(frame, [fromFrame, fromFrame + 18], [18, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
justifyContent: "flex-end",
alignItems: "center",
paddingBottom: 220,
opacity,
}}
>
<div
style={{
fontFamily: serifFont,
fontWeight: 700,
fontSize: 50,
color: "#FFFFFF",
textAlign: "center",
letterSpacing: 1,
transform: `translateY(${rise}px)`,
textShadow: "0 2px 16px rgba(0,0,0,0.8)",
}}
>
{text}
</div>
</AbsoluteFill>
);
};

View File

@ -0,0 +1,35 @@
// Default props = the verified 머뭄 PoC #2 layout.
// The Python render bridge overrides these per-job via --props.
import { ShortProps } from "./types";
export const defaultProps: ShortProps = {
videoSrc: "mumum_marketing_v1.mp4",
fps: 30,
width: 720,
height: 1280,
durationInFrames: 241, // ≈ 8.04s
hook: {
eyebrow: "아무도 모르는 군산",
title: "골목 안, 단 한 채의 독채 스테이 머뭄",
fromFrame: 0,
toFrame: 78,
},
sellingPoint: {
items: ["프라이빗 독채펜션", "넓은 정원", "핵심 관광지 가까이"],
fromFrame: 80,
toFrame: 138,
},
brandLines: {
lines: ["머무는 시간이", "다르게 흐르는 곳"],
fromFrame: 138,
toFrame: 196,
},
endCard: {
brand: "Stay, 머뭄",
location: "전북 군산시 절골길 18",
disclosure: "실제 사진 기반, AI 카메라 효과를 적용한 영상입니다.",
fromFrame: 192,
toFrame: 241,
},
};

View File

@ -0,0 +1,19 @@
// Props contract — shared by Remotion composition and the Python render bridge.
// Mirrors server/app/schemas.py VideoSpec (+ video config + timings).
export type ShortProps = {
videoSrc: string; // file in public/ (staticFile name)
fps: number;
width: number;
height: number;
durationInFrames: number;
hook: { eyebrow: string; title: string; fromFrame: number; toFrame: number };
sellingPoint: { items: string[]; fromFrame: number; toFrame: number };
brandLines: { lines: string[]; fromFrame: number; toFrame: number };
endCard: {
brand: string;
location: string;
disclosure: string;
fromFrame: number;
toFrame: number;
};
};

15
remotion/src/fonts.ts Normal file
View File

@ -0,0 +1,15 @@
// 한글 폰트 로딩 (@remotion/google-fonts, korean subset)
// Hook = Black Han Sans (굵고 강렬한 바이럴 헤드라인)
// Body/Brand = Nanum Myeongjo (우아한 명조 — 머뭄의 정적 브랜드 톤)
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"],
}).fontFamily;
export const serifFont = loadSerif("normal", {
weights: ["400", "700", "800"],
subsets: ["korean", "latin"],
}).fontFamily;

4
remotion/src/index.ts Normal file
View File

@ -0,0 +1,4 @@
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);

15
remotion/tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
},
"include": ["src"]
}

6
server/.env.example Normal file
View File

@ -0,0 +1,6 @@
# ADO2 Higgsfield Shorts server
# Claude API (spec_builder)
ANTHROPIC_API_KEY=
# Higgsfield CLI must be authenticated separately: `higgsfield auth login`
# (the server shells out to the CLI; no key needed here)

0
server/app/__init__.py Normal file
View File

115
server/app/main.py Normal file
View File

@ -0,0 +1,115 @@
"""④ FastAPI orchestration — LLM → Higgsfield → Remotion wrapper.
POST /api/generate (multipart: photos[] + interview fields)
build_spec (Claude) higgsfield.generate remotion.render mp4
GET /api/health
Static: /outputs/<file> (final videos), / (the web app)
"""
from __future__ import annotations
import shutil
import uuid
from pathlib import Path
from fastapi import FastAPI, File, Form, UploadFile, HTTPException
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
ENGINE = Path(__file__).resolve().parents[2] # engine/higgsfield_shorts
WEBAPP = ENGINE / "webapp"
OUTPUTS = ENGINE / "server" / "outputs"
UPLOADS = ENGINE / "server" / ".uploads"
OUTPUTS.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="ADO2 Higgsfield Shorts")
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
)
@app.get("/api/health")
def health():
return {"ok": True}
@app.post("/api/captions", response_model=ScriptResult)
def captions(req: GenerateRequest):
"""인터뷰 답변 → 인트로·셀링포인트·감성스토리·CTA 4블록 (Claude)."""
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(
photos: list[UploadFile] = File(...),
kind: str = Form(...),
biz_name: str = Form(...),
selling: str = Form(...),
addr: str | None = Form(None),
price: str | None = Form(None),
dry_run: bool = Form(False),
):
if len(photos) < 4:
raise HTTPException(400, "사진 4장 이상이 필요합니다.")
# 1. Persist uploaded photos
job = uuid.uuid4().hex[:8]
job_dir = UPLOADS / job
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}"
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
# 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,
)
# Static mounts (after API routes)
app.mount("/outputs", StaticFiles(directory=str(OUTPUTS)), name="outputs")
@app.get("/")
def index():
return FileResponse(str(WEBAPP / "index.html"))
app.mount("/", StaticFiles(directory=str(WEBAPP)), name="webapp")

View File

View File

@ -0,0 +1,70 @@
"""② 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.
"""
from __future__ import annotations
import json
import re
import subprocess
import urllib.request
import uuid
from pathlib import Path
ENGINE = Path(__file__).resolve().parents[3] # engine/higgsfield_shorts
DEMO_VIDEO = ENGINE / "webapp" / "demo" / "mumum.mp4"
TMP = ENGINE / "server" / ".tmp"
MODEL = "marketing_studio_video"
_URL_RE = re.compile(r'"result_url"\s*:\s*"([^"]+)"')
_CREDITS_RE = re.compile(r'"credits(?:_exact)?"\s*:\s*([0-9.]+)')
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"]
return args
def cost(prompt: str, image_paths: list[Path], duration: int = 8) -> float:
out = subprocess.run(
["higgsfield", "generate", "cost", MODEL, *_base_args(prompt, image_paths, duration), "--json"],
capture_output=True, text=True, check=True,
)
m = _CREDITS_RE.search(out.stdout)
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",
) -> 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),
"--wait", "--wait-timeout", wait_timeout, "--json"],
capture_output=True, text=True, check=True,
)
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
TMP.mkdir(parents=True, exist_ok=True)
dest = TMP / f"base_{uuid.uuid4().hex[:8]}.mp4"
urllib.request.urlretrieve(url, dest)
return dest, credits

View File

@ -0,0 +1,90 @@
"""③ Remotion step — VideoSpec + base video → final mp4 with overlays.
Generalized: builds a props JSON (video config + default timings + spec content)
and runs `npx remotion render MumumShort <out> --props=<file>`. The base video is
copied into remotion/public/ so OffthreadVideo's staticFile() can resolve it.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import uuid
from pathlib import Path
from app.schemas import VideoSpec
ENGINE = Path(__file__).resolve().parents[3] # engine/higgsfield_shorts
REMOTION = ENGINE / "remotion"
PUBLIC = REMOTION / "public"
OUT_DIR = REMOTION / "out"
FPS = 30
def _probe_frames(video_path: Path, fps: int = FPS) -> tuple[int, int, int]:
"""Return (durationInFrames, width, height) via ffprobe."""
out = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height,duration",
"-of", "json", str(video_path)],
capture_output=True, text=True, check=True,
)
info = json.loads(out.stdout)["streams"][0]
width = int(info["width"])
height = int(info["height"])
seconds = float(info.get("duration", 8.0))
return max(1, round(seconds * fps)), width, height
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))
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},
}
def build_props(spec: VideoSpec, base_video: Path) -> tuple[dict, str]:
"""Copy base video into public/ and assemble the Remotion props dict."""
PUBLIC.mkdir(parents=True, exist_ok=True)
job = f"job_{uuid.uuid4().hex[:8]}.mp4"
shutil.copy(base_video, PUBLIC / job)
frames, width, height = _probe_frames(PUBLIC / job)
t = _timings(frames)
props = {
"videoSrc": job,
"fps": FPS, "width": width, "height": height, "durationInFrames": frames,
"hook": {"eyebrow": spec.hook.eyebrow, "title": spec.hook.title, **t["hook"]},
"sellingPoint": {"items": spec.selling_point.items, **t["sellingPoint"]},
"brandLines": {"lines": spec.brand_lines, **t["brandLines"]},
"endCard": {
"brand": spec.end_card.brand,
"location": spec.end_card.location,
"disclosure": spec.end_card.disclosure,
**t["endCard"],
},
}
return props, job
def render(spec: VideoSpec, base_video: Path) -> Path:
OUT_DIR.mkdir(parents=True, exist_ok=True)
props, _ = 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),
f"--props={props_file}"],
cwd=str(REMOTION), check=True,
)
return out_file

View File

@ -0,0 +1,171 @@
"""① LLM step — interview answers → VideoSpec (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.
"""
from __future__ import annotations
import json
import anthropic
from app.schemas import GenerateRequest, VideoSpec, ScriptResult
MODEL = "claude-opus-4-7"
# Stable, cacheable guardrail + role prompt.
SYSTEM_PROMPT = """\
당신은 한국 로컬 비즈니스(숙소·매장·제품) 8 세로형 숏폼 광고 카피 디렉터입니다.
사장/마케터가 직접 답한 정보만으로, 영상 사양(VideoSpec) 설계합니다. 복잡한 분석은 하지 않습니다.
[절대 가드레일]
1. 식민지 유산·역사정치 민감 용어 금지: "적산가옥", "일제", "식민지", "근대사" 미학·경험 어휘로 대체.
2. 거짓·과장 금지: 사용자가 주지 않은 거리(: "서울 40분")·효능·수치를 지어내지 않는다. 주어진 정보만 사용.
3. 자막은 흰색 모노크롬 전제 카피에 지시어를 넣지 않는다.
4. AI 디스클로저는 end_card.disclosure에 고정 문구로 포함.
[hook 작성 레퍼런스 바이럴 공식 "[반전/호기심] + [한방]"]
- eyebrow: 짧은 도발/맥락 (: "아무도 모르는 군산").
- title: 굵은 한방 헤드라인. 사용자의 셀링포인트를 가장 강하게 압축.
[selling_point.items] 정확히 3. 사용자 한방 셀링포인트 + 유형 기반의 구체 명사구(짧게). 이모지 금지.
[brand_lines] 감성 2 ( 짧게). 덩어리로 읽히게.
[end_card] brand=업체/상품명, location=주소나 URL(없으면 문자열 ""), disclosure 고정.
[caption] 업로드용. 정보(업체/주소/가격) + 짧은 감성 + 해시태그 5~8. 유형에 맞는 해시태그.
[profile 선택]
- place + 정적/프라이빗/힐링 "Still Cinema"
- 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" 포함.
"""
# Structured-output JSON schema (additionalProperties:false everywhere).
_SPEC_SCHEMA = {
"type": "object",
"additionalProperties": False,
"properties": {
"profile": {"type": "string", "enum": ["Still Cinema", "Rhythm Reveal", "Maximum Viral"]},
"higgsfield_prompt": {"type": "string"},
"hook": {
"type": "object",
"additionalProperties": False,
"properties": {"eyebrow": {"type": "string"}, "title": {"type": "string"}},
"required": ["eyebrow", "title"],
},
"selling_point": {
"type": "object",
"additionalProperties": False,
"properties": {"items": {"type": "array", "items": {"type": "string"}}},
"required": ["items"],
},
"brand_lines": {"type": "array", "items": {"type": "string"}},
"end_card": {
"type": "object",
"additionalProperties": False,
"properties": {
"brand": {"type": "string"},
"location": {"type": "string"},
"disclosure": {"type": "string"},
},
"required": ["brand", "location", "disclosure"],
},
"caption": {"type": "string"},
},
"required": [
"profile", "higgsfield_prompt", "hook", "selling_point",
"brand_lines", "end_card", "caption",
],
}
def build_spec(req: GenerateRequest, client: anthropic.Anthropic | None = None) -> VideoSpec:
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"
"위 정보로 VideoSpec을 설계해줘."
)
resp = client.messages.create(
model=MODEL,
max_tokens=2048,
thinking={"type": "adaptive"},
output_config={
"effort": "medium",
"format": {"type": "json_schema", "name": "video_spec", "schema": _SPEC_SCHEMA},
},
system=[{
"type": "text",
"text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"},
}],
messages=[{"role": "user", "content": user_msg}],
)
text = "".join(b.text for b in resp.content if b.type == "text")
return VideoSpec.model_validate(json.loads(text))
# ---------- 자막 스크립트 4블록 ----------
SCRIPT_SYSTEM = """\
당신은 한국 로컬 비즈니스 숏폼 광고의 자막 스크립트 작가입니다.
사장/마케터가 답한 정보로, 영상에 얹을 4 블록을 만듭니다.
[4블록]
- intro: 1~2 후킹. 호기심·반전을 거는 짧은 .
- selling: 가장 강력한 셀링포인트를 문장으로 압축.
- story: 감성 스토리. 고객이 공간/제품에서 느낄 감정을 1~2문장으로.
- cta: 행동 유도. 예약·구매·방문으로 자연스럽게.
[가드레일]
- 거짓·과장 금지(주어지지 않은 거리·효능·수치 X). 주어진 정보만.
- 식민지 유산 어휘(적산가옥/일제/식민지 ) 금지 미학·경험 어휘로.
- 자막은 흰색 모노크롬 전제 지시어 넣지 않음. 이모지 남발 금지.
- 블록은 짧고 입에 붙게. 한국어.
"""
_SCRIPT_SCHEMA = {
"type": "object",
"additionalProperties": False,
"properties": {
"intro": {"type": "string"},
"selling": {"type": "string"},
"story": {"type": "string"},
"cta": {"type": "string"},
},
"required": ["intro", "selling", "story", "cta"],
}
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블록 자막 스크립트를 만들어줘."
)
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))

64
server/app/schemas.py Normal file
View File

@ -0,0 +1,64 @@
"""Pydantic schemas for the Higgsfield Shorts wrapper.
VideoSpec mirrors the Remotion data contract (remotion/src/data/mumum.ts) 1:1.
The LLM (spec_builder) fills VideoSpec; Higgsfield consumes higgsfield_prompt;
Remotion consumes the rest (hook / selling_point / brand_lines / end_card).
"""
from __future__ import annotations
from typing import Literal, Optional
from pydantic import BaseModel, Field
# ---------- Inbound: interview answers (no complex analysis) ----------
class GenerateRequest(BaseModel):
kind: Literal["place", "product", "message"]
biz_name: str = Field(..., description="업체명·상품명·메시지 제목")
addr: Optional[str] = Field(None, description="주소 또는 판매 사이트 URL")
price: Optional[str] = Field(None, description="가격 정보")
selling: str = Field(..., description="주인/마케터가 생각하는 강력한 한방 셀링포인트")
# ---------- VideoSpec sub-objects (mirror mumum.ts) ----------
class Hook(BaseModel):
eyebrow: str
title: str
class SellingPoint(BaseModel):
items: list[str] = Field(..., description="3개 독립 배지 카피")
class EndCard(BaseModel):
brand: str
location: str
disclosure: str = "실제 사진 기반, AI 카메라 효과를 적용한 영상입니다."
class VideoSpec(BaseModel):
# 에너지 프로파일 → Remotion 리듬/트랜지션 기본값 결정
profile: Literal["Still Cinema", "Rhythm Reveal", "Maximum Viral"]
# Higgsfield marketing_studio_video 프롬프트 (유형별 톤)
higgsfield_prompt: str
hook: Hook
selling_point: SellingPoint
brand_lines: list[str] = Field(..., description="감성 카피 2줄")
end_card: EndCard
caption: str = Field(..., description="업로드용 캡션+해시태그")
# ---------- Outbound: 자막 스크립트 4블록 ----------
class ScriptResult(BaseModel):
intro: str = Field(..., description="인트로 (후킹)")
selling: str = Field(..., description="셀링포인트")
story: str = Field(..., description="감성 스토리")
cta: str = Field(..., description="CTA")
# ---------- Outbound: final result ----------
class GenerateResult(BaseModel):
video_url: str
caption: str
profile: str
cost_credits: float = 0.0
job_id: Optional[str] = None

15
server/pyproject.toml Normal file
View File

@ -0,0 +1,15 @@
[project]
name = "ado2-higgsfield-server"
version = "0.1.0"
description = "ADO2 Higgsfield Shorts wrapper — LLM + Higgsfield + Remotion orchestration"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"python-multipart>=0.0.9",
"anthropic>=0.69",
"pydantic>=2.7",
]
[tool.uvicorn]
# run: uv run uvicorn app.main:app --reload --port 8000

1
webapp/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.vercel

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
webapp/demo/mumum.mp4 Normal file

Binary file not shown.

589
webapp/index.html Normal file
View File

@ -0,0 +1,589 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<title>ADO2 Hookit — 8초 숏폼 생성기</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
/* ===== ADO2 Design Tokens (from tokens.css) ===== */
@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css");
:root{
--ado-purple: rgb(174,114,249);
--ado-purple-light: rgb(207,171,251);
--ado-mint: rgb(148,251,224);
--bg-0: rgb(1,25,26);
--bg-1: rgb(0,34,36);
--bg-2: rgb(25,38,40);
--bg-3: rgb(1,57,59);
--stroke-1: rgb(50,75,80);
--stroke-2: rgb(37,57,60);
--text-white: rgb(255,255,255);
--text-teal-1: rgb(155,202,204);
--text-teal-2: rgb(106,176,179);
--text-teal-3: rgb(101,126,131);
--text-mute-1: rgb(178,191,193);
--text-mute-2: rgb(217,223,224);
--r-md:12px; --r-lg:20px; --r-pill:999px;
--font:"Pretendard","Apple SD Gothic Neo",-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
--shadow-card:0 1px 0 rgba(0,0,0,0.4),0 12px 32px -8px rgba(0,0,0,0.45);
}
*{box-sizing:border-box;}
html,body{margin:0;background:var(--bg-1);color:var(--text-white);font-family:var(--font);-webkit-font-smoothing:antialiased;letter-spacing:-0.006em;word-break:keep-all;overflow-wrap:anywhere;}
/* ===== Layout: centered, no cluttered sidebar ===== */
.topbar{
position:sticky;top:0;z-index:10;
height:72px;display:flex;align-items:center;justify-content:space-between;
padding:0 32px;background:rgba(0,34,36,0.82);backdrop-filter:blur(12px);
border-bottom:1px solid var(--stroke-2);
}
.wordmark{font:800 22px/1 var(--font);letter-spacing:-0.02em;}
.wordmark em{font-style:normal;background:linear-gradient(135deg,var(--ado-mint),var(--ado-purple-light));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;}
.step-count{font:700 15px/1 var(--font);color:var(--text-teal-2);}
.step-count b{color:var(--text-white);}
.wrap{max-width:1000px;margin:0 auto;padding:48px 32px 120px;}
/* ===== Hero ===== */
.hero{margin-bottom:48px;}
.eyebrow{display:inline-flex;align-items:center;gap:10px;font:700 14px/1 var(--font);letter-spacing:0.14em;text-transform:uppercase;color:var(--ado-mint);margin-bottom:24px;}
.eyebrow::before{content:"";width:9px;height:9px;border-radius:999px;background:var(--ado-mint);box-shadow:0 0 12px var(--ado-mint);}
h1.hero-title{font:800 clamp(40px,6vw,68px)/1.05 var(--font);letter-spacing:-0.035em;margin:0;}
h1.hero-title em{font-style:normal;background:linear-gradient(135deg,var(--ado-mint),var(--ado-purple-light));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;}
.hero-lede{font:500 20px/1.6 var(--font);color:var(--text-teal-1);margin:28px 0 0;max-width:680px;}
/* ===== Stepper (4) ===== */
.stepper{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:48px;}
.step{display:flex;align-items:center;gap:16px;padding:20px 22px;background:var(--bg-2);border:1px solid var(--stroke-1);border-radius:16px;transition:.15s;}
.step.active{border-color:var(--ado-purple);background:linear-gradient(180deg,rgba(174,114,249,0.10),rgba(174,114,249,0.02));}
.step .num{width:42px;height:42px;flex:0 0 42px;border-radius:999px;display:flex;align-items:center;justify-content:center;font:800 18px/1 var(--font);background:rgba(255,255,255,0.06);color:var(--text-teal-1);}
.step.active .num{background:var(--ado-purple);color:#fff;box-shadow:0 0 0 6px rgba(174,114,249,0.18);}
.step.done .num{background:var(--ado-mint);color:var(--bg-1);}
.step b{display:block;font:700 17px/1.2 var(--font);color:var(--text-mute-2);}
.step.active b{color:#fff;}
.step span{display:block;font:500 14px/1.2 var(--font);color:var(--text-teal-3);margin-top:5px;}
@media(max-width:760px){.stepper{grid-template-columns:repeat(2,1fr);} h1.hero-title{font-size:38px;}}
/* ===== Card ===== */
.card{background:var(--bg-2);border:1px solid var(--stroke-1);border-radius:24px;padding:44px;box-shadow:var(--shadow-card);}
.card h2{font:800 32px/1.15 var(--font);margin:0 0 12px;letter-spacing:-0.018em;}
.card .sub{font:500 17px/1.55 var(--font);color:var(--text-teal-1);margin:0 0 36px;max-width:620px;}
/* ===== Upload ===== */
.upload{height:260px;border:2px dashed var(--stroke-1);border-radius:20px;background:var(--bg-1);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;cursor:pointer;transition:.15s;}
.upload:hover,.upload.drag{border-color:var(--ado-mint);background:rgba(148,251,224,0.04);}
.upload .ico{width:64px;height:64px;border-radius:999px;background:rgba(148,251,224,0.12);display:flex;align-items:center;justify-content:center;color:var(--ado-mint);}
.upload b{font:700 20px/1.2 var(--font);}
.upload span{font:500 15px/1.4 var(--font);color:var(--text-teal-1);}
.thumbs{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-top:24px;}
@media(max-width:640px){.thumbs{grid-template-columns:repeat(2,1fr);}}
.thumb{position:relative;aspect-ratio:1;border-radius:14px;overflow:hidden;border:1px solid var(--stroke-1);background:var(--bg-3);}
.thumb img{width:100%;height:100%;object-fit:cover;display:block;}
.thumb .n{position:absolute;top:8px;left:8px;height:24px;padding:0 10px;border-radius:999px;background:rgba(0,0,0,0.6);backdrop-filter:blur(8px);font:700 13px/24px var(--font);}
.thumb .x{position:absolute;top:8px;right:8px;width:26px;height:26px;border-radius:999px;background:rgba(0,0,0,0.6);backdrop-filter:blur(8px);border:none;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;}
/* ===== Fields ===== */
.fields{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:28px;}
@media(max-width:640px){.fields{grid-template-columns:1fr;}}
.field label{display:block;font:700 15px/1.3 var(--font);letter-spacing:0.04em;color:var(--text-mute-2);margin-bottom:12px;}
.field input{width:100%;height:58px;padding:0 20px;background:var(--bg-1);border:1px solid var(--stroke-1);border-radius:12px;color:#fff;font:500 16px/1 var(--font);outline:none;}
.field input:focus{border-color:var(--ado-mint);box-shadow:0 0 0 3px rgba(148,251,224,0.14);}
.field textarea{width:100%;min-height:120px;padding:16px 18px;background:var(--bg-1);border:1px solid var(--stroke-1);border-radius:12px;color:#fff;font:500 16px/1.6 var(--font);outline:none;resize:vertical;}
.field textarea:focus{border-color:var(--ado-mint);box-shadow:0 0 0 3px rgba(148,251,224,0.14);}
.field .hint{font:500 14px/1.5 var(--font);color:var(--text-teal-3);margin-top:10px;}
.form-stack{display:flex;flex-direction:column;gap:24px;}
.kind-tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;}
@media(max-width:640px){.kind-tiles{grid-template-columns:1fr;}}
.tile{border:1.5px solid var(--stroke-1);background:var(--bg-1);border-radius:16px;padding:24px;cursor:pointer;transition:.15s;}
.tile:hover{border-color:rgba(148,251,224,0.4);}
.tile.sel{border-color:var(--ado-mint);background:rgba(148,251,224,0.05);}
.tile b{font:800 20px/1.2 var(--font);display:block;}
.tile span{font:500 14px/1.4 var(--font);color:var(--text-teal-1);margin-top:8px;display:block;}
.req{color:var(--ado-mint);}
.opt{color:var(--text-teal-3);font-weight:500;margin-left:6px;text-transform:none;letter-spacing:0;}
/* ===== Action bar ===== */
.actionbar{margin-top:28px;display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--bg-2);border:1px solid var(--stroke-1);border-radius:20px;padding:22px 28px;}
.actionbar .stat b{font:800 22px/1 var(--font);}
.actionbar .stat span{display:block;font:600 13px/1 var(--font);color:var(--text-teal-3);letter-spacing:0.06em;text-transform:uppercase;margin-top:6px;}
.btn{height:58px;padding:0 32px;border-radius:999px;border:none;cursor:pointer;font:700 17px/1 var(--font);display:inline-flex;align-items:center;gap:10px;transition:.15s;}
.btn-primary{background:linear-gradient(135deg,var(--ado-mint),var(--ado-purple));color:var(--bg-0);}
.btn-primary:disabled{opacity:0.4;cursor:not-allowed;}
.btn-ghost{background:transparent;border:1.5px solid var(--stroke-1);color:var(--text-mute-2);}
.btn-ghost:hover{border-color:var(--ado-mint);color:#fff;}
/* ===== Analysis ===== */
.profile-banner{display:flex;align-items:center;gap:24px;padding:28px;border-radius:18px;background:linear-gradient(135deg,rgba(174,114,249,0.16),rgba(148,251,224,0.06));border:1px solid rgba(174,114,249,0.3);margin-bottom:32px;}
.profile-banner .score{width:88px;height:88px;flex:0 0 88px;border-radius:999px;border:3px solid var(--ado-mint);display:flex;flex-direction:column;align-items:center;justify-content:center;}
.profile-banner .score b{font:800 30px/1 var(--font);}
.profile-banner .score span{font:600 11px/1 var(--font);color:var(--text-teal-2);margin-top:4px;letter-spacing:0.08em;}
.profile-banner .pinfo b{font:800 24px/1.2 var(--font);display:block;}
.profile-banner .pinfo p{font:500 16px/1.5 var(--font);color:var(--text-teal-1);margin:8px 0 0;}
.sp-row{display:flex;align-items:center;gap:18px;padding:16px 0;border-top:1px solid var(--stroke-2);}
.sp-row:first-child{border-top:none;}
.sp-row .sp-name{flex:0 0 200px;font:700 17px/1.3 var(--font);}
.sp-row .bar{flex:1;height:8px;border-radius:999px;background:rgba(255,255,255,0.06);overflow:hidden;}
.sp-row .bar i{display:block;height:100%;border-radius:999px;background:linear-gradient(90deg,var(--ado-mint),var(--ado-purple));}
.sp-row .sp-score{flex:0 0 48px;text-align:right;font:800 18px/1 var(--font);font-variant-numeric:tabular-nums;}
.chips{display:flex;flex-wrap:wrap;gap:10px;margin-top:24px;}
.chip{height:38px;padding:0 18px;border-radius:999px;display:inline-flex;align-items:center;font:600 15px/1 var(--font);background:rgba(148,251,224,0.10);border:1px solid rgba(148,251,224,0.3);color:var(--ado-mint);}
/* ===== Generating ===== */
.agent{display:grid;grid-template-columns:1fr 220px;gap:28px;align-items:center;padding:26px 0;border-top:1px solid var(--stroke-2);}
.agent:first-child{border-top:none;}
.agent .aname b{font:700 19px/1.2 var(--font);display:block;}
.agent .aname span{font:500 15px/1.3 var(--font);color:var(--text-teal-1);margin-top:6px;display:block;}
.agent .pbar{height:10px;border-radius:999px;background:rgba(255,255,255,0.06);overflow:hidden;}
.agent .pbar i{display:block;height:100%;border-radius:999px;background:linear-gradient(90deg,var(--ado-mint),var(--ado-purple));transition:width .5s cubic-bezier(.4,0,.2,1);}
.agent .pct{font:800 16px/1 var(--font);color:var(--text-teal-2);text-align:right;margin-top:8px;font-variant-numeric:tabular-nums;}
.dot{width:9px;height:9px;border-radius:999px;display:inline-block;margin-right:10px;}
.dot.run{background:var(--ado-purple);box-shadow:0 0 0 4px rgba(174,114,249,0.18);}
.dot.done{background:var(--ado-mint);}
.dot.queue{background:rgba(255,255,255,0.2);}
/* ===== Result ===== */
.result-grid{display:grid;grid-template-columns:320px 1fr;gap:48px;align-items:start;}
@media(max-width:760px){.result-grid{grid-template-columns:1fr;}}
.phone{width:300px;margin:0 auto;background:#0a1414;border-radius:40px;padding:10px;box-shadow:0 30px 70px -20px rgba(0,0,0,0.6);}
.phone video{width:100%;border-radius:30px;display:block;background:#000;aspect-ratio:9/16;object-fit:cover;}
.res-h{font:800 28px/1.2 var(--font);margin:0 0 8px;}
.res-sub{font:500 16px/1.5 var(--font);color:var(--text-teal-1);margin:0 0 28px;}
.caption-box{background:var(--bg-1);border:1px solid var(--stroke-1);border-radius:16px;padding:24px;font:500 16px/1.7 var(--font);color:var(--text-mute-2);white-space:pre-wrap;}
.res-actions{display:flex;gap:14px;margin-top:24px;flex-wrap:wrap;}
.spinner{width:18px;height:18px;border-radius:999px;border:2.5px solid rgba(148,251,224,0.2);border-top-color:var(--ado-mint);animation:spin .9s linear infinite;}
@keyframes spin{to{transform:rotate(360deg);}}
@keyframes pulse{0%,100%{opacity:.5;}50%{opacity:1;}}
.pulse{animation:pulse 1.6s ease-in-out infinite;}
.note{font:500 14px/1.6 var(--font);color:var(--text-teal-3);margin-top:20px;}
/* Brand logo */
.brand-logo{height:30px;width:auto;display:block;}
/* 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);} /* 절반 축소 */
/* Tone auto-match chip */
.tone-chip{display:inline-flex;align-items:center;gap:10px;height:40px;padding:0 18px;border-radius:999px;background:rgba(174,114,249,0.16);border:1px solid rgba(174,114,249,0.4);color:var(--ado-purple-light);font:700 15px/1 var(--font);}
.tone-chip i{width:8px;height:8px;border-radius:999px;background:var(--ado-purple-light);box-shadow:0 0 10px var(--ado-purple-light);}
/* Result: editable caption + actions */
.caption-edit{width:100%;min-height:200px;padding:20px 22px;background:var(--bg-1);border:1px solid var(--stroke-1);border-radius:16px;color:var(--text-mute-2);font:500 16px/1.7 var(--font);outline:none;resize:vertical;}
.caption-edit:focus{border-color:var(--ado-mint);box-shadow:0 0 0 3px rgba(148,251,224,0.14);}
.cap-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;}
.cap-head .lbl{font:700 13px/1 var(--font);letter-spacing:0.08em;text-transform:uppercase;color:var(--text-mute-1);}
.copy-btn{background:transparent;border:none;cursor:pointer;color:var(--ado-mint);font:700 14px/1 var(--font);display:inline-flex;align-items:center;gap:6px;}
.res-badges{display:flex;gap:10px;margin-bottom:20px;flex-wrap:wrap;}
/* 자막 스크립트 생성기 */
.script-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;}
.btn-mini{height:42px;padding:0 20px;border-radius:999px;border:none;cursor:pointer;font:700 15px/1 var(--font);background:linear-gradient(135deg,var(--ado-mint),var(--ado-purple));color:var(--bg-0);display:inline-flex;align-items:center;gap:8px;}
.btn-mini:disabled{opacity:0.4;cursor:not-allowed;}
.script-card{background:var(--bg-1);border:1px solid var(--stroke-1);border-radius:14px;padding:16px 18px;margin-top:12px;}
.sc-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;}
.sc-label{font:700 12px/1 var(--font);letter-spacing:0.08em;text-transform:uppercase;color:var(--ado-mint);}
.sc-actions{display:flex;gap:8px;}
.sc-actions button{background:transparent;border:1px solid var(--stroke-1);border-radius:999px;height:30px;padding:0 14px;color:var(--text-mute-2);font:600 13px/1 var(--font);cursor:pointer;}
.sc-actions button:hover{border-color:var(--ado-mint);color:#fff;}
.sc-text{font:500 16px/1.6 var(--font);color:var(--text-mute-2);margin:0;white-space:pre-wrap;}
.script-card textarea{width:100%;min-height:70px;padding:12px 14px;background:var(--bg-0);border:1px solid var(--ado-mint);border-radius:10px;color:#fff;font:500 16px/1.6 var(--font);outline:none;resize:vertical;}
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"></script>
<script type="text/babel">
const { useState, useRef, useEffect } = React;
/* ===== Minimal icon set (7) — 단순 라인 아이콘만 ===== */
const Icon = {
Image: (p) => <svg width={p.size||20} height={p.size||20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="M3 17l5-4 4 3 3-2 6 4"/></svg>,
Check: (p) => <svg width={p.size||20} height={p.size||20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4"><path d="M5 12l5 5 9-11"/></svg>,
Right: (p) => <svg width={p.size||20} height={p.size||20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14M13 6l6 6-6 6"/></svg>,
Left: (p) => <svg width={p.size||20} height={p.size||20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 12H5M11 6l-6 6 6 6"/></svg>,
Play: (p) => <svg width={p.size||20} height={p.size||20} viewBox="0 0 24 24" fill="currentColor"><path d="M7 5l13 7-13 7V5z"/></svg>,
Close: (p) => <svg width={p.size||16} height={p.size||16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 5l14 14M19 5L5 19"/></svg>,
Download: (p) => <svg width={p.size||20} height={p.size||20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><path d="M12 3v12M7 11l5 5 5-5M5 21h14"/></svg>,
};
/* ===== Config ===== */
// 같은 출처(FastAPI가 이 페이지를 서빙)면 "" 로 두면 /api/generate 로 전송.
// 백엔드 미연결 시 fetch 실패 → 자동으로 데모 결과로 폴백.
const API_BASE = "";
const DEMO_VIDEO = "./demo/mumum.mp4";
const STEPS = [
{ k:"setup", n:"1", b:"사진 업로드", s:"4~5장" },
{ k:"interview", n:"2", b:"정보 입력", s:"몇 가지만 답하기" },
{ k:"generating", n:"3", b:"영상 생성", s:"AI 카메라 + 자막" },
{ k:"result", n:"4", b:"완성", s:"다운로드" },
];
const SCRIPT_BLOCKS = [
{ key:"intro", label:"인트로" },
{ key:"selling", label:"셀링포인트" },
{ key:"story", label:"감성 스토리" },
{ key:"cta", label:"CTA" },
];
function App(){
const [step, setStep] = useState("setup");
const [photos, setPhotos] = useState([]); // {url, name}
const [drag, setDrag] = useState(false);
// 인터뷰 답변 (사람이 직접 = 인텔리전스)
const [kind, setKind] = useState(""); // "place" | "product"
const [bizName, setBizName] = useState("");
const [addr, setAddr] = useState(""); // 주소 또는 판매 사이트 URL
const [price, setPrice] = useState("");
const [selling, setSelling] = useState(""); // 강력한 한방 셀링포인트
const [prog, setProg] = useState([0,0,0]);
const [videoUrl, setVideoUrl] = useState(DEMO_VIDEO);
const [srvCaption, setSrvCaption] = useState(null);
const [srvProfile, setSrvProfile] = useState(null);
const [editedCaption, setEditedCaption] = useState("");
const [copied, setCopied] = useState(false);
const [note, setNote] = useState("");
const [script, setScript] = useState(null); // {intro,selling,story,cta}
const [scriptEditing, setScriptEditing] = useState({});
const [scriptLoading, setScriptLoading] = useState(false);
const fileRef = useRef();
const doneRef = useRef({ bars:false, fetch:false });
// 톤 자동 매칭 (인텔리전스 IP를 가볍게 노출 — 유형 기반)
const tone = kind === "product"
? { label: "역동·쇼케이스 톤", profile: "Rhythm Reveal" }
: kind === "message"
? { label: "따뜻한 메시지 톤", profile: "Still Cinema" }
: { label: "감성·시네마틱 톤", profile: "Still Cinema" };
// 유형별 입력 placeholder (분기를 한 곳에 모음)
const KIND_PH = {
place: { name: "예: 스테이 머뭄", addr: "예: 전북 군산시 절골길 18", price: "예: 1박 19만원~", selling: "예: 옆방 손님 없는 완전한 독채, 개별 정원까지" },
product: { name: "예: 브랜드명 숄더백", addr: "예: smartstore.naver.com/...", price: "예: 12,900원", selling: "예: 새벽 수확한 감귤만 당일 착즙 — 설탕 무첨가" },
message: { name: "예: 생일 축하", addr: "예: 관련 링크 (선택)", price: "", selling: "예: 늘 곁에 있어줘서 고마워, 생일 축하해" },
};
const ph = KIND_PH[kind] || KIND_PH.place;
const stepIdx = STEPS.findIndex(s=>s.k===step);
const canGenerate = kind && bizName.trim() && selling.trim();
function addFiles(list){
const imgs = Array.from(list).filter(f=>f.type.startsWith("image/"));
const next = imgs.map(f=>({ url:URL.createObjectURL(f), name:f.name, file:f }));
setPhotos(p=>[...p, ...next]);
}
function removePhoto(i){ setPhotos(p=>p.filter((_,idx)=>idx!==i)); }
function finishIfReady(){
if(doneRef.current.bars && doneRef.current.fetch) setStep("result");
}
function startGenerate(){
setStep("generating");
setProg([0,0,0]); setNote(""); 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);
fd.append("selling", selling);
if(addr) fd.append("addr", addr);
if(price) fd.append("price", price);
photos.forEach(p=> p.file && fd.append("photos", p.file, p.name));
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);
})
.catch(()=>{
setVideoUrl(DEMO_VIDEO);
setNote("백엔드 미연결 — 데모 결과를 표시합니다. (서버 실행 시 실제 생성)");
})
.finally(()=>{ doneRef.current.fetch=true; finishIfReady(); });
}
function reset(){ setStep("setup"); setPhotos([]); setKind(""); setBizName(""); setAddr(""); setPrice(""); setSelling(""); setProg([0,0,0]); setNote(""); setSrvCaption(null); setSrvProfile(null); setEditedCaption(""); setCopied(false); setVideoUrl(DEMO_VIDEO); setScript(null); setScriptEditing({}); }
function copyCaption(){
navigator.clipboard.writeText(editedCaption).then(()=>{ setCopied(true); setTimeout(()=>setCopied(false), 1800); });
}
// 백엔드 없을 때 입력값 기반 폴백 (Vercel 정적 배포에서도 동작)
function localScript(){
if(kind === "message"){
const title = bizName || "전하는 마음";
return {
intro: title,
selling: selling || "전하고 싶은 한마디",
story: "사진 한 장에 마음을 담아, 오래 기억될 순간으로.",
cta: "마음이 닿기를",
};
}
const place = kind !== "product";
const name = bizName || (place ? "이 곳" : "이 제품");
return {
intro: place ? `${addr ? addr + " " : ""}${name}, 아직 아무도 모르는 곳`
: `${name}, 한 번 쓰면 다시 찾게 되는`,
selling: selling || (place ? "옆방 손님 없는 완전한 독채" : "하나하나 정성껏 만든 단 하나"),
story: place ? "도착하는 순간, 일상의 속도가 천천히 느려집니다. 오늘 밤은 오롯이 당신만의 시간."
: "바쁜 하루의 끝, 작은 사치 하나가 마음을 데웁니다.",
cta: place ? `지금 프로필 링크에서 ${name} 예약하기`
: (addr ? `지금 ${addr}에서 만나보기` : "지금 주문하기"),
};
}
async function generateScript(){
setScriptLoading(true); setScriptEditing({});
try {
const r = await fetch(`${API_BASE}/api/captions`, {
method:"POST", headers:{ "Content-Type":"application/json" },
body: JSON.stringify({ kind, biz_name:bizName, addr, price, selling }),
});
if(!r.ok) throw new Error("HTTP "+r.status);
const d = await r.json();
setScript({ intro:d.intro, selling:d.selling, story:d.story, cta:d.cta });
} catch {
setScript(localScript()); // 폴백
} finally { setScriptLoading(false); }
}
function toggleScriptEdit(key){ setScriptEditing(s=>({ ...s, [key]: !s[key] })); }
function delScriptBlock(key){ setScript(s=>({ ...s, [key]: null })); }
// 인터뷰 답변 → 결과 캡션
const caption = `${bizName||"내 가게"}${addr?` · ${addr}`:""}${price?`\n${price}`:""}\n\n${selling||"고객에게 가장 자랑하고 싶은 한 가지"}\n\n${kind==="product"?"#제품추천 #신상 #쇼핑":"#감성장소 #핫플 #여행"} #ADO2\n\n— 실제 사진 기반, AI 카메라 효과를 적용한 영상입니다.`;
return (
<div>
<div className="topbar">
<img className="brand-logo" src="./assets/ado2-logo-white.png" alt="ADO2.AI" />
<div className="step-count"><b>{stepIdx+1}</b> / 4</div>
</div>
<div className="wrap">
{step==="setup" && (
<div className="hero">
<div className="eyebrow">ADO2 Hookit · 8초 숏폼</div>
<h1 className="hero-title">사진 몇 장과 한마디면,<br/><em>8초 홍보 영상</em>이 됩니다</h1>
<p className="hero-lede">복잡한 분석 없이 — 사진을 올리고 몇 가지만 답하면, AI 카메라 무브와 자막을 입힌 <span style={{whiteSpace:"nowrap"}}>세로형 숏폼</span>이 완성됩니다.</p>
</div>
)}
<div className="stepper">
{STEPS.map((s,i)=>(
<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><b>{s.b}</b><span>{s.s}</span></div>
</div>
))}
</div>
{/* STEP 1 — UPLOAD */}
{step==="setup" && (
<div className="card">
<h2>사진 업로드</h2>
<p className="sub">홍보할 장소나 물건 사진 4~5장. 전경 1장 · 디테일 2~3장을 섞으면 영상의 서사가 살아납니다.</p>
<div className={"upload"+(drag?" drag":"")}
onClick={()=>fileRef.current.click()}
onDragOver={e=>{e.preventDefault();setDrag(true);}}
onDragLeave={()=>setDrag(false)}
onDrop={e=>{e.preventDefault();setDrag(false);addFiles(e.dataTransfer.files);}}>
<div className="ico"><Icon.Image size={30}/></div>
<b>여기로 사진을 끌어다 놓거나 클릭</b>
<span>JPG · PNG · 4장 이상</span>
<input ref={fileRef} type="file" accept="image/*" multiple style={{display:"none"}}
onChange={e=>addFiles(e.target.files)} />
</div>
{photos.length>0 && (
<div className="thumbs">
{photos.map((p,i)=>(
<div className="thumb" key={i}>
<img src={p.url} alt=""/>
<div className="n">{i+1}</div>
<button className="x" onClick={()=>removePhoto(i)}><Icon.Close/></button>
</div>
))}
</div>
)}
<div className="actionbar">
<div className="stat"><b>{photos.length}장</b><span>업로드됨 {photos.length<4?`· ${4-photos.length} `:"· "}</span></div>
<button className="btn btn-primary" disabled={photos.length<4} onClick={()=>setStep("interview")}>
다음: 정보 입력 <Icon.Right/>
</button>
</div>
</div>
)}
{/* STEP 2 — INTERVIEW (사람이 직접 답 = 인텔리전스) */}
{step==="interview" && (
<div className="card">
<h2>몇 가지만 알려주세요</h2>
<p className="sub">사장님·마케터가 가장 잘 아는 정보로 카피를 만듭니다.</p>
<div className="form-stack">
<div className="field">
<label>영상의 목적이 무엇인가요? <span className="req">*</span></label>
<div className="kind-tiles">
<div className={"tile"+(kind==="place"?" sel":"")} onClick={()=>setKind("place")}>
<b>장소</b><span>펜션 · 카페 · 식당 · 매장 등 공간</span>
</div>
<div className={"tile"+(kind==="product"?" sel":"")} onClick={()=>setKind("product")}>
<b>물건</b><span>제품, 음식 등 정적인 아이템</span>
</div>
<div className={"tile"+(kind==="message"?" sel":"")} onClick={()=>setKind("message")}>
<b>메시지</b><span>축하 · 안부 · 홍보 등</span>
</div>
</div>
{kind && (
<div style={{marginTop:16}}>
<span className="tone-chip"><i></i>자동 매칭 톤 · {tone.label}</span>
</div>
)}
</div>
<div className="field">
<label>업체 · 상품 · 메시지 제목 <span className="req">*</span></label>
<input value={bizName} onChange={e=>setBizName(e.target.value)} placeholder={ph.name}/>
</div>
<div className="field field-hero">
<label>가장 자랑하고 싶은 한 가지 <span className="req">*</span></label>
<textarea value={selling} onChange={e=>setSelling(e.target.value)} placeholder={ph.selling}></textarea>
<div className="hint">이 한마디가 영상의 후킹 타이틀·핵심 메시지가 됩니다. 구체적일수록 좋아요.</div>
</div>
{/* AI 자막 스크립트 생성기 */}
<div className="field">
<div className="script-head">
<label style={{margin:0}}>영상 자막 · 스크립트</label>
<button className="btn-mini" onClick={generateScript} disabled={scriptLoading || !selling.trim()}>
{scriptLoading ? "생성 중…" : (script ? "다시 생성" : "자막 생성하기")}
</button>
</div>
{!script && (
<div className="hint">위 한 가지를 적은 뒤 누르면, AI가 <b>인트로 · 셀링포인트 · 감성 스토리 · CTA</b> 4가지를 만들어줍니다. 각 블록은 수정·삭제할 수 있어요.</div>
)}
{script && SCRIPT_BLOCKS.map(b => script[b.key] != null && (
<div className="script-card" key={b.key}>
<div className="sc-top">
<span className="sc-label">{b.label}</span>
<span className="sc-actions">
<button onClick={()=>toggleScriptEdit(b.key)}>{scriptEditing[b.key] ? "완료" : "수정"}</button>
<button onClick={()=>delScriptBlock(b.key)}>삭제</button>
</span>
</div>
{scriptEditing[b.key]
? <textarea value={script[b.key]} onChange={e=>setScript({ ...script, [b.key]: e.target.value })} />
: <p className="sc-text">{script[b.key]}</p>}
</div>
))}
</div>
<div className="field">
<label>주소 또는 판매 사이트 URL<span className="opt">(선택)</span></label>
<input value={addr} onChange={e=>setAddr(e.target.value)} placeholder={ph.addr}/>
<div className="hint">영상 엔드카드·해시태그에 활용됩니다.</div>
</div>
<div className="field">
<label>가격 정보<span className="opt">(선택)</span></label>
<input value={price} onChange={e=>setPrice(e.target.value)} placeholder={ph.price}/>
</div>
</div>
<div className="actionbar">
<button className="btn btn-ghost" onClick={()=>setStep("setup")}><Icon.Left/> 사진 다시</button>
<button className="btn btn-primary" disabled={!canGenerate} onClick={startGenerate}>영상 생성 <Icon.Right/></button>
</div>
</div>
)}
{/* STEP 3 — GENERATING */}
{step==="generating" && (
<div className="card">
<h2>영상 생성 중</h2>
<p className="sub">한 번의 생성으로 트랜지션·자막·사운드까지 완성됩니다.</p>
{[
{b:"카피·구성 설계", s:"입력한 셀링포인트 → 후킹 타이틀·자막 구성"},
{b:"AI 영상 생성", s:"사진 → 8초 시네마틱 (AI 카메라 효과)"},
{b:"자막·합성", s:"후킹 타이틀 · 셀링 배지 · 엔드카드"},
].map((a,i)=>{
const done = prog[i]>=100, run = prog[i]>0 && prog[i]<100;
return (
<div className="agent" key={i}>
<div className="aname">
<b><span className={"dot "+(done?"done":run?"run":"queue")}></span>{a.b}</b>
<span>{a.s}</span>
</div>
<div>
<div className="pbar"><i style={{width:prog[i]+"%"}}></i></div>
<div className="pct">{done?"완료":prog[i]+"%"}</div>
</div>
</div>
);
})}
</div>
)}
{/* STEP 4 — RESULT */}
{step==="result" && (
<div className="card">
<div className="result-grid">
<div className="phone">
<video src={videoUrl} controls playsInline poster=""></video>
</div>
<div>
<h2 className="res-h">완성됐습니다</h2>
<p className="res-sub">9:16 · 8초 · 사운드 포함 — 바로 업로드할 수 있는 완성본입니다.</p>
<div className="res-badges">
<span className="chip">9:16 세로형</span>
<span className="chip">8초</span>
{srvProfile && <span className="tone-chip"><i></i>{srvProfile}</span>}
</div>
<div className="cap-head">
<span className="lbl">업로드 캡션 · 수정 가능</span>
<button className="copy-btn" onClick={copyCaption}>{copied ? "복사됨" : "복사"}</button>
</div>
<textarea className="caption-edit" value={editedCaption} onChange={e=>setEditedCaption(e.target.value)}></textarea>
<div className="res-actions">
<a className="btn btn-primary" href={videoUrl} download><Icon.Download/> 영상 다운로드</a>
<button className="btn btn-ghost" onClick={reset}>새 영상 만들기</button>
</div>
{note && <p className="note">※ {note}</p>}
</div>
</div>
</div>
)}
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App/>);
</script>
</body>
</html>

15
webapp/vercel.json Normal file
View File

@ -0,0 +1,15 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"cleanUrls": true,
"trailingSlash": false,
"headers": [
{
"source": "/demo/(.*)",
"headers": [{ "key": "Cache-Control", "value": "public, max-age=86400" }]
},
{
"source": "/assets/(.*)",
"headers": [{ "key": "Cache-Control", "value": "public, max-age=86400" }]
}
]
}