Files
web-page-backend/docs/superpowers/specs/2026-05-09-essential-mix-pipeline-design.md
gahusb d4fb485931 docs(spec): Essential Mix 파이프라인 설계
1시간+ mix 영상(컴파일 → 파이프라인) + essential 시각 스타일(배경 사진 + 중앙 방사형 막대 + 곡명 자막) + 진행 탭 산출물 미리보기 모달.

핵심 결정:
- 입력: track_id XOR compile_job_id
- 시각: single (기존) / essential (신규, default)
- 배경: static(사진) / video_loop(Pexels 영상)
- 배경 소스: AI 기본 + Pexels 폴백
- Mix 메타: 트랙 리스트 자동 챕터화 (YouTube 자동 인식)
- UX: PipelineCard mini 미리보기 + 클릭 시 상세 모달

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:55:24 +09:00

707 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Essential Mix 파이프라인 — 1시간 mix + essential 시각 스타일 + UX 강화 설계
> 작성일: 2026-05-09
> 관련 spec:
> - `2026-05-07-music-youtube-pipeline-design.md` (본 파이프라인의 베이스)
> - `2026-05-09-gpu-video-offload-design.md` (Windows GPU 인코딩)
---
## 1. 배경
현재 파이프라인은 **단일 트랙 → 단일 영상**(커버 + 가장자리 파형)만 지원. 사용자는 YouTube essential 채널처럼 **1시간 이상의 음악 mix + 차분한 배경 + 중앙 비주얼라이저** 영상을 원함.
또한 진행 중 산출물(커버·썸네일·영상)을 NAS 파일시스템에서 직접 확인하는 게 번거로워, 진행 탭에서도 미리보기 가능했으면 함.
---
## 2. 비목표
- 사용자 직접 업로드 사진/영상 (P3로 미룸)
- 360° 정확한 방사형 비주얼라이저 (ffmpeg 단독으로 한계 — `showfreqs` + ring overlay로 근사)
- Mix 자동 큐레이션(곡 자동 선택) — 기존 컴파일 탭의 수동 선택 그대로 활용
- AI 검토 가중치 자동 튜닝 (Mix와 단일 트랙의 다른 기준 등 — P3)
- 텔레그램 사진 첨부 — 본 작업의 PipelineDetailModal로 우선 해결, 차후 P3
---
## 3. 사용자 흐름
### 3-1. Mix 영상 만들기
```
[사용자] Compile 탭에서 트랙 N개 선택 → crossfade 설정 → 컴파일 시작
→ 컴파일 완료 (1시간+ mp3 생성, 기존 흐름)
→ 컴파일 카드에 [🎬 영상 만들기] 버튼 클릭
→ 백엔드: POST /api/music/pipeline { compile_job_id, visual_style: 'essential' }
→ 진행 탭으로 자동 이동, 새 카드 생성
→ 단계별 텔레그램 승인 (기존과 동일):
cover (또는 background_video) → video → thumbnail → metadata → AI 검토 → 발행
→ YouTube 비공개 영상 1편
```
### 3-2. 단일 트랙 영상 만들기 (기존)
진행 탭 모달에 라디오 "단일 트랙 / Mix" 추가. 단일 선택 시 기존 흐름 그대로.
### 3-3. 산출물 미리보기
진행 탭 카드의 cover/thumbnail 미니 썸네일 → 카드 클릭 → 상세 모달 → 큰 이미지 + 영상 플레이어 + 메타·검토 JSON.
---
## 4. 데이터 모델 변경
### 4-1. `video_pipelines` 테이블 확장
신규 컬럼:
```sql
ALTER TABLE video_pipelines ADD COLUMN compile_job_id INTEGER NULL REFERENCES compile_jobs(id);
ALTER TABLE video_pipelines ADD COLUMN visual_style TEXT NOT NULL DEFAULT 'essential';
ALTER TABLE video_pipelines ADD COLUMN background_mode TEXT NOT NULL DEFAULT 'static';
ALTER TABLE video_pipelines ADD COLUMN background_keyword TEXT;
```
| 컬럼 | 의미 |
|------|------|
| `track_id` (기존) | 단일 트랙 입력 시 |
| `compile_job_id` (신규) | Mix 입력 시 — `track_id` XOR `compile_job_id` |
| `visual_style` | `single` / `essential` |
| `background_mode` | `static` (사진) / `video_loop` (영상) |
| `background_keyword` | Pexels 검색용 (예: "rainy window cafe"). 비어있으면 장르 기반 자동 |
마이그레이션: `ADD COLUMN`은 SQLite에서 안전. 기존 행은 NULL 또는 default 값 부여.
### 4-2. `youtube_setup.visual_defaults` JSON 확장
기존:
```json
{"resolution": "1920x1080", "style": "visualizer", "background": "ai_cover"}
```
신규:
```json
{
"resolution": "1920x1080",
"default_visual_style": "essential",
"default_background_mode": "static",
"default_background_keyword": "",
"background_image_source": "ai", // ai | pexels (Mix는 default ai)
"subtitle_track_titles": true // Mix에서 곡명 자막 표시
}
```
기존 클라이언트 호환을 위해 미설정 키는 default로 fallback.
---
## 5. API 변경
### 5-1. `POST /api/music/pipeline` 요청 body 확장
```json
{
"track_id": 13,
// 또는
"compile_job_id": 5,
// 옵션 (default는 setup에서)
"visual_style": "essential", // single | essential
"background_mode": "static", // static | video_loop
"background_keyword": "rainy cafe"
}
```
검증:
- `track_id` XOR `compile_job_id` 정확히 하나만 — 둘 다거나 둘 다 없으면 400
- `compile_job_id`인 경우 `compile_jobs` 테이블에서 status='succeeded' 확인 — 아니면 400
- `visual_style` 미지정 시 `youtube_setup.visual_defaults.default_visual_style`
- `background_mode` 미지정 시 `youtube_setup.visual_defaults.default_background_mode`
응답:
```json
{
"id": 7,
"track_id": null,
"compile_job_id": 5,
"visual_style": "essential",
"background_mode": "static",
"state": "created",
...
}
```
### 5-2. `GET /api/music/pipeline/{id}` 응답 확장
신규 필드: `compile_job_id`, `visual_style`, `background_mode`, `background_keyword`, `tracks` (Mix면 트랙 리스트, 단일이면 단일 트랙 1개)
`tracks` 형식:
```json
[
{"id": 13, "title": "Lo-Fi Drive", "start_offset_sec": 0, "duration_sec": 176},
{"id": 14, "title": "Midnight Cafe", "start_offset_sec": 173, "duration_sec": 200},
...
]
```
`start_offset_sec`은 컴파일 시 acrossfade 적용을 고려한 누적 시작 시각 (=영상 자막 트리거 타이밍).
### 5-3. 변경 없음
`/feedback`, `/cancel`, `/publish`, `/setup`, `/youtube/*` 모두 그대로.
---
## 6. 백엔드 — NAS music-lab
### 6-1. `pipeline/orchestrator.py` 변경
`run_step`에 입력 audio 결정 로직 추가:
```python
def _resolve_input(p: dict) -> dict:
"""파이프라인 입력 = 단일 트랙 또는 컴파일 결과.
반환: {"audio_path": str, "duration_sec": int, "tracks": list[dict],
"title": str, "genre": str, "moods": list, ...}
"""
if p.get("compile_job_id"):
job = db.get_compile_job(p["compile_job_id"])
if not job or job["status"] != "succeeded":
raise ValueError(f"compile job {p['compile_job_id']} not ready")
# 누적 offset 계산 (acrossfade 고려)
tracks = []
offset = 0.0
crossfade = job["crossfade_sec"]
for tid in job["track_ids"]:
t = db.get_track_by_id(tid)
tracks.append({
"id": tid, "title": t["title"],
"start_offset_sec": offset,
"duration_sec": t["duration_sec"],
})
offset += t["duration_sec"] - crossfade # acrossfade overlap만큼 차감
return {
"audio_path": job["audio_path"], # /app/data/compiles/{id}.mp3
"duration_sec": int(offset + crossfade), # 마지막 트랙은 풀 길이
"tracks": tracks,
"title": job["title"] or "Mix",
"genre": "mix",
"moods": [],
}
else:
t = db.get_track_by_id(p["track_id"])
return {
"audio_path": t["file_path"],
"duration_sec": t["duration_sec"],
"tracks": [{"id": t["id"], "title": t["title"],
"start_offset_sec": 0, "duration_sec": t["duration_sec"]}],
"title": t["title"], "genre": t["genre"], "moods": t.get("moods", []),
}
```
각 step runner는 `_resolve_input(p)` 결과를 사용:
- `_run_cover`: `genre`, `moods`, `title` 활용 (Mix면 `genre="mix"` → "mix" 키 prompt 또는 default)
- `_run_video`: `audio_path`, `duration_sec`, `tracks` 모두 Windows로 전달
- `_run_meta`: `tracks` 리스트를 메타 prompt에 포함
- `_run_review`: `tracks` 리스트를 검토 prompt에 포함 (트랙 수, 다양한 장르 등)
### 6-2. `pipeline/cover.py` Pexels 폴백/대안
```python
async def generate(*, pipeline_id: int, genre: str, prompt_template: str,
mood: str = "", track_title: str = "", feedback: str = "",
image_source: str = "ai") -> dict:
"""image_source: 'ai' (DALL·E) | 'pexels' (스톡 검색)."""
if image_source == "pexels":
return await _generate_with_pexels(pipeline_id, genre, mood, track_title)
# 기존 AI 흐름 그대로
...
# AI 실패 시 — 그라데이션 폴백 대신 Pexels 시도 (config 옵션)
...
```
신규 `_generate_with_pexels`:
- Pexels API: `GET https://api.pexels.com/v1/search?query={keyword}&per_page=10`
- 결과 1번째 큰 사진 다운로드 → `/app/data/videos/{id}/cover.jpg`
- API key 미설정/실패 시 그라데이션 폴백
### 6-3. 신규 `pipeline/background.py` (video_loop 모드)
```python
async def fetch_video_loop(pipeline_id: int, keyword: str) -> dict:
"""Pexels Video API로 515초 루프 영상 받아옴.
/app/data/videos/{id}/loop.mp4 저장.
"""
# GET https://api.pexels.com/videos/search?query=...&per_page=5
# SD/HD 720p 중에서 골라 다운로드
...
return {"path": "/app/data/videos/{id}/loop.mp4", "duration_sec": ...}
```
오케스트레이터에서 `background_mode == "video_loop"` 분기 시 cover step 대신 또는 보조로 호출 (디자인 결정: cover step을 두 모드의 공통 입력 준비 단계로 통합 — 정적이면 cover.jpg, 영상이면 loop.mp4).
### 6-4. `pipeline/metadata.py` Mix 지원
`generate(*, track, template, trend_keywords, feedback="", tracks=None)` 시그니처 확장. `tracks` 있으면 Claude prompt에 다음 추가:
```
이 영상은 {len(tracks)}개 트랙의 mix입니다. 트랙 리스트:
1. [00:00] Lo-Fi Drive — lo-fi
2. [03:00] Midnight Cafe — lo-fi
...
설명에는 트랙 리스트를 타임스탬프와 함께 포함하세요.
```
응답 description은 자동으로 트랙리스트 포함됨. 이는 YouTube에서 챕터로 자동 인식.
### 6-5. `pipeline/video.py` (NAS측, 변경 작음)
기존 함수에 추가 파라미터 전달:
```python
def generate(*, pipeline_id, audio_path, cover_path, genre, duration_sec,
resolution="1920x1080", style="essential",
background_mode="static", background_path=None,
tracks=None) -> dict:
payload = {
"audio_path_nas": ..., "cover_path_nas": ...,
"output_path_nas": ...,
"resolution": resolution,
"duration_sec": duration_sec,
"style": style, # NEW: single | essential
"background_mode": background_mode, # NEW: static | video_loop
"background_path_nas": ..., # NEW: video_loop일 때 loop.mp4 경로
"tracks": tracks, # NEW: Mix면 트랙 리스트 (자막용)
}
...
```
### 6-6. `db.py` 변경
신규 컬럼 추가 마이그레이션 + `get_compile_job(id)` (없으면 추가) + `get_track_by_id(id)` 활용.
---
## 7. 백엔드 — Windows music_ai
### 7-1. `/encode_video` 요청 확장
```json
{
"audio_path_nas": "...",
"cover_path_nas": "...",
"output_path_nas": "...",
"resolution": "1920x1080",
"duration_sec": 3600,
"style": "essential", // NEW
"background_mode": "static", // NEW
"background_path_nas": "...", // NEW: video_loop면 loop.mp4
"tracks": [ // NEW: 자막용
{"start_offset_sec": 0, "title": "Lo-Fi Drive"},
{"start_offset_sec": 173, "title": "Midnight Cafe"}
]
}
```
### 7-2. `video_encoder.py` 분기 로직
```python
def encode_video(*, ..., style="essential", background_mode="static",
background_path_nas=None, tracks=None):
if style == "single":
cmd = build_single_track_cmd(...)
else: # essential
if background_mode == "static":
cmd = build_essential_static_cmd(cover, audio, out, w, h, tracks)
else:
bg = translate_path(background_path_nas)
cmd = build_essential_video_loop_cmd(bg, audio, out, w, h, tracks)
...
```
### 7-3. Essential 정적 ffmpeg 명령
핵심 filter_complex 구조:
```
[0:v]scale=1920:1080,format=yuv420p[bg]; # 정적 배경 사진
[1:a]showfreqs=s=400x200:mode=bar:cmode=combined:colors=0xFFFFFF@0.9[bars]; # 중앙 막대
[2:v]format=rgba[ring]; # 데코 ring PNG (사전 제작 1장)
[bg][bars]overlay=(W-w)/2:(H-h)/2[mid]; # 막대 정중앙 배치
[mid][ring]overlay=(W-w)/2:(H-h)/2[viz]; # ring 데코 같은 위치
[viz]drawtext=...:enable='between(t,0,5)+between(t,173,178)+...'[final]
```
- `showfreqs s=400x200 mode=bar` — 가로 막대 (방사형 근사 1차 버전)
- `ring.png` — 사전 제작된 투명 PNG (`music_ai/assets/visualizer_ring.png`, 단순 흰색 원 + 외곽 점선)
- `drawtext` — 트랙 리스트 순회하며 enable expression 동적 생성
향후(V2): `showcqt``showspectrum` 시도 + 진짜 360° 방사형은 외부 도구(예: SuperCollider, butterchurn) 검토.
### 7-4. Essential 영상 루프 ffmpeg 명령
```
[0:v]scale=1920:1080,setpts=PTS-STARTPTS[bg_loop];
loop=loop=-1:size=N # 루프 영상 무한 반복
[1:a]showfreqs=...[bars];
[bg_loop][bars]overlay=center[mid];
[mid][ring]overlay=center[viz];
... drawtext 동일
```
루프는 `-stream_loop -1 -i loop.mp4` 입력 옵션 + `-shortest` 출력으로 audio 길이만큼 반복.
### 7-5. 자막(곡명) drawtext
```python
def build_drawtext_filter(tracks, total_duration):
expressions = []
for tr in tracks:
start = tr["start_offset_sec"]
end = start + 5 # 5초 표시
# alpha fade in/out
text = tr["title"].replace(":", r"\:").replace("'", r"\'")
expressions.append(
f"drawtext=fontfile='Arial Bold':text='{text}'"
f":fontcolor=white:fontsize=36:x=(w-text_w)/2:y=h-100"
f":alpha='if(between(t,{start},{end}),"
f" if(lt(t-{start},1), t-{start}," # 0~1s fade in
f" if(gt(t-{start},4), {end}-t, 1)), 0)'" # 4~5s fade out
)
return ",".join(expressions) # 체인으로 연결
```
폰트는 Windows에 기본 설치된 Arial 또는 NanumGothic 사용. 한글 트랙명 지원 위해 NanumGothic 권장.
### 7-6. 신규 자산 파일
`music_ai/assets/visualizer_ring.png` — 1920×1080 캔버스 정중앙 400×400 영역에 그려진 흰색 원형 (외곽선 + 옅은 inner glow). 사전 제작 1장 — Pillow로 자동 생성도 가능 (서버 시작 시 없으면 생성).
---
## 8. 프론트엔드 변경
### 8-1. `CompileTab.jsx` — 영상 만들기 버튼
완료된 compile job 카드에 버튼 추가:
```jsx
{job.status === 'succeeded' && (
<button onClick={() => handleVideoFromCompile(job.id)}>
🎬 영상 만들기
</button>
)}
```
`handleVideoFromCompile`:
```js
async (compileJobId) => {
const p = await createPipeline({ compile_job_id: compileJobId });
await startPipeline(p.id);
// 진행 탭으로 이동 (router push 또는 setTab + setOpenPipelineFor 패턴)
};
```
### 8-2. `PipelineStartModal.jsx` 확장
```jsx
const [inputType, setInputType] = useState('track'); // 'track' | 'compile'
const [compileJobs, setCompileJobs] = useState([]);
useEffect(() => {
if (inputType === 'compile') getCompileJobs().then(setCompileJobs);
}, [inputType]);
return (
<div className="modal-body">
<h3> 파이프라인 시작</h3>
<fieldset>
<legend>입력</legend>
<label><input type="radio" checked={inputType==='track'}
onChange={() => setInputType('track')}/> 단일 트랙</label>
<label><input type="radio" checked={inputType==='compile'}
onChange={() => setInputType('compile')}/> Mix (컴파일 결과)</label>
</fieldset>
{inputType === 'track' && (
<select>{library.map(...)}</select>
)}
{inputType === 'compile' && (
<select>{compileJobs.filter(j=>j.status==='succeeded').map(j =>
<option key={j.id} value={j.id}>{j.title} ({j.tracks_count}, {fmtDuration(j.duration_sec)})</option>
)}</select>
)}
{/* 시각 모드 override */}
<details>
<summary>고급 옵션</summary>
<select>visual_style: single | essential</select>
<select>background_mode: static | video_loop</select>
<input>background_keyword</input>
</details>
{/* ... 기존 시작/취소 버튼 */}
</div>
);
```
### 8-3. `PipelineCard.jsx` — 미리보기 inline
```jsx
return (
<div className="pipeline-card" onClick={() => setShowDetail(true)}>
<div className="pipeline-card__head">
<h4>{pipeline.track_title || pipeline.compile_title || `Pipeline #${pipeline.id}`}</h4>
<span className="pipeline-style-badge">{pipeline.visual_style}</span>
...
</div>
{/* 미니 미리보기 */}
<div className="pipeline-previews">
{pipeline.cover_url && <img src={pipeline.cover_url} alt="" className="pipeline-preview-mini" />}
{pipeline.thumbnail_url && <img src={pipeline.thumbnail_url} alt="" className="pipeline-preview-mini" />}
{pipeline.video_url && <span className="pipeline-video-icon"></span>}
</div>
{/* 진행도 바 + 현재 상태 (기존) */}
...
</div>
);
```
### 8-4. `PipelineDetailModal.jsx` (신규)
```jsx
export default function PipelineDetailModal({ pipeline, onClose }) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-body modal-body--lg" onClick={e=>e.stopPropagation()}>
<header>
<h3>{pipeline.compile_title || pipeline.track_title}</h3>
<span className="badge">{pipeline.visual_style}</span>
<button onClick={onClose}>×</button>
</header>
{/* 큰 미리보기 그리드 */}
<div className="pdm-grid">
{pipeline.cover_url && (
<figure>
<img src={pipeline.cover_url} alt="cover" />
<figcaption>커버 (배경)</figcaption>
</figure>
)}
{pipeline.thumbnail_url && (
<figure>
<img src={pipeline.thumbnail_url} alt="thumbnail" />
<figcaption>썸네일</figcaption>
</figure>
)}
</div>
{/* 영상 플레이어 */}
{pipeline.video_url && (
<div className="pdm-video">
<video src={pipeline.video_url} controls width="100%" />
</div>
)}
{/* 메타데이터 */}
{pipeline.metadata && (
<section className="pdm-meta">
<h4>메타데이터</h4>
<p><strong>제목:</strong> {pipeline.metadata.title}</p>
<details>
<summary>설명</summary>
<pre>{pipeline.metadata.description}</pre>
</details>
<p><strong>태그:</strong> {pipeline.metadata.tags?.join(', ')}</p>
</section>
)}
{/* AI 검토 */}
{pipeline.review && (
<section className="pdm-review">
<h4>AI 검토 <span className="badge">{pipeline.review.verdict}</span> ({pipeline.review.weighted_total}/100)</h4>
<table>
<tbody>
<tr><td>메타데이터 품질</td><td>{pipeline.review.metadata_quality.score}</td></tr>
<tr><td>콘텐츠 정책</td><td>{pipeline.review.policy_compliance.score}</td></tr>
<tr><td>시청 경험</td><td>{pipeline.review.viewer_experience.score}</td></tr>
<tr><td>트렌드 정렬</td><td>{pipeline.review.trend_alignment.score}</td></tr>
</tbody>
</table>
<p><em>{pipeline.review.summary}</em></p>
</section>
)}
{/* 트랙 리스트 (Mix일 때) */}
{pipeline.tracks && pipeline.tracks.length > 1 && (
<section className="pdm-tracks">
<h4>트랙 리스트 ({pipeline.tracks.length})</h4>
<ol>
{pipeline.tracks.map(t => (
<li key={t.id}>
[{fmtTimestamp(t.start_offset_sec)}] {t.title} ({fmtDuration(t.duration_sec)})
</li>
))}
</ol>
</section>
)}
{/* 피드백 히스토리 */}
{pipeline.feedback && pipeline.feedback.length > 0 && (
<section className="pdm-feedback">
<h4>피드백 ({pipeline.feedback.length})</h4>
<ul>
{pipeline.feedback.map(f => (
<li key={f.id}>
<code>[{f.step}]</code> {f.feedback_text}
<small>{f.received_at}</small>
</li>
))}
</ul>
</section>
)}
{/* YouTube 링크 */}
{pipeline.youtube_video_id && (
<a href={`https://youtu.be/${pipeline.youtube_video_id}`}
target="_blank" rel="noreferrer" className="pdm-youtube">
🎬 YouTube에서 보기
</a>
)}
</div>
</div>
);
}
```
### 8-5. `SetupTab.jsx` 확장
영상 비주얼 기본값 카드 확장:
- **default_visual_style** 드롭다운: `single` / `essential`
- **default_background_mode** 드롭다운: `static` / `video_loop`
- **default_background_keyword** 텍스트 입력 (예: "lofi cafe")
- **background_image_source** 드롭다운: `ai` / `pexels`
- **subtitle_track_titles** 체크박스: Mix에서 곡명 자막 표시
---
## 9. 환경변수 (NAS측)
신규 — 이미 `.env`에 있을 가능성 높음:
```env
PEXELS_API_KEY=xxx # 이미 있음 (현재 미사용)
```
신규 (Windows측 — music_ai/.env):
```env
# 한글 자막용 폰트 경로 (선택)
SUBTITLE_FONT=C:\Windows\Fonts\malgun.ttf
```
---
## 10. 에러 처리
| 시나리오 | 결과 |
|---------|------|
| compile_job 미완료 (status != succeeded) | POST /pipeline 시 400 |
| compile_job 삭제됨 | get_pipeline에서 `compile_title=null`, 진행 탭에 "삭제됨" 배지 |
| Pexels API 실패 (image) | AI 폴백 |
| Pexels API 실패 (video) | 단색 폴백 + 텔레그램에 "Pexels 실패" 명시 |
| drawtext 자막 한글 폰트 누락 | 자막 없이 인코딩 + 경고 로그 |
| 1시간 NVENC timeout | 영상 단계 timeout 600s → 그래도 부족하면 failed (보통 NVENC면 5분 내) |
---
## 11. 테스트 전략
### 11-1. 단위 테스트 (NAS music-lab)
| 대상 | 테스트 |
|------|--------|
| `orchestrator._resolve_input` | track_id 분기 / compile_job_id 분기 / 둘 다 / 둘 다 없음 / compile not ready |
| `cover.generate` `image_source='pexels'` | Pexels API mock + 다운로드 + 파일 저장 |
| `background.fetch_video_loop` | Pexels Video API mock + mp4 다운로드 |
| `metadata.generate` `tracks=[...]` | 트랙 리스트가 prompt에 포함되는지, 응답 description에 chapter 포맷 |
| API `POST /pipeline { compile_job_id }` | 정상 / not ready 400 / 둘 다 400 / 단일은 기존 작동 |
| DB 마이그레이션 | 새 컬럼 default 값 |
### 11-2. 단위 테스트 (Windows music_ai)
| 대상 | 테스트 |
|------|--------|
| `build_essential_static_cmd` | filter_complex 문자열 검증 (showfreqs, overlay 위치 등) |
| `build_drawtext_filter` | 트랙 N개 → enable expression N개 생성, alpha fade 검증 |
| `encode_video` `style='essential'` | 새 분기 호출됨 |
| `encode_video` `style='single'` | 기존 단일 트랙 명령 그대로 |
| 자산 ring.png 자동 생성 | 서버 시작 시 없으면 PIL로 생성 |
### 11-3. 통합 테스트
`test_essential_pipeline_flow.py`:
- compile job 생성 → 파이프라인 시작 (compile_job_id) → 모든 단계 mock → published → tracks 리스트가 metadata description에 포함됐는지
### 11-4. 수동 E2E
- [ ] 컴파일 탭에서 3-5분 mix 컴파일
- [ ] "🎬 영상 만들기" 클릭 → 진행 탭 카드 생성, visual_style=essential
- [ ] cover 단계 → 텔레그램 알림 + 카드에 cover 미니 썸네일 표시
- [ ] 카드 클릭 → 상세 모달 → cover 큰 이미지, 메타·검토 영역 표시 (해당 단계 진행 시)
- [ ] 모든 단계 승인 → 발행 → YouTube 비공개 영상에 essential 시각 + 챕터 자동 인식 확인
- [ ] 1시간 mix로 동일 흐름 — Windows NVENC 인코딩 시간 5분 미만 확인
- [ ] background_mode=video_loop로 시도 — Pexels 영상 다운로드 + 루프 인코딩
---
## 12. 마이그레이션 + 배포
### 12-1. DB 마이그레이션
`init_db()` 신규 컬럼 `ALTER TABLE` (SQLite는 idempotent: 컬럼 존재 확인 후 추가):
```python
def _add_column_if_missing(cursor, table, column, ddl):
cursor.execute(f"PRAGMA table_info({table})")
cols = [r[1] for r in cursor.fetchall()]
if column not in cols:
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")
```
### 12-2. 자산 파일
`music_ai/assets/visualizer_ring.png`은 git에 커밋 (small, ~30KB). Windows 측이므로 사용자가 수동 배포 (이미 music_ai는 로컬 전용).
또는 **서버 시작 시 자동 생성** (PIL로 단순 ring 그리기) — 권장. assets 디렉토리도 자동 생성.
### 12-3. 환경변수
NAS `.env` 변경 없음 (PEXELS_API_KEY 이미 있음).
Windows `.env``SUBTITLE_FONT` 추가 (선택).
---
## 13. 산출물
| 영역 | 파일 |
|------|------|
| Spec/Plan | 본 문서 + plan |
| NAS music-lab | `db.py` (마이그레이션), `pipeline/orchestrator.py` (resolve_input), `pipeline/cover.py` (Pexels 분기), `pipeline/background.py` (신규), `pipeline/metadata.py` (tracks 옵션), `pipeline/video.py` (style/background 파라미터), `app/main.py` (POST /pipeline body 확장) |
| Windows music_ai | `video_encoder.py` (style 분기, drawtext, ring), `server.py` (요청 schema 확장), `assets/visualizer_ring.png` (자동 생성), Pillow 이미 있음 |
| Frontend | `CompileTab.jsx` (영상 만들기 버튼), `PipelineStartModal.jsx` (라디오), `PipelineCard.jsx` (미리보기 inline), `PipelineDetailModal.jsx` (신규), `SetupTab.jsx` (visual_defaults 확장), `api.js` 헬퍼 추가, `MusicStudio.css` 스타일 |
| 테스트 | NAS 단위 6+ / Windows 단위 5+ / 통합 1 / 수동 E2E |
---
## 14. 후속 (P3)
- 사용자 직접 사진/영상 업로드
- 텔레그램에 cover/thumbnail 사진 첨부
- 360° 진짜 방사형 visualizer (외부 도구 또는 GPU shader)
- AI 검토 가중치 mix vs 단일 자동 분리
- Pexels 검색 미리보기 UI (구성 탭에서 "이 키워드로 검색해보기" 버튼)
---