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

27 KiB
Raw Permalink Blame History

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 테이블 확장

신규 컬럼:

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 확장

기존:

{"resolution": "1920x1080", "style": "visualizer", "background": "ai_cover"}

신규:

{
  "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 확장

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

응답:

{
  "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 형식:

[
  {"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 결정 로직 추가:

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 폴백/대안

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 모드)

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측, 변경 작음)

기존 함수에 추가 파라미터 전달:

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 요청 확장

{
  "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 분기 로직

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): showcqtshowspectrum 시도 + 진짜 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

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 카드에 버튼 추가:

{job.status === 'succeeded' && (
    <button onClick={() => handleVideoFromCompile(job.id)}>
        🎬 영상 만들기
    </button>
)}

handleVideoFromCompile:

async (compileJobId) => {
    const p = await createPipeline({ compile_job_id: compileJobId });
    await startPipeline(p.id);
    // 진행 탭으로 이동 (router push 또는 setTab + setOpenPipelineFor 패턴)
};

8-2. PipelineStartModal.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

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 (신규)

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에 있을 가능성 높음:

PEXELS_API_KEY=xxx     # 이미 있음 (현재 미사용)

신규 (Windows측 — music_ai/.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: 컬럼 존재 확인 후 추가):

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 .envSUBTITLE_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 (구성 탭에서 "이 키워드로 검색해보기" 버튼)