Initial commit: ADO2 Hookit — 8초 후킹 숏폼 생성 파이프라인
3-엔진 오케스트레이션(Claude → Higgsfield → Remotion) PoC + 셀프 웹앱. 백엔드 개발자 핸드오프 문서(HANDOFF.md) 포함. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>main
commit
4deefff061
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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/` 참조.
|
||||||
|
|
@ -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 자동 생성?
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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 | **0–35** | **36–70** | **71–100** |
|
||||||
|
| 정체성 | 정적·다이내믹 하이브리드 | 레퍼런스 매칭 | 공격적 바이럴 |
|
||||||
|
| 카메라 어휘 | 느린 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로 재해석…" 의무 노출
|
||||||
|
|
@ -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 (욕망 트리거가 저장 유발에 강함)
|
||||||
|
|
@ -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 예상 (느림 → 일부 스킵)
|
||||||
|
- 댓글 "여기 어디예요?" 비율 ★★★
|
||||||
|
|
@ -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% (정적 톤 → 알고리즘 약점)
|
||||||
|
- 그러나 **전환율(예약)** 은 ★★★ — 욕망 트리거가 행동으로 가장 빠르게 변환
|
||||||
|
|
@ -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 (정적 톤 단점)
|
||||||
|
- 외부링크 클릭률 ★★ (호기심 → 검색 유발)
|
||||||
|
|
@ -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% 미만 → 선별 운영 필요
|
||||||
|
|
@ -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일 후 성과 데이터로 컨셉별 가중치 업데이트
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
- 음악 매칭 메타데이터 스키마
|
||||||
|
|
@ -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순위 규칙)
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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.
|
|
@ -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");
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { registerRoot } from "remotion";
|
||||||
|
import { RemotionRoot } from "./Root";
|
||||||
|
|
||||||
|
registerRoot(RemotionRoot);
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -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,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")
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
.vercel
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
|
@ -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>
|
||||||
|
|
@ -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" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue