Files
web-page-backend/docs/superpowers/plans/2026-05-09-essential-mix-pipeline.md
gahusb ebbfa6299a docs(plan): Essential Mix 파이프라인 — 17 task 구현 계획
DB 마이그레이션 → orchestrator _resolve_input → cover Pexels 분기 →
background.py 신규 → metadata tracks → video.py 파라미터 확장 →
main.py compile_job_id → Windows essential filter (showfreqs+ring+drawtext) →
server.py schema → 통합 테스트 → 배포 → 프론트(api.js, CompileTab,
PipelineStartModal, PipelineCard+DetailModal, SetupTab) → 프론트 푸시 → E2E.

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

94 KiB
Raw Blame History

Essential Mix Pipeline Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 1시간+ 음악 mix를 컴파일 결과물에서 바로 영상화하고, essential 채널 스타일(배경 + 중앙 비주얼라이저 + 곡명 자막)로 발행. 진행 탭에서 산출물을 미리보기 + 상세 모달.

Architecture: 기존 파이프라인 확장 — track_id XOR compile_job_id 입력 받아 orchestrator가 분기. Windows GPU 인코더에 새 essential filter graph 추가. 프론트는 새 모달 + 기존 카드/탭 보강.

Tech Stack: SQLite (마이그레이션), httpx, FastAPI, ffmpeg showfreqs + drawtext + Pexels image/video API, React 18.

Spec: docs/superpowers/specs/2026-05-09-essential-mix-pipeline-design.md


File Structure

NAS music-lab (web-backend/music-lab/)

경로 책임
app/db.py (modify) video_pipelines 4개 컬럼 추가 + _add_column_if_missing 헬퍼 + 컴파일 헬퍼 강화
app/pipeline/orchestrator.py (modify) _resolve_input(p) 함수 + 모든 step runner에 적용
app/pipeline/cover.py (modify) image_source 파라미터 (ai/pexels) + Pexels 이미지 검색·다운로드
app/pipeline/background.py (new) fetch_video_loop(pid, keyword) — Pexels Video API 호출 + mp4 저장
app/pipeline/metadata.py (modify) tracks 파라미터 + Claude prompt에 트랙 리스트 + 챕터 형식
app/pipeline/video.py (modify) style/background_mode/background_path/tracks Windows로 전달
app/main.py (modify) PipelineCreate 모델 확장, compile_job_id 분기, 응답 확장

Windows music_ai (C:/Users/jaeoh/Desktop/workspace/music_ai/)

경로 책임
video_encoder.py (modify) style/background_mode/tracks 분기. essential filter_complex 빌더. drawtext 자막 빌더
assets/visualizer_ring.png (auto-generate) 서버 시작 시 PIL로 1회 생성 (없으면)
server.py (modify) EncodeVideoRequest 모델 확장

Frontend (web-ui/)

경로 책임
src/api.js (modify) createPipeline(payload) 받게 변경, getCompileJobs/triggerVideoFromCompile 헬퍼
src/pages/music/components/CompileTab.jsx (modify) 완료된 job 카드에 "🎬 영상 만들기" 버튼
src/pages/music/components/PipelineStartModal.jsx (modify) 입력 라디오 (단일/Mix) + compile job 드롭다운 + 고급 옵션
src/pages/music/components/PipelineCard.jsx (modify) mini 미리보기 inline + 카드 클릭 → 모달
src/pages/music/components/PipelineDetailModal.jsx (new) 큰 미리보기 + 영상 플레이어 + 메타·검토·트랙·피드백
src/pages/music/components/SetupTab.jsx (modify) visual_defaults 5개 옵션 확장
src/pages/music/MusicStudio.css (modify) 새 미리보기/모달 스타일
src/pages/music/MusicStudio.jsx (modify) CompileTab 핸들러에 onVideoFromCompile 추가, YoutubeTab으로 라우팅

Task 1: DB 마이그레이션 — video_pipelines 4개 컬럼

Files:

  • Modify: music-lab/app/db.py

  • Test: music-lab/tests/test_pipeline_db.py

  • Step 1: Write failing tests (append to existing test file)

# tests/test_pipeline_db.py — append at end

def test_create_pipeline_with_compile_job(fresh_db):
    pid = db.create_pipeline(track_id=None, compile_job_id=42,
                              visual_style="essential", background_mode="static",
                              background_keyword="rainy cafe")
    row = db.get_pipeline(pid)
    assert row["track_id"] is None
    assert row["compile_job_id"] == 42
    assert row["visual_style"] == "essential"
    assert row["background_mode"] == "static"
    assert row["background_keyword"] == "rainy cafe"


def test_create_pipeline_with_track_keeps_defaults(fresh_db):
    pid = db.create_pipeline(track_id=1)
    row = db.get_pipeline(pid)
    assert row["track_id"] == 1
    assert row["compile_job_id"] is None
    assert row["visual_style"] == "essential"  # default
    assert row["background_mode"] == "static"  # default
    assert row["background_keyword"] is None


def test_migration_idempotent(monkeypatch, tmp_path):
    """init_db 두 번 호출해도 ALTER TABLE 에러 없이 통과."""
    db_path = tmp_path / "music.db"
    monkeypatch.setattr(db, "DB_PATH", str(db_path))
    db.init_db()
    db.init_db()  # 두 번째 — 컬럼 이미 존재해도 OK여야
    # 새 컬럼 모두 존재 확인
    import sqlite3
    conn = sqlite3.connect(str(db_path))
    cols = [r[1] for r in conn.execute("PRAGMA table_info(video_pipelines)").fetchall()]
    assert "compile_job_id" in cols
    assert "visual_style" in cols
    assert "background_mode" in cols
    assert "background_keyword" in cols
    conn.close()
  • Step 2: Run, verify fail
cd music-lab && python -m pytest tests/test_pipeline_db.py -v

Expected: FAIL on create_pipeline(... compile_job_id ...) (시그니처 불일치) + 새 컬럼 미존재.

  • Step 3: Add migration helper + ALTER TABLE in init_db()

db.pyinit_db() 안 (기존 video_pipelines CREATE TABLE 직후):

# After the CREATE TABLE IF NOT EXISTS video_pipelines block,
# add idempotent column migrations:
_add_column_if_missing(cursor, "video_pipelines", "compile_job_id", "INTEGER")
_add_column_if_missing(cursor, "video_pipelines", "visual_style", "TEXT NOT NULL DEFAULT 'essential'")
_add_column_if_missing(cursor, "video_pipelines", "background_mode", "TEXT NOT NULL DEFAULT 'static'")
_add_column_if_missing(cursor, "video_pipelines", "background_keyword", "TEXT")

db.py 모듈 레벨 헬퍼 추가 (init_db 함수 앞):

def _add_column_if_missing(cursor, table: str, column: str, ddl: str) -> None:
    """SQLite-safe ALTER TABLE ADD COLUMN — idempotent.

    SQLite의 ALTER TABLE은 컬럼 존재 시 에러 발생. PRAGMA로 미리 확인.
    """
    cursor.execute(f"PRAGMA table_info({table})")
    existing = {row[1] for row in cursor.fetchall()}
    if column not in existing:
        cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")
  • Step 4: Update create_pipeline signature
def create_pipeline(track_id: int | None = None, *,
                    compile_job_id: int | None = None,
                    visual_style: str = "essential",
                    background_mode: str = "static",
                    background_keyword: str | None = None) -> int:
    """track_id XOR compile_job_id 검증."""
    if (track_id is None) == (compile_job_id is None):
        raise ValueError("track_id와 compile_job_id 중 정확히 하나만 지정")
    with _conn() as conn:
        cur = conn.cursor()
        now = _now()
        cur.execute("""
            INSERT INTO video_pipelines
              (track_id, compile_job_id, visual_style, background_mode, background_keyword,
               state, state_started_at, created_at, updated_at)
            VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?)
        """, (track_id, compile_job_id, visual_style, background_mode,
              background_keyword, now, now, now))
        return cur.lastrowid
  • Step 5: Run tests
cd music-lab && python -m pytest tests/test_pipeline_db.py -v

Expected: 모두 PASS (기존 + 신규 3개 = 12+개).

  • Step 6: Commit (DO NOT PUSH)
git add music-lab/app/db.py music-lab/tests/test_pipeline_db.py
git commit -m "feat(music-lab): video_pipelines 4 컬럼 추가 (compile_job_id, visual_style, background_mode, background_keyword)"

Task 2: Orchestrator — _resolve_input 함수

Files:

  • Modify: music-lab/app/pipeline/orchestrator.py

  • Test: music-lab/tests/test_orchestrator_resolve.py (new)

  • Step 1: Write failing test

# tests/test_orchestrator_resolve.py
import pytest
from unittest.mock import patch, MagicMock
from app.pipeline.orchestrator import _resolve_input


def test_resolve_input_track():
    pipeline = {"id": 1, "track_id": 13, "compile_job_id": None}
    track = {
        "id": 13, "title": "Lo-Fi Drive", "genre": "lo-fi",
        "moods": ["chill"], "duration_sec": 176,
        "file_path": "/app/data/x.mp3", "audio_url": "/media/music/x.mp3",
    }
    with patch("app.pipeline.orchestrator.db.get_track_by_id", return_value=track):
        result = _resolve_input(pipeline)
    assert result["audio_path"] == "/app/data/x.mp3"
    assert result["duration_sec"] == 176
    assert len(result["tracks"]) == 1
    assert result["tracks"][0]["start_offset_sec"] == 0
    assert result["title"] == "Lo-Fi Drive"
    assert result["genre"] == "lo-fi"


def test_resolve_input_compile_job():
    pipeline = {"id": 2, "track_id": None, "compile_job_id": 5}
    job = {
        "id": 5, "status": "succeeded", "title": "Chill Mix",
        "audio_path": "/app/data/compiles/5.mp3",
        "track_ids": [13, 14, 15],
        "crossfade_sec": 3,
    }
    tracks = {
        13: {"id": 13, "title": "T1", "duration_sec": 180},
        14: {"id": 14, "title": "T2", "duration_sec": 200},
        15: {"id": 15, "title": "T3", "duration_sec": 150},
    }
    with patch("app.pipeline.orchestrator.db.get_compile_job", return_value=job), \
         patch("app.pipeline.orchestrator.db.get_track_by_id", side_effect=lambda i: tracks[i]):
        result = _resolve_input(pipeline)
    assert result["audio_path"] == "/app/data/compiles/5.mp3"
    # 누적 = 180+200+150 - 2*3(crossfade) = 524
    assert result["duration_sec"] == 524
    assert len(result["tracks"]) == 3
    assert result["tracks"][0]["start_offset_sec"] == 0
    assert result["tracks"][1]["start_offset_sec"] == 177  # 180 - 3
    assert result["tracks"][2]["start_offset_sec"] == 374  # 177 + 200 - 3
    assert result["title"] == "Chill Mix"
    assert result["genre"] == "mix"


def test_resolve_input_compile_not_ready():
    pipeline = {"id": 3, "track_id": None, "compile_job_id": 6}
    job = {"id": 6, "status": "rendering"}
    with patch("app.pipeline.orchestrator.db.get_compile_job", return_value=job):
        with pytest.raises(ValueError, match="not ready"):
            _resolve_input(pipeline)


def test_resolve_input_neither():
    pipeline = {"id": 4, "track_id": None, "compile_job_id": None}
    with pytest.raises(ValueError, match="track_id"):
        _resolve_input(pipeline)
  • Step 2: Run, verify fail
cd music-lab && python -m pytest tests/test_orchestrator_resolve.py -v

Expected: ImportError on _resolve_input.

  • Step 3: Implement _resolve_input in orchestrator.py

orchestrator.py에 추가 (file 상단 import 정리 + 함수 추가):

def _resolve_input(p: dict) -> dict:
    """파이프라인 입력 = 단일 트랙 또는 컴파일 결과.

    반환: {
        "audio_path": str,           # 컨테이너 절대경로
        "duration_sec": int,
        "tracks": list[{"id", "title", "start_offset_sec", "duration_sec"}],
        "title": str,
        "genre": str,                 # mix는 "mix"
        "moods": list[str],
    }
    """
    track_id = p.get("track_id")
    compile_id = p.get("compile_job_id")

    if track_id is None and compile_id is None:
        raise ValueError("track_id 또는 compile_job_id 중 하나는 필요")

    if compile_id is not None:
        job = db.get_compile_job(compile_id)
        if not job or job.get("status") != "succeeded":
            raise ValueError(f"compile job {compile_id} not ready (status={job.get('status') if job else None})")

        tracks = []
        offset = 0.0
        crossfade = job.get("crossfade_sec", 0) or 0
        track_ids = job.get("track_ids") or []
        last_dur = 0
        for tid in track_ids:
            t = db.get_track_by_id(tid)
            if not t:
                continue
            dur = t.get("duration_sec", 0)
            tracks.append({
                "id": tid, "title": t.get("title", ""),
                "start_offset_sec": int(offset),
                "duration_sec": dur,
            })
            offset += dur - crossfade  # acrossfade overlap 차감
            last_dur = dur
        # 마지막 트랙은 풀 길이 반영 (crossfade 빼기 한 것 복구)
        total = int(offset + crossfade) if tracks else 0
        return {
            "audio_path": job["audio_path"],
            "duration_sec": total,
            "tracks": tracks,
            "title": job.get("title") or "Mix",
            "genre": "mix",
            "moods": [],
        }

    # 단일 트랙
    t = db.get_track_by_id(track_id)
    if not t:
        raise ValueError(f"track {track_id} 없음")
    return {
        "audio_path": t.get("file_path") or _local_path(t.get("audio_url", "")),
        "duration_sec": t.get("duration_sec", 0),
        "tracks": [{
            "id": t["id"], "title": t.get("title", ""),
            "start_offset_sec": 0,
            "duration_sec": t.get("duration_sec", 0),
        }],
        "title": t.get("title", ""),
        "genre": t.get("genre", "default"),
        "moods": t.get("moods", []) or [],
    }
  • Step 4: Run tests
cd music-lab && python -m pytest tests/test_orchestrator_resolve.py -v

Expected: 4 PASS.

  • Step 5: Update step runners to use _resolve_input

orchestrator.pyrun_step 안 + 각 _run_* 함수 변경. run_step에서 _resolve_input을 한 번만 호출하고 결과를 step runner에 전달:

async def run_step(pipeline_id: int, step: str, feedback: str = "") -> None:
    job_id = db.create_pipeline_job(pipeline_id, step)
    db.update_pipeline_job(job_id, status="running")
    p = db.get_pipeline(pipeline_id)
    try:
        ctx = _resolve_input(p)
    except ValueError as e:
        db.update_pipeline_job(job_id, status="failed", error=str(e))
        db.update_pipeline_state(pipeline_id, "failed", failed_reason=f"{step}: {e}")
        return

    try:
        if step == "cover":
            result = await _run_cover(p, ctx, feedback)
        elif step == "video":
            result = await _run_video(p, ctx)
        elif step == "thumb":
            result = await _run_thumb(p, ctx, feedback)
        elif step == "meta":
            result = await _run_meta(p, ctx, feedback)
        elif step == "review":
            result = await _run_review(p, ctx)
        elif step == "publish":
            result = await _run_publish(p, ctx)
        else:
            raise ValueError(f"unknown step: {step}")
        db.update_pipeline_job(job_id, status="succeeded")
        db.update_pipeline_state(pipeline_id, result["next_state"], **result.get("fields", {}))
    except Exception as e:
        logger.exception("step %s failed for pipeline %s", step, pipeline_id)
        db.update_pipeline_job(job_id, status="failed", error=str(e))
        db.update_pipeline_state(pipeline_id, "failed", failed_reason=f"{step}: {e}")

_run_* 함수의 track 파라미터를 ctx로 변경, 안에서 ctx["title"] ctx["genre"] 등 사용:

async def _run_cover(p, ctx, feedback):
    setup = db.get_youtube_setup()
    prompts = setup["cover_prompts"]
    template = prompts.get(ctx["genre"].lower(), prompts.get("default", ""))
    image_source = setup.get("background_image_source", "ai")
    out = await cover.generate(
        pipeline_id=p["id"], genre=ctx["genre"],
        prompt_template=template,
        mood=", ".join(ctx["moods"] or []),
        track_title=ctx["title"], feedback=feedback,
        image_source=image_source,  # NEW
    )
    return {"next_state": "cover_pending", "fields": {"cover_url": out["url"]}}


async def _run_video(p, ctx):
    setup = db.get_youtube_setup()
    vd = setup["visual_defaults"]
    audio_path = ctx["audio_path"]
    cover_path = _local_path(p["cover_url"])
    bg_mode = p.get("background_mode") or vd.get("default_background_mode", "static")
    bg_path = None
    if bg_mode == "video_loop":
        # background.py가 fetch한 결과 — _run_cover가 호출 시점에 같이 받았어야
        # 하나 cover step과 분리해도 OK. 여기선 cover_url과 같은 위치에 loop.mp4 있다 가정.
        loop_local = os.path.join(storage.pipeline_dir(p["id"]), "loop.mp4")
        bg_path = loop_local if os.path.isfile(loop_local) else None
    out = video.generate(
        pipeline_id=p["id"], audio_path=audio_path, cover_path=cover_path,
        genre=ctx["genre"], duration_sec=ctx["duration_sec"],
        resolution=vd.get("resolution", "1920x1080"),
        style=p.get("visual_style") or vd.get("default_visual_style", "essential"),
        background_mode=bg_mode, background_path=bg_path,
        tracks=ctx["tracks"] if len(ctx["tracks"]) > 1 else None,  # 1곡이면 자막 X
    )
    return {"next_state": "video_pending", "fields": {"video_url": out["url"]}}


async def _run_thumb(p, ctx, feedback):
    video_path = _local_path(p["video_url"])
    out = await asyncio.to_thread(
        thumb.generate,
        pipeline_id=p["id"], video_path=video_path,
        track_title=ctx["title"], overlay_text=True,
    )
    return {"next_state": "thumb_pending", "fields": {"thumbnail_url": out["url"]}}


async def _run_meta(p, ctx, feedback):
    setup = db.get_youtube_setup()
    trend_top = _get_trend_top()
    out = await metadata.generate(
        track={"title": ctx["title"], "genre": ctx["genre"],
               "duration_sec": ctx["duration_sec"], "moods": ctx["moods"]},
        template=setup["metadata_template"],
        trend_keywords=trend_top, feedback=feedback,
        tracks=ctx["tracks"] if len(ctx["tracks"]) > 1 else None,  # NEW
    )
    return {"next_state": "meta_pending",
            "fields": {"metadata_json": json.dumps(out, ensure_ascii=False)}}

(나머지 _run_review, _run_publish도 ctx 받게 시그니처만 바꾸고 내용 그대로 — 필요 시 ctx에서 정보 추출)

  • Step 6: Run all music-lab tests
cd music-lab && python -m pytest tests/ -v

Expected: 모두 PASS (기존 통합 테스트 영향 없는지 확인).

  • Step 7: Commit
git add music-lab/app/pipeline/orchestrator.py music-lab/tests/test_orchestrator_resolve.py
git commit -m "feat(music-lab): orchestrator _resolve_input — track/compile_job 통합 입력"

Task 3: cover.py — Pexels 이미지 검색 분기

Files:

  • Modify: music-lab/app/pipeline/cover.py

  • Test: music-lab/tests/test_cover_generation.py

  • Step 1: Write failing test (append)

# 기존 imports
import os
from pathlib import Path

# 기존 테스트 끝에 append:

@pytest.mark.asyncio
@respx.mock
async def test_pexels_image_source(tmp_storage, monkeypatch):
    monkeypatch.setenv("PEXELS_API_KEY", "test-pexels-key")
    img_url = "https://images.pexels.com/photos/123/photo.jpg"
    respx.get("https://api.pexels.com/v1/search").mock(
        return_value=Response(200, json={
            "photos": [{
                "id": 123,
                "src": {"large2x": img_url, "original": img_url},
            }],
        })
    )
    png_bytes = bytes.fromhex(
        "89504e470d0a1a0a0000000d49484452000000010000000108020000009077"
        "53de0000000c4944415478da6300010000050001"
        "0d0a2db40000000049454e44ae426082"
    )
    respx.get(img_url).mock(return_value=Response(200, content=png_bytes))

    out = await cover.generate(
        pipeline_id=99, genre="lo-fi", prompt_template="ignored",
        mood="chill", track_title="Mix",
        image_source="pexels",
    )
    assert out["used_fallback"] is False
    assert out["url"].endswith("/cover.jpg")
    assert (tmp_storage / "99" / "cover.jpg").exists()


@pytest.mark.asyncio
async def test_pexels_no_api_key_falls_back(tmp_storage, monkeypatch):
    monkeypatch.delenv("PEXELS_API_KEY", raising=False)
    out = await cover.generate(
        pipeline_id=98, genre="lo-fi", prompt_template="x",
        mood="", track_title="Test",
        image_source="pexels",
    )
    assert out["used_fallback"] is True


@pytest.mark.asyncio
@respx.mock
async def test_pexels_zero_results_falls_back(tmp_storage, monkeypatch):
    monkeypatch.setenv("PEXELS_API_KEY", "test-key")
    respx.get("https://api.pexels.com/v1/search").mock(
        return_value=Response(200, json={"photos": []})
    )
    out = await cover.generate(
        pipeline_id=97, genre="lo-fi", prompt_template="x",
        mood="", track_title="Test",
        image_source="pexels",
    )
    assert out["used_fallback"] is True
  • Step 2: Run, verify fail

python -m pytest tests/test_cover_generation.py -v Expected: FAIL on image_source 파라미터 미지원.

  • Step 3: Add Pexels branch to cover.py

cover.py 끝에 헬퍼 함수 + generate()image_source 파라미터:

PEXELS_API_KEY = lambda: os.getenv("PEXELS_API_KEY", "")
PEXELS_IMG_TIMEOUT_S = 30


async def _generate_with_pexels(genre: str, mood: str, track_title: str,
                                  out_path: str, keyword_override: str = "") -> bool:
    """Pexels 이미지 검색·다운로드. 성공 시 True. API key 없거나 0 결과면 False."""
    api_key = PEXELS_API_KEY()
    if not api_key:
        return False
    keyword = keyword_override or f"{genre} aesthetic background"
    try:
        async with httpx.AsyncClient(timeout=PEXELS_IMG_TIMEOUT_S) as client:
            resp = await client.get(
                "https://api.pexels.com/v1/search",
                headers={"Authorization": api_key},
                params={"query": keyword, "per_page": 5, "orientation": "landscape"},
            )
            resp.raise_for_status()
            data = resp.json()
            photos = data.get("photos", [])
            if not photos:
                return False
            img_url = photos[0]["src"].get("large2x") or photos[0]["src"].get("original")
            img_resp = await client.get(img_url)
            img_resp.raise_for_status()
            from io import BytesIO
            with Image.open(BytesIO(img_resp.content)) as src:
                img = src.convert("RGB")
            img.save(out_path, "JPEG", quality=92)
            return True
    except (httpx.HTTPError, httpx.TimeoutException, KeyError, ValueError, OSError) as e:
        logger.warning("Pexels 이미지 검색 실패: %s", e)
        return False

generate() 시그니처 + 분기:

async def generate(*, pipeline_id: int, genre: str, prompt_template: str,
                   mood: str = "", track_title: str = "", feedback: str = "",
                   image_source: str = "ai",
                   background_keyword: str = "") -> dict:
    """image_source: 'ai' (DALL·E 기본) | 'pexels' (스톡 사진)."""
    out_path = os.path.join(storage.pipeline_dir(pipeline_id), "cover.jpg")
    used_fallback = False
    error = None

    if image_source == "pexels":
        ok = await _generate_with_pexels(genre, mood, track_title, out_path, background_keyword)
        if ok:
            return {"url": storage.media_url(pipeline_id, "cover.jpg"),
                    "used_fallback": False, "error": None}
        # Pexels 실패 → 그라데이션 폴백
        used_fallback = True
        error = "Pexels 검색 실패 또는 API 키 없음"
        from .gradient import make_gradient_with_title
        make_gradient_with_title(genre, track_title, out_path)
        return {"url": storage.media_url(pipeline_id, "cover.jpg"),
                "used_fallback": True, "error": error}

    # 기존 AI 흐름 그대로 (이미 구현됨)
    ...

(기존 코드는 그대로 두고, 위 분기를 함수 시작부에 추가)

  • Step 4: Run tests

python -m pytest tests/test_cover_generation.py -v Expected: 모두 PASS (기존 4 + 신규 3 = 7).

  • Step 5: Commit
git add music-lab/app/pipeline/cover.py music-lab/tests/test_cover_generation.py
git commit -m "feat(music-lab): cover.py Pexels 이미지 검색 분기 (image_source=pexels)"

Task 4: background.py — video_loop 모드 (Pexels 영상)

Files:

  • Create: music-lab/app/pipeline/background.py

  • Create: music-lab/tests/test_background.py

  • Step 1: Write failing test

# tests/test_background.py
import os
import pytest
import respx
from httpx import Response
from app.pipeline import background, storage


@pytest.fixture
def tmp_storage(monkeypatch, tmp_path):
    monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path))
    return tmp_path


@pytest.mark.asyncio
@respx.mock
async def test_fetch_video_loop_success(tmp_storage, monkeypatch):
    monkeypatch.setenv("PEXELS_API_KEY", "k")
    video_url = "https://videos.pexels.com/video-files/123/sample.mp4"
    respx.get("https://api.pexels.com/videos/search").mock(
        return_value=Response(200, json={
            "videos": [{
                "id": 123, "duration": 10,
                "video_files": [
                    {"quality": "hd", "width": 1920, "link": video_url},
                ],
            }],
        })
    )
    respx.get(video_url).mock(return_value=Response(200, content=b"\x00" * 4096))

    result = await background.fetch_video_loop(pipeline_id=10, keyword="rainy window")
    assert result["used_fallback"] is False
    assert (tmp_storage / "10" / "loop.mp4").exists()


@pytest.mark.asyncio
async def test_fetch_video_loop_no_api_key(tmp_storage, monkeypatch):
    monkeypatch.delenv("PEXELS_API_KEY", raising=False)
    result = await background.fetch_video_loop(pipeline_id=11, keyword="rain")
    assert result["used_fallback"] is True


@pytest.mark.asyncio
@respx.mock
async def test_fetch_video_loop_zero_results(tmp_storage, monkeypatch):
    monkeypatch.setenv("PEXELS_API_KEY", "k")
    respx.get("https://api.pexels.com/videos/search").mock(
        return_value=Response(200, json={"videos": []})
    )
    result = await background.fetch_video_loop(pipeline_id=12, keyword="impossible-keyword")
    assert result["used_fallback"] is True
  • Step 2: Run, verify fail

python -m pytest tests/test_background.py -v Expected: ImportError.

  • Step 3: Implement background.py
"""Pexels Video API로 background loop 영상 받아오기."""
import os
import logging
import httpx

from . import storage

logger = logging.getLogger("music-lab.background")
TIMEOUT_S = 60


async def fetch_video_loop(pipeline_id: int, keyword: str) -> dict:
    """Pexels Video API → 720p HD mp4 다운로드 → /app/data/videos/{id}/loop.mp4 저장.

    반환: {"path": str | None, "used_fallback": bool, "error": str | None}
    """
    api_key = os.getenv("PEXELS_API_KEY", "")
    if not api_key:
        return {"path": None, "used_fallback": True, "error": "PEXELS_API_KEY 미설정"}

    out_dir = storage.pipeline_dir(pipeline_id)
    out_path = os.path.join(out_dir, "loop.mp4")

    try:
        async with httpx.AsyncClient(timeout=TIMEOUT_S) as client:
            resp = await client.get(
                "https://api.pexels.com/videos/search",
                headers={"Authorization": api_key},
                params={"query": keyword or "ambient calm", "per_page": 5,
                        "orientation": "landscape"},
            )
            resp.raise_for_status()
            data = resp.json()
            videos = data.get("videos", [])
            if not videos:
                return {"path": None, "used_fallback": True,
                        "error": f"Pexels 결과 없음: {keyword}"}

            # 720p HD 우선, 없으면 첫 번째
            chosen = None
            for v in videos:
                for f in v.get("video_files", []):
                    if f.get("quality") == "hd" and f.get("width") in (1280, 1920):
                        chosen = f
                        break
                if chosen:
                    break
            if not chosen:
                chosen = videos[0]["video_files"][0]

            video_url = chosen["link"]
            vid_resp = await client.get(video_url)
            vid_resp.raise_for_status()
            with open(out_path, "wb") as f:
                f.write(vid_resp.content)

            return {"path": out_path, "used_fallback": False, "error": None}
    except (httpx.HTTPError, httpx.TimeoutException, KeyError, ValueError, OSError) as e:
        logger.warning("Pexels video fetch 실패: %s", e)
        return {"path": None, "used_fallback": True, "error": str(e)}
  • Step 4: Run tests

python -m pytest tests/test_background.py -v Expected: 3 PASS.

  • Step 5: Hook into orchestrator's _run_cover for video_loop mode

orchestrator.py _run_cover 시작부:

from . import background  # add at top

async def _run_cover(p, ctx, feedback):
    setup = db.get_youtube_setup()
    bg_mode = p.get("background_mode") or setup["visual_defaults"].get("default_background_mode", "static")
    keyword = p.get("background_keyword") or setup["visual_defaults"].get("default_background_keyword", "")

    # video_loop 모드면 Pexels video 받아오기 (병렬로 cover.jpg도 받기 — visualizer overlay용 단색 배경 폴백)
    if bg_mode == "video_loop":
        bg_result = await background.fetch_video_loop(p["id"], keyword)
        # video_loop 받기 성공 여부와 무관하게 cover.jpg는 또 만들어야 (썸네일 추출 + 폴백용)
        # 그냥 그라데이션 cover로 충분 (video_loop 받기 성공 시 시각적으로 안 보이고, 실패 시 cover로 fallback)
        from .gradient import make_gradient_with_title
        out_path = os.path.join(storage.pipeline_dir(p["id"]), "cover.jpg")
        make_gradient_with_title(ctx["genre"], ctx["title"], out_path)
        return {"next_state": "cover_pending",
                "fields": {"cover_url": storage.media_url(p["id"], "cover.jpg")}}

    # 정적 모드 — 기존 cover.generate 흐름 그대로
    prompts = setup["cover_prompts"]
    template = prompts.get(ctx["genre"].lower(), prompts.get("default", ""))
    image_source = setup["visual_defaults"].get("background_image_source", "ai")
    out = await cover.generate(
        pipeline_id=p["id"], genre=ctx["genre"],
        prompt_template=template,
        mood=", ".join(ctx["moods"] or []),
        track_title=ctx["title"], feedback=feedback,
        image_source=image_source,
        background_keyword=keyword,
    )
    return {"next_state": "cover_pending", "fields": {"cover_url": out["url"]}}
  • Step 6: Run all tests

python -m pytest tests/ -v Expected: 모두 PASS.

  • Step 7: Commit
git add music-lab/app/pipeline/background.py music-lab/app/pipeline/orchestrator.py \
        music-lab/tests/test_background.py
git commit -m "feat(music-lab): background.py — video_loop 모드 Pexels 영상 다운로드"

Task 5: metadata.py — tracks 옵션 + 챕터 prompt

Files:

  • Modify: music-lab/app/pipeline/metadata.py

  • Test: music-lab/tests/test_metadata_generation.py

  • Step 1: Write failing test (append)

@pytest.mark.asyncio
@respx.mock
async def test_metadata_with_tracks_includes_chapter_format(monkeypatch):
    monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
    captured = {}

    def hook(req):
        import json as _json
        captured["body"] = _json.loads(req.content)
        return Response(200, json={"content": [{"type": "text", "text":
            '{"title":"Lo-Fi Mix 3 Tracks","description":"Track 1: [00:00] T1\\nTrack 2: [03:00] T2",'
            '"tags":["lofi","mix"],"category_id":10}'}]})

    respx.post("https://api.anthropic.com/v1/messages").mock(side_effect=hook)
    result = await metadata.generate(
        track={"title": "Mix", "genre": "mix", "duration_sec": 600,
               "moods": []},
        template={"title": "{title}", "description": "{title}",
                  "tags": [], "category_id": 10},
        trend_keywords=[],
        tracks=[
            {"id": 1, "title": "T1", "start_offset_sec": 0, "duration_sec": 180},
            {"id": 2, "title": "T2", "start_offset_sec": 180, "duration_sec": 200},
            {"id": 3, "title": "T3", "start_offset_sec": 380, "duration_sec": 220},
        ],
    )
    body_str = str(captured["body"])
    assert "T1" in body_str and "T2" in body_str and "T3" in body_str
    assert "00:00" in body_str
    # 챕터 형식 포함 검증 (description에)
    assert result["used_fallback"] is False


@pytest.mark.asyncio
async def test_metadata_fallback_with_tracks(monkeypatch):
    """API 키 없을 때 폴백에서도 트랙 리스트 포함되는지."""
    monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
    result = await metadata.generate(
        track={"title": "Mix", "genre": "mix", "duration_sec": 600, "moods": []},
        template={"title": "{title}", "description": "{title}",
                  "tags": [], "category_id": 10},
        trend_keywords=[],
        tracks=[
            {"id": 1, "title": "T1", "start_offset_sec": 0, "duration_sec": 180},
            {"id": 2, "title": "T2", "start_offset_sec": 180, "duration_sec": 200},
        ],
    )
    assert result["used_fallback"] is True
    # 폴백 description에도 트랙 챕터 자동 포함
    assert "00:00" in result["description"]
    assert "T1" in result["description"]
    assert "T2" in result["description"]
  • Step 2: Run, verify fail

python -m pytest tests/test_metadata_generation.py -v Expected: FAIL on tracks 파라미터 + 폴백 description에 트랙 미포함.

  • Step 3: Update metadata.generate signature + prompt + fallback
async def generate(*, track: dict, template: dict, trend_keywords: list[str],
                   feedback: str = "", tracks: list[dict] | None = None) -> dict:
    api_key = _get_api_key()
    if not api_key:
        return {**_fallback_template(track, template, tracks), "used_fallback": True, "error": "no api key"}

    try:
        result = await _call_claude(track, template, trend_keywords, feedback, tracks,
                                    api_key=api_key, model=_get_model())
        return {**result, "used_fallback": False, "error": None}
    except (httpx.HTTPError, httpx.TimeoutException, KeyError, ValueError, json.JSONDecodeError) as e:
        logger.warning("메타데이터 LLM 실패 — 폴백: %s", e)
        return {**_fallback_template(track, template, tracks), "used_fallback": True, "error": str(e)}


def _format_chapters(tracks: list[dict]) -> str:
    """YouTube 챕터 자동 인식 형식: '[mm:ss] 제목' 한 줄씩."""
    if not tracks:
        return ""
    lines = []
    for t in tracks:
        offset = t.get("start_offset_sec", 0)
        m, s = divmod(int(offset), 60)
        h, m = divmod(m, 60)
        if h > 0:
            ts = f"{h:02d}:{m:02d}:{s:02d}"
        else:
            ts = f"{m:02d}:{s:02d}"
        lines.append(f"{ts} {t.get('title', '')}")
    return "\n".join(lines)


def _fallback_template(track: dict, template: dict, tracks: list[dict] | None = None) -> dict:
    fmt_vars = {
        "title": track.get("title", ""),
        "genre": track.get("genre", ""),
        "bpm": track.get("bpm", ""),
        "key": track.get("key", ""),
        "scale": track.get("scale", ""),
    }
    title = template.get("title", "{title}").format(**fmt_vars)
    description = template.get("description", "{title}").format(**fmt_vars)
    if tracks and len(tracks) > 1:
        description = description + "\n\n" + _format_chapters(tracks)
    return {
        "title": title[:100],
        "description": description[:5000],
        "tags": (template.get("tags") or [])[:15],
        "category_id": template.get("category_id", 10),
    }


async def _call_claude(track, template, trend_keywords, feedback, tracks,
                        *, api_key, model):
    user_prompt = (
        "다음 트랙의 YouTube 메타데이터를 생성하세요. JSON으로만 응답.\n\n"
        f"트랙: {json.dumps(track, ensure_ascii=False)}\n"
        f"템플릿: {json.dumps(template, ensure_ascii=False)}\n"
        f"트렌드 키워드: {', '.join(trend_keywords)}\n"
    )
    if tracks and len(tracks) > 1:
        chapters = _format_chapters(tracks)
        user_prompt += (
            f"\n이 영상은 {len(tracks)}개 트랙의 mix입니다. "
            f"description에 다음 챕터 리스트를 그대로 포함하세요 (YouTube 자동 챕터 인식용):\n{chapters}\n"
        )
    if feedback:
        user_prompt += f"\n사용자 피드백: {feedback}\n"
    user_prompt += (
        '\n출력 JSON: {"title": "60자 이내", "description": "1000자 이내",'
        ' "tags": ["15개 이내"], "category_id": 10}'
    )

    async with httpx.AsyncClient(timeout=TIMEOUT_S) as client:
        resp = await client.post(
            "https://api.anthropic.com/v1/messages",
            headers={
                "x-api-key": api_key,
                "anthropic-version": "2023-06-01",
                "content-type": "application/json",
            },
            json={
                "model": model, "max_tokens": 2048,  # mix 더 길어서
                "messages": [{"role": "user", "content": user_prompt}],
            },
        )
        resp.raise_for_status()
        text = resp.json()["content"][0]["text"]
        start = text.find("{")
        end = text.rfind("}") + 1
        if start < 0 or end <= start:
            raise ValueError("Claude 응답에 JSON 블록 없음")
        return json.loads(text[start:end])
  • Step 4: Run tests

python -m pytest tests/test_metadata_generation.py -v Expected: 5 PASS (기존 3 + 신규 2).

  • Step 5: Commit
git add music-lab/app/pipeline/metadata.py music-lab/tests/test_metadata_generation.py
git commit -m "feat(music-lab): metadata tracks 옵션 + YouTube 챕터 자동 형식"

Task 6: NAS pipeline/video.py — Windows 추가 파라미터 전달

Files:

  • Modify: music-lab/app/pipeline/video.py

  • Test: music-lab/tests/test_video_thumb.py

  • Step 1: Write failing test (append)

@respx.mock
def test_generate_video_passes_essential_params(encoder_env, tmp_path, monkeypatch):
    monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path))
    captured = {}

    def hook(req):
        import json as _json
        captured["body"] = _json.loads(req.content)
        return Response(200, json={"ok": True, "duration_ms": 5000,
                                    "output_path_nas": "/v/3/video.mp4",
                                    "output_bytes": 10_000_000,
                                    "encoder": "h264_nvenc", "preset": "p4"})

    respx.post("http://192.168.45.59:8765/encode_video").mock(side_effect=hook)
    out = video.generate(
        pipeline_id=3, audio_path="/app/data/x.mp3",
        cover_path="/app/data/videos/3/cover.jpg",
        genre="mix", duration_sec=3600, resolution="1920x1080",
        style="essential", background_mode="video_loop",
        background_path="/app/data/videos/3/loop.mp4",
        tracks=[{"id": 1, "title": "T1", "start_offset_sec": 0}],
    )
    body = captured["body"]
    assert body["style"] == "essential"
    assert body["background_mode"] == "video_loop"
    assert body["background_path_nas"] == "/volume1/docker/webpage/data/videos/3/loop.mp4"
    assert body["tracks"][0]["title"] == "T1"
  • Step 2: Run, verify fail

python -m pytest tests/test_video_thumb.py -v Expected: FAIL — video.generate 새 파라미터 미지원.

  • Step 3: Update video.generate signature + payload

pipeline/video.py:

def generate(*, pipeline_id: int, audio_path: str, cover_path: str,
             genre: str, duration_sec: int, resolution: str = "1920x1080",
             style: str = "essential",
             background_mode: str = "static",
             background_path: str | None = None,
             tracks: list[dict] | None = None) -> dict:
    if not ENCODER_URL:
        raise VideoGenerationError(
            "WINDOWS_VIDEO_ENCODER_URL 미설정 — Windows 인코더 서버 주소 필요"
        )

    out_path = os.path.join(storage.pipeline_dir(pipeline_id), "video.mp4")
    nas_audio = _container_to_nas(audio_path)
    nas_cover = _container_to_nas(cover_path)
    nas_output = _container_to_nas(out_path)
    nas_bg = _container_to_nas(background_path) if background_path else None

    payload = {
        "cover_path_nas": nas_cover,
        "audio_path_nas": nas_audio,
        "output_path_nas": nas_output,
        "resolution": resolution,
        "duration_sec": duration_sec,
        "style": style,
        "background_mode": background_mode,
        "background_path_nas": nas_bg,
        "tracks": tracks or [],
    }
    # ... 기존 httpx 호출 그대로 ...

(기존 함수 내용에서 payload만 확장.)

  • Step 4: Run tests

python -m pytest tests/test_video_thumb.py -v Expected: 모두 PASS.

  • Step 5: Commit
git add music-lab/app/pipeline/video.py music-lab/tests/test_video_thumb.py
git commit -m "feat(music-lab): video.py — Windows에 style/background_mode/tracks 전달"

Task 7: main.py — POST /pipeline body 확장

Files:

  • Modify: music-lab/app/main.py

  • Test: music-lab/tests/test_pipeline_endpoints.py

  • Step 1: Write failing test (append)

def test_create_pipeline_with_compile_job(client, monkeypatch):
    # compile_jobs 1개 신규 + status=succeeded
    import sqlite3
    conn = sqlite3.connect(db.DB_PATH)
    cur = conn.cursor()
    try:
        cur.execute("""
            INSERT INTO compile_jobs (title, track_ids_json, crossfade_sec,
                                       audio_path, status, created_at)
            VALUES ('Test Mix', '[1,2,3]', 3, '/app/data/compiles/9.mp3',
                     'succeeded', datetime())
        """)
    except sqlite3.OperationalError:
        # 스키마 다를 수 있음 — 실 컬럼명에 맞게 INSERT를 conftest 패턴 따라 조정
        pytest.skip("compile_jobs schema mismatch")
    conn.commit()
    cid = cur.lastrowid
    conn.close()

    r = client.post("/api/music/pipeline", json={"compile_job_id": cid})
    assert r.status_code == 201
    body = r.json()
    assert body["track_id"] is None
    assert body["compile_job_id"] == cid
    assert body["visual_style"] == "essential"


def test_create_pipeline_rejects_both_inputs(client):
    r = client.post("/api/music/pipeline", json={"track_id": 1, "compile_job_id": 1})
    assert r.status_code == 400


def test_create_pipeline_rejects_neither(client):
    r = client.post("/api/music/pipeline", json={})
    assert r.status_code == 400
  • Step 2: Run, verify fail

python -m pytest tests/test_pipeline_endpoints.py -v Expected: FAIL on compile_job_id 미지원.

  • Step 3: Update PipelineCreate model + endpoint

main.py:

class PipelineCreate(BaseModel):
    track_id: int | None = None
    compile_job_id: int | None = None
    visual_style: str | None = None       # single | essential
    background_mode: str | None = None    # static | video_loop
    background_keyword: str | None = None


@app.post("/api/music/pipeline", status_code=201)
def create_pipeline(req: PipelineCreate):
    # XOR 검증
    if (req.track_id is None) == (req.compile_job_id is None):
        raise HTTPException(400, "track_id 또는 compile_job_id 중 정확히 하나를 지정")

    # compile_job 상태 확인
    if req.compile_job_id is not None:
        job = db.get_compile_job(req.compile_job_id)
        if not job:
            raise HTTPException(404, f"compile job {req.compile_job_id} 없음")
        if job.get("status") != "succeeded":
            raise HTTPException(400, f"compile job {req.compile_job_id} not ready (status={job.get('status')})")

    # 동일 입력으로 이미 active 파이프라인 있으면 409
    actives = db.list_pipelines(active_only=True)
    for p in actives:
        if (req.track_id and p.get("track_id") == req.track_id) or \
           (req.compile_job_id and p.get("compile_job_id") == req.compile_job_id):
            raise HTTPException(409, "이미 진행 중인 파이프라인이 있습니다")

    setup = db.get_youtube_setup()
    vd = setup["visual_defaults"]
    pid = db.create_pipeline(
        track_id=req.track_id,
        compile_job_id=req.compile_job_id,
        visual_style=req.visual_style or vd.get("default_visual_style", "essential"),
        background_mode=req.background_mode or vd.get("default_background_mode", "static"),
        background_keyword=req.background_keyword or vd.get("default_background_keyword", ""),
    )
    return db.get_pipeline(pid)
  • Step 4: Run tests

python -m pytest tests/test_pipeline_endpoints.py -v Expected: 모두 PASS (기존 + 신규 3).

  • Step 5: Commit
git add music-lab/app/main.py music-lab/tests/test_pipeline_endpoints.py
git commit -m "feat(music-lab): POST /pipeline에 compile_job_id 입력 + visual_style 등 옵션"

Task 8: Windows video_encoder.py — essential filter 분기

Files:

  • Modify: C:/Users/jaeoh/Desktop/workspace/music_ai/video_encoder.py

  • Modify: C:/Users/jaeoh/Desktop/workspace/music_ai/tests/test_video_encoder.py

  • Step 1: Write failing tests

# tests/test_video_encoder.py — append

@patch("video_encoder.subprocess.run")
def test_encode_essential_static_branch(mock_run, env, tmp_path, monkeypatch):
    cover = tmp_path / "cover.jpg"; cover.write_bytes(b"\x00")
    audio = tmp_path / "audio.mp3"; audio.write_bytes(b"\x00")
    out_path = tmp_path / "out.mp4"

    captured_cmd = []
    def fake_run(cmd, **kwargs):
        captured_cmd.extend(cmd)
        out_path.write_bytes(b"\x00" * (2 * 1024 * 1024))
        return MagicMock(returncode=0, stderr="")
    mock_run.side_effect = fake_run

    def fake_translate(p):
        return str(tmp_path / p.split("/")[-1])
    monkeypatch.setattr(video_encoder, "translate_path", fake_translate)

    result = video_encoder.encode_video(
        cover_path_nas="/volume1/cover.jpg",
        audio_path_nas="/volume1/audio.mp3",
        output_path_nas="/volume1/out.mp4",
        resolution="1920x1080",
        duration_sec=120,
        style="essential",
        background_mode="static",
    )
    assert result["ok"] is True
    cmd_str = " ".join(captured_cmd)
    assert "showfreqs" in cmd_str
    assert "h264_nvenc" in cmd_str


@patch("video_encoder.subprocess.run")
def test_encode_essential_video_loop_branch(mock_run, env, tmp_path, monkeypatch):
    cover = tmp_path / "cover.jpg"; cover.write_bytes(b"\x00")
    audio = tmp_path / "audio.mp3"; audio.write_bytes(b"\x00")
    loop = tmp_path / "loop.mp4"; loop.write_bytes(b"\x00" * 1024)
    out = tmp_path / "out.mp4"

    captured_cmd = []
    def fake_run(cmd, **kwargs):
        captured_cmd.extend(cmd)
        out.write_bytes(b"\x00" * (2 * 1024 * 1024))
        return MagicMock(returncode=0, stderr="")
    mock_run.side_effect = fake_run

    def fake_translate(p):
        return str(tmp_path / p.split("/")[-1])
    monkeypatch.setattr(video_encoder, "translate_path", fake_translate)

    result = video_encoder.encode_video(
        cover_path_nas="/volume1/cover.jpg",
        audio_path_nas="/volume1/audio.mp3",
        output_path_nas="/volume1/out.mp4",
        resolution="1920x1080",
        duration_sec=120,
        style="essential",
        background_mode="video_loop",
        background_path_nas="/volume1/loop.mp4",
    )
    assert result["ok"] is True
    cmd_str = " ".join(captured_cmd)
    assert "stream_loop" in cmd_str
    assert "h264_nvenc" in cmd_str


@patch("video_encoder.subprocess.run")
def test_encode_essential_with_track_subtitles(mock_run, env, tmp_path, monkeypatch):
    cover = tmp_path / "cover.jpg"; cover.write_bytes(b"\x00")
    audio = tmp_path / "audio.mp3"; audio.write_bytes(b"\x00")
    out = tmp_path / "out.mp4"

    captured_cmd = []
    def fake_run(cmd, **kwargs):
        captured_cmd.extend(cmd)
        out.write_bytes(b"\x00" * (2 * 1024 * 1024))
        return MagicMock(returncode=0, stderr="")
    mock_run.side_effect = fake_run

    def fake_translate(p):
        return str(tmp_path / p.split("/")[-1])
    monkeypatch.setattr(video_encoder, "translate_path", fake_translate)

    result = video_encoder.encode_video(
        cover_path_nas="/volume1/cover.jpg",
        audio_path_nas="/volume1/audio.mp3",
        output_path_nas="/volume1/out.mp4",
        resolution="1920x1080",
        duration_sec=600,
        style="essential",
        background_mode="static",
        tracks=[
            {"start_offset_sec": 0, "title": "T1"},
            {"start_offset_sec": 180, "title": "T2"},
            {"start_offset_sec": 380, "title": "T3"},
        ],
    )
    assert result["ok"] is True
    cmd_str = " ".join(captured_cmd)
    assert "drawtext" in cmd_str
    assert "T1" in cmd_str and "T2" in cmd_str and "T3" in cmd_str


@patch("video_encoder.subprocess.run")
def test_encode_single_branch_unchanged(mock_run, env, tmp_path, monkeypatch):
    """style=single은 기존 visualizer cmd 유지 (showwaves)."""
    cover = tmp_path / "cover.jpg"; cover.write_bytes(b"\x00")
    audio = tmp_path / "audio.mp3"; audio.write_bytes(b"\x00")
    out = tmp_path / "out.mp4"
    captured_cmd = []
    def fake_run(cmd, **kwargs):
        captured_cmd.extend(cmd)
        out.write_bytes(b"\x00" * (2 * 1024 * 1024))
        return MagicMock(returncode=0, stderr="")
    mock_run.side_effect = fake_run

    def fake_translate(p):
        return str(tmp_path / p.split("/")[-1])
    monkeypatch.setattr(video_encoder, "translate_path", fake_translate)

    video_encoder.encode_video(
        cover_path_nas="/volume1/cover.jpg",
        audio_path_nas="/volume1/audio.mp3",
        output_path_nas="/volume1/out.mp4",
        resolution="1920x1080",
        duration_sec=120,
        style="single",
    )
    cmd_str = " ".join(captured_cmd)
    assert "showwaves" in cmd_str
    assert "showfreqs" not in cmd_str
  • Step 2: Run, verify fail
cd music_ai && venv/Scripts/python -m pytest tests/test_video_encoder.py -v

Expected: FAIL — style/background_mode/tracks 파라미터 미지원.

  • Step 3: Update encode_video + add filter builders

video_encoder.py 변경:

def encode_video(*, cover_path_nas: str, audio_path_nas: str,
                  output_path_nas: str, resolution: str,
                  duration_sec: int = 0, style: str = "essential",
                  background_mode: str = "static",
                  background_path_nas: str | None = None,
                  tracks: list | None = None) -> dict:
    # 1) Resolution 검증 + 경로 변환 + 입력 존재 확인 (기존 로직 유지)
    if not RESOLUTION_RE.match(resolution):
        raise EncodeError("input_validation", f"invalid resolution: {resolution}")
    w, h = resolution.split("x")
    try:
        cover_win = translate_path(cover_path_nas)
        audio_win = translate_path(audio_path_nas)
        out_win = translate_path(output_path_nas)
        bg_win = translate_path(background_path_nas) if background_path_nas else None
    except ValueError as e:
        raise EncodeError("path_translate", str(e))

    if not os.path.isfile(cover_win):
        raise EncodeError("input_validation", f"cover not found: {cover_win}")
    if not os.path.isfile(audio_win):
        raise EncodeError("input_validation", f"audio not found: {audio_win}")
    if bg_win and not os.path.isfile(bg_win):
        raise EncodeError("input_validation", f"background video not found: {bg_win}")
    os.makedirs(os.path.dirname(out_win), exist_ok=True)

    # 2) Filter builder 분기
    if style == "single":
        cmd = _build_single_cmd(cover_win, audio_win, out_win, w, h)
    elif style == "essential":
        if background_mode == "video_loop" and bg_win:
            cmd = _build_essential_video_loop_cmd(bg_win, audio_win, out_win, w, h, tracks)
        else:
            cmd = _build_essential_static_cmd(cover_win, audio_win, out_win, w, h, tracks)
    else:
        raise EncodeError("input_validation", f"unknown style: {style}")

    logger.info("ffmpeg: %s", " ".join(cmd))

    # 3) 실행 + 검증 (기존 로직 유지)
    import time
    t0 = time.time()
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=FFMPEG_TIMEOUT_S)
    except subprocess.TimeoutExpired:
        raise EncodeError("ffmpeg", f"timeout after {FFMPEG_TIMEOUT_S}s")
    duration_ms = int((time.time() - t0) * 1000)
    if result.returncode != 0:
        raise EncodeError("ffmpeg", f"returncode={result.returncode}: {result.stderr[-800:]}")
    if not os.path.isfile(out_win):
        raise EncodeError("output_check", "output file not created")
    output_bytes = os.path.getsize(out_win)
    if output_bytes < MIN_OUTPUT_BYTES:
        raise EncodeError("output_check", f"output too small: {output_bytes} bytes")
    return {
        "ok": True, "duration_ms": duration_ms,
        "output_path_nas": output_path_nas, "output_bytes": output_bytes,
        "encoder": "h264_nvenc", "preset": "p4",
    }

신규 builder 함수들:

def _build_single_cmd(cover_win, audio_win, out_win, w, h):
    """기존 단일 트랙 — showwaves 가장자리 파형."""
    return [
        FFMPEG_PATH, "-y",
        "-hwaccel", "cuda",
        "-loop", "1", "-i", cover_win,
        "-i", audio_win,
        "-filter_complex",
        f"[0:v]scale={w}:{h},format=yuv420p[bg];"
        f"[1:a]showwaves=s={w}x200:mode=cline:colors=0xFF4444@0.8[wave];"
        f"[bg][wave]overlay=0:({h}-200)[out]",
        "-map", "[out]", "-map", "1:a",
        "-c:v", "h264_nvenc", "-preset", "p4",
        "-rc", "vbr", "-cq", "23", "-b:v", "0",
        "-pix_fmt", "yuv420p",
        "-c:a", "aac", "-b:a", "192k",
        "-shortest", out_win,
    ]


def _build_essential_static_cmd(cover_win, audio_win, out_win, w, h, tracks):
    """essential 정적 — 풀스크린 cover + 중앙 showfreqs + ring + drawtext 자막."""
    ring_path = _ensure_ring_asset()
    base_filter = (
        f"[0:v]scale={w}:{h},format=yuv420p[bg];"
        f"[1:a]showfreqs=s=400x200:mode=bar:cmode=combined:colors=0xFFFFFF@0.9[bars];"
        f"[2:v]format=rgba[ring];"
        f"[bg][bars]overlay=({w}-400)/2:({h}-200)/2[mid];"
        f"[mid][ring]overlay=0:0[viz]"
    )
    drawtext = _build_drawtext_filter(tracks, w, h)
    if drawtext:
        full_filter = base_filter + ";" + f"[viz]{drawtext}[out]"
    else:
        full_filter = base_filter.replace("[viz]", "[out]", 1)

    return [
        FFMPEG_PATH, "-y",
        "-hwaccel", "cuda",
        "-loop", "1", "-i", cover_win,
        "-i", audio_win,
        "-loop", "1", "-i", ring_path,
        "-filter_complex", full_filter,
        "-map", "[out]", "-map", "1:a",
        "-c:v", "h264_nvenc", "-preset", "p4",
        "-rc", "vbr", "-cq", "23", "-b:v", "0",
        "-pix_fmt", "yuv420p",
        "-c:a", "aac", "-b:a", "192k",
        "-shortest", out_win,
    ]


def _build_essential_video_loop_cmd(bg_win, audio_win, out_win, w, h, tracks):
    """essential 영상 루프 — bg 영상 무한 반복 + 중앙 showfreqs + ring + drawtext."""
    ring_path = _ensure_ring_asset()
    base_filter = (
        f"[0:v]scale={w}:{h},setpts=PTS-STARTPTS,format=yuv420p[bg];"
        f"[1:a]showfreqs=s=400x200:mode=bar:cmode=combined:colors=0xFFFFFF@0.9[bars];"
        f"[2:v]format=rgba[ring];"
        f"[bg][bars]overlay=({w}-400)/2:({h}-200)/2[mid];"
        f"[mid][ring]overlay=0:0[viz]"
    )
    drawtext = _build_drawtext_filter(tracks, w, h)
    if drawtext:
        full_filter = base_filter + ";" + f"[viz]{drawtext}[out]"
    else:
        full_filter = base_filter.replace("[viz]", "[out]", 1)

    return [
        FFMPEG_PATH, "-y",
        "-hwaccel", "cuda",
        "-stream_loop", "-1", "-i", bg_win,
        "-i", audio_win,
        "-loop", "1", "-i", ring_path,
        "-filter_complex", full_filter,
        "-map", "[out]", "-map", "1:a",
        "-c:v", "h264_nvenc", "-preset", "p4",
        "-rc", "vbr", "-cq", "23", "-b:v", "0",
        "-pix_fmt", "yuv420p",
        "-c:a", "aac", "-b:a", "192k",
        "-shortest", out_win,
    ]


def _build_drawtext_filter(tracks, w, h) -> str:
    """곡명 자막 — 트랙 시작 시점에 5초 표시 (alpha fade in/out)."""
    if not tracks or len(tracks) <= 1:
        return ""
    font_path = os.getenv("SUBTITLE_FONT", "C:/Windows/Fonts/malgun.ttf")
    font_path_esc = font_path.replace(":", r"\:").replace("\\", "/")
    parts = []
    for tr in tracks:
        start = int(tr.get("start_offset_sec", 0))
        end = start + 5
        title = tr.get("title", "")
        # ffmpeg drawtext escape: : ' \ %
        title_esc = title.replace("\\", r"\\").replace("'", r"\'").replace(":", r"\:").replace("%", r"\%")
        # alpha: 0~1초 fade in, 4~5초 fade out
        alpha_expr = (
            f"if(between(t,{start},{end}),"
            f"  if(lt(t-{start},1), t-{start},"
            f"     if(gt(t-{start},4), {end}-t, 1)), 0)"
        ).replace(" ", "").replace("\n", "")
        parts.append(
            f"drawtext=fontfile='{font_path_esc}':text='{title_esc}'"
            f":fontcolor=white:fontsize=36:x=(w-text_w)/2:y=h-100"
            f":alpha='{alpha_expr}':box=1:boxcolor=black@0.5:boxborderw=10"
        )
    return ",".join(parts)


def _ensure_ring_asset() -> str:
    """assets/visualizer_ring.png 없으면 PIL로 자동 생성."""
    asset_dir = os.path.join(os.path.dirname(__file__), "assets")
    os.makedirs(asset_dir, exist_ok=True)
    ring_path = os.path.join(asset_dir, "visualizer_ring.png")
    if os.path.isfile(ring_path):
        return ring_path
    from PIL import Image, ImageDraw
    W, H = 1920, 1080
    img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
    draw = ImageDraw.Draw(img)
    cx, cy = W // 2, H // 2
    # 외곽 원 (반투명 흰색)
    for r in (250, 240):
        draw.ellipse((cx - r, cy - r, cx + r, cy + r),
                      outline=(255, 255, 255, 90), width=2)
    # 점선 데코 (12개 점)
    import math
    for i in range(24):
        angle = i * (360 / 24) * math.pi / 180
        x = cx + 280 * math.cos(angle)
        y = cy + 280 * math.sin(angle)
        draw.ellipse((x - 3, y - 3, x + 3, y + 3), fill=(255, 255, 255, 120))
    img.save(ring_path, "PNG")
    return ring_path
  • Step 4: Run tests
cd music_ai && venv/Scripts/python -m pytest tests/test_video_encoder.py -v

Expected: 12 PASS (기존 8 + 신규 4).

  • Step 5: No commit (music_ai is local-only)

Task 9: Windows server.py — schema 확장

Files:

  • Modify: C:/Users/jaeoh/Desktop/workspace/music_ai/server.py

  • Step 1: Update EncodeVideoRequest

class TrackInfo(BaseModel):
    start_offset_sec: int
    title: str


class EncodeVideoRequest(BaseModel):
    cover_path_nas: str
    audio_path_nas: str
    output_path_nas: str
    resolution: str = "1920x1080"
    duration_sec: int = 0
    style: str = "essential"
    background_mode: str = "static"
    background_path_nas: str | None = None
    tracks: list[TrackInfo] = []


@app.post("/encode_video")
def encode_video_endpoint(req: EncodeVideoRequest):
    try:
        result = video_encoder.encode_video(
            cover_path_nas=req.cover_path_nas,
            audio_path_nas=req.audio_path_nas,
            output_path_nas=req.output_path_nas,
            resolution=req.resolution,
            duration_sec=req.duration_sec,
            style=req.style,
            background_mode=req.background_mode,
            background_path_nas=req.background_path_nas,
            tracks=[t.dict() for t in req.tracks] if req.tracks else None,
        )
        return result
    except video_encoder.EncodeError as e:
        status_code = 400 if e.stage in ("input_validation", "path_translate") else 500
        raise HTTPException(
            status_code=status_code,
            detail={"ok": False, "stage": e.stage, "error": e.message},
        )
  • Step 2: Verify imports
cd music_ai && venv/Scripts/python -c "from server import app; print(len([r for r in app.routes if hasattr(r,'path')]))"

Expected: 임포트 성공, 라우트 그대로.

  • Step 3: Run all tests
cd music_ai && venv/Scripts/python -m pytest tests/ -v

Expected: 모두 PASS.

  • Step 4: No commit (local-only)

Task 10: NAS 통합 테스트 — compile_job 기반 happy path

Files:

  • Modify: music-lab/tests/test_pipeline_flow.py

  • Step 1: Append new test

@patch("app.pipeline.youtube.upload_video", return_value={"video_id": "MIX_VID"})
@patch("app.pipeline.review.run_4_axis", new=AsyncMock(return_value={
    "metadata_quality": {"score": 90, "notes": ""},
    "policy_compliance": {"score": 95, "issues": []},
    "viewer_experience": {"score": 85, "notes": ""},
    "trend_alignment": {"score": 70, "matched_keywords": []},
    "weighted_total": 87.0, "verdict": "pass", "summary": "ok",
    "used_fallback": False,
}))
@patch("app.pipeline.metadata.generate", new=AsyncMock(return_value={
    "title": "Mix", "description": "Track desc",
    "tags": ["lofi"], "category_id": 10,
    "used_fallback": False, "error": None,
}))
@patch("app.pipeline.thumb.generate", return_value={
    "url": "/media/videos/X/thumbnail.jpg", "used_fallback": False,
})
@patch("app.pipeline.video.generate", return_value={
    "url": "/media/videos/X/video.mp4", "used_fallback": False, "duration_sec": 600,
})
@patch("app.pipeline.cover.generate", new=AsyncMock(return_value={
    "url": "/media/videos/X/cover.jpg", "used_fallback": False, "error": None,
}))
def test_full_pipeline_compile_job_happy_path(*_, client):
    # compile_job 1개 추가 (succeeded)
    import sqlite3
    conn = sqlite3.connect(db.DB_PATH)
    cur = conn.cursor()
    try:
        cur.execute("""
            INSERT INTO compile_jobs (title, track_ids_json, crossfade_sec, audio_path,
                                       status, created_at)
            VALUES ('Test Mix', '[1]', 3, '/app/data/compiles/1.mp3', 'succeeded', datetime())
        """)
    except sqlite3.OperationalError:
        pytest.skip("compile_jobs schema mismatch")
    conn.commit()
    cid = cur.lastrowid
    conn.close()

    pid = client.post("/api/music/pipeline", json={"compile_job_id": cid}).json()["id"]
    assert db.get_pipeline(pid)["state"] == "created"
    assert db.get_pipeline(pid)["compile_job_id"] == cid
    assert db.get_pipeline(pid)["track_id"] is None

    client.post(f"/api/music/pipeline/{pid}/start")
    p = db.get_pipeline(pid)
    assert p["state"] == "cover_pending"

    for step in ["cover", "video", "thumb", "meta"]:
        r = client.post(f"/api/music/pipeline/{pid}/feedback",
                         json={"step": step, "intent": "approve"})
        assert r.status_code == 202

    p = db.get_pipeline(pid)
    assert p["state"] == "publish_pending"

    client.post(f"/api/music/pipeline/{pid}/publish")
    p = db.get_pipeline(pid)
    assert p["state"] == "published"
    assert p["youtube_video_id"] == "MIX_VID"
  • Step 2: Run

python -m pytest tests/test_pipeline_flow.py -v Expected: 3 PASS (기존 2 + 신규 1).

  • Step 3: Run full suite

python -m pytest tests/ -v Expected: 전부 PASS.

  • Step 4: Commit
git add music-lab/tests/test_pipeline_flow.py
git commit -m "test(music-lab): compile_job 기반 happy path 통합 테스트"

Task 11: NAS 푸시 → webhook 자동 배포 + Windows 수동 배포

Files: (배포 단계, 코드 변경 없음)

  • Step 1: 백엔드 푸시
git -C C:/Users/jaeoh/Desktop/workspace/web-backend push origin main

(여러 commit 누적된 상태에서 한 번에 push. Gitea 인증 첫 시도 실패 시 재시도.)

  • Step 2: NAS 자동 배포 확인
ssh user@gahusb.synology.me
cd /volume1/docker/webpage
docker compose logs music-lab --tail=20

Application startup complete + 새 컬럼 마이그레이션 메시지 확인.

  • Step 3: Windows music_ai 수동 재시작

Windows PowerShell:

cd C:\Users\jaeoh\Desktop\workspace\music_ai
# 기존 실행 중이면 Ctrl+C 종료
.\start.bat
  • Step 4: 헬스 체크
Invoke-RestMethod http://localhost:8765/health

ffmpeg_nvenc: true 확인.

  • Step 5: Windows 신규 어셋 자동 생성 확인
ls C:\Users\jaeoh\Desktop\workspace\music_ai\assets\

visualizer_ring.png 자동 생성됐는지 확인 (없으면 첫 encode 호출 시 생성).


Task 12: 프론트엔드 — api.js 헬퍼 + CompileTab 버튼

Files:

  • Modify: web-ui/src/api.js

  • Modify: web-ui/src/pages/music/components/CompileTab.jsx

  • Step 1: api.js — createPipeline 시그니처 변경 + 영상 트리거

기존 createPipeline(track_id)createPipeline(payload). 호환을 위해 track_id만 받는 옛 호출도 동작하게:

// 기존 createPipeline 교체:
export const createPipeline = (payload) => {
    // 옛 호출 호환: createPipeline(13) → { track_id: 13 }
    if (typeof payload === 'number') payload = { track_id: payload };
    return apiPost('/api/music/pipeline', payload);
};
  • Step 2: CompileTab.jsx — "🎬 영상 만들기" 버튼

CompileTab.jsx의 완료된 job 카드 액션 영역에 추가:

import { createPipeline, startPipeline } from '../../../api';

// 컴포넌트 내부 핸들러:
const handleVideoFromCompile = async (jobId) => {
    if (!confirm('이 mix로 영상 파이프라인을 시작할까요?')) return;
    try {
        const p = await createPipeline({ compile_job_id: jobId });
        await startPipeline(p.id);
        // YoutubeTab > 진행 서브탭으로 이동 (props onSwitchToPipeline 활용)
        if (props.onSwitchToPipeline) {
            props.onSwitchToPipeline(p.id);
        }
    } catch (e) {
        alert(`파이프라인 시작 실패: ${e.message || e}`);
    }
};

// 카드 액션 영역 (job.status === 'succeeded' 분기):
{job.status === 'succeeded' && (
    <button onClick={() => handleVideoFromCompile(job.id)} className="cmp-btn-video">
        🎬 영상 만들기
    </button>
)}

props.onSwitchToPipeline prop 받아옴 — CompileTab({ library, onSwitchToPipeline }).

  • Step 3: MusicStudio.jsx에서 onSwitchToPipeline prop 연결

기존 <YoutubeTab>에 prop 전달:

const handleVideoFromCompile = (pipelineId) => {
    setTab('youtube');
    setOpenPipelineFor(pipelineId);  // 진행 서브탭 자동 전환
};

// YoutubeTab에서 CompileTab으로 onSwitchToPipeline prop 전달

YoutubeTab.jsx 수정 — CompileTab에 prop 전달:

{subtab === 'compile' && <CompileTab library={library} onSwitchToPipeline={(pid) => {
    setSubtab('pipeline');
    // openPipelineFor 갱신해 PipelineTab이 해당 pipeline 표시
}} />}
  • Step 4: Build + manual verify
cd web-ui && npm run build 2>&1 | tail -5

빌드 성공 확인.

  • Step 5: Commit (DO NOT PUSH)
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/api.js \
                                                   src/pages/music/components/CompileTab.jsx \
                                                   src/pages/music/components/YoutubeTab.jsx \
                                                   src/pages/music/MusicStudio.jsx
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(web-ui): CompileTab에 영상 만들기 버튼 + createPipeline payload 시그니처"

Task 13: PipelineStartModal — Mix 라디오 + 고급 옵션

Files:

  • Modify: web-ui/src/pages/music/components/PipelineStartModal.jsx

  • Step 1: 모달 개편

import { useState, useEffect } from 'react';
import { createPipeline, startPipeline, getCompileJobs } from '../../../api';

const fmtDur = (s) => {
    const m = Math.floor(s / 60);
    const sec = Math.round(s % 60);
    return `${m}:${String(sec).padStart(2, '0')}`;
};

export default function PipelineStartModal({ library, initialTrackId, onClose, onCreated }) {
    const [inputType, setInputType] = useState('track');  // 'track' | 'compile'
    const [tid, setTid] = useState(initialTrackId || library?.[0]?.id || '');
    const [cid, setCid] = useState('');
    const [compileJobs, setCompileJobs] = useState([]);
    const [advanced, setAdvanced] = useState(false);
    const [visualStyle, setVisualStyle] = useState('');  // '' = use default
    const [bgMode, setBgMode] = useState('');
    const [bgKeyword, setBgKeyword] = useState('');
    const [error, setError] = useState('');

    useEffect(() => {
        if (inputType === 'compile') {
            getCompileJobs().then(r => {
                const completed = (r.jobs || []).filter(j => j.status === 'succeeded');
                setCompileJobs(completed);
                if (completed.length && !cid) setCid(completed[0].id);
            }).catch(e => setError(String(e)));
        }
    }, [inputType]);

    const submit = async () => {
        try {
            const payload = {};
            if (inputType === 'track') payload.track_id = parseInt(tid);
            else payload.compile_job_id = parseInt(cid);
            if (visualStyle) payload.visual_style = visualStyle;
            if (bgMode) payload.background_mode = bgMode;
            if (bgKeyword) payload.background_keyword = bgKeyword;

            const p = await createPipeline(payload);
            await startPipeline(p.id);
            onCreated(p);
        } catch (e) { setError(e.message || String(e)); }
    };

    return (
        <div className="modal-overlay" onClick={onClose}>
            <div className="modal-body" onClick={e => e.stopPropagation()}>
                <h3> 파이프라인 시작</h3>

                <fieldset className="psm-input-radio">
                    <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 value={tid} onChange={e => setTid(e.target.value)}>
                        {(library || []).map(t => (
                            <option key={t.id} value={t.id}>{t.title} ({t.genre})</option>
                        ))}
                    </select>
                ) : (
                    <select value={cid} onChange={e => setCid(e.target.value)}>
                        {compileJobs.length === 0 && <option value="">완료된 Mix 없음</option>}
                        {compileJobs.map(j => (
                            <option key={j.id} value={j.id}>
                                {j.title || `Mix #${j.id}`} ({fmtDur(j.duration_sec || 0)}, {j.tracks_count || j.track_ids?.length || '?'})
                            </option>
                        ))}
                    </select>
                )}

                <details className="psm-advanced" open={advanced}>
                    <summary onClick={(e) => { e.preventDefault(); setAdvanced(!advanced); }}>
                        고급 옵션
                    </summary>
                    <label>시각 스타일
                        <select value={visualStyle} onChange={e => setVisualStyle(e.target.value)}>
                            <option value="">기본 (구성  default)</option>
                            <option value="essential">essential (배경 + 중앙 비주얼)</option>
                            <option value="single">single (커버 + 가장자리 파형)</option>
                        </select>
                    </label>
                    <label>배경 모드
                        <select value={bgMode} onChange={e => setBgMode(e.target.value)}>
                            <option value="">기본 (구성  default)</option>
                            <option value="static">정적 사진</option>
                            <option value="video_loop">영상 루프 (Pexels)</option>
                        </select>
                    </label>
                    <label>배경 키워드 (Pexels 검색용)
                        <input value={bgKeyword} onChange={e => setBgKeyword(e.target.value)}
                            placeholder="rainy window, lofi cafe ..." />
                    </label>
                </details>

                {error && <div className="ms-error">{error}</div>}
                <div className="modal-actions">
                    <button onClick={onClose}>취소</button>
                    <button className="button primary" onClick={submit}
                        disabled={inputType === 'compile' && !cid}>
                        시작
                    </button>
                </div>
            </div>
        </div>
    );
}
  • Step 2: api.js — getCompileJobs 헬퍼 (기존에 있는지 확인)

api.js에 이미 있으면 skip. 없으면 추가:

export const getCompileJobs = () => apiGet('/api/music/compile/jobs');
// (실제 endpoint 경로는 기존 CompileTab이 호출하는 것 그대로)
  • Step 3: Build + verify

npm run build 깔끔히 통과.

  • Step 4: Commit
git add src/pages/music/components/PipelineStartModal.jsx src/api.js
git commit -m "feat(web-ui): PipelineStartModal Mix 입력 라디오 + 고급 옵션"

Task 14: PipelineCard — mini 미리보기 + 클릭 → 상세 모달

Files:

  • Modify: web-ui/src/pages/music/components/PipelineCard.jsx

  • Create: web-ui/src/pages/music/components/PipelineDetailModal.jsx

  • Step 1: PipelineDetailModal.jsx (신규)

const fmtTimestamp = (sec) => {
    if (sec == null) return '';
    const m = Math.floor(sec / 60);
    const s = Math.round(sec % 60);
    const h = Math.floor(m / 60);
    if (h) return `${h}:${String(m % 60).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
    return `${m}:${String(s).padStart(2,'0')}`;
};

const fmtDuration = fmtTimestamp;

export default function PipelineDetailModal({ pipeline, onClose }) {
    if (!pipeline) return null;
    const meta = pipeline.metadata || {};
    const review = pipeline.review || {};

    return (
        <div className="modal-overlay" onClick={onClose}>
            <div className="modal-body modal-body--lg" onClick={e => e.stopPropagation()}>
                <header className="pdm-header">
                    <h3>{pipeline.compile_title || pipeline.track_title || `Pipeline #${pipeline.id}`}</h3>
                    <span className="pdm-badge">{pipeline.visual_style || 'essential'}</span>
                    <button onClick={onClose} className="pdm-close">×</button>
                </header>

                <div className="pdm-grid">
                    {pipeline.cover_url && (
                        <figure className="pdm-figure">
                            <img src={pipeline.cover_url} alt="cover" />
                            <figcaption>커버 (배경)</figcaption>
                        </figure>
                    )}
                    {pipeline.thumbnail_url && (
                        <figure className="pdm-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 preload="metadata" width="100%" />
                    </div>
                )}

                {meta.title && (
                    <section className="pdm-section">
                        <h4>메타데이터</h4>
                        <p><strong>제목:</strong> {meta.title}</p>
                        <details>
                            <summary>설명 ({(meta.description || '').length})</summary>
                            <pre className="pdm-pre">{meta.description}</pre>
                        </details>
                        <p><strong>태그:</strong> {(meta.tags || []).join(', ')}</p>
                    </section>
                )}

                {review.weighted_total != null && (
                    <section className="pdm-section">
                        <h4>AI 검토
                            <span className={`pdm-verdict pdm-verdict--${review.verdict}`}>{review.verdict}</span>
                            <span className="pdm-score">({review.weighted_total}/100)</span>
                        </h4>
                        <table className="pdm-review-table">
                            <tbody>
                                <tr><td>메타데이터 품질</td><td>{review.metadata_quality?.score}</td></tr>
                                <tr><td>콘텐츠 정책</td><td>{review.policy_compliance?.score}</td></tr>
                                <tr><td>시청 경험</td><td>{review.viewer_experience?.score}</td></tr>
                                <tr><td>트렌드 정렬</td><td>{review.trend_alignment?.score}</td></tr>
                            </tbody>
                        </table>
                        {review.summary && <p className="pdm-summary"><em>{review.summary}</em></p>}
                    </section>
                )}

                {pipeline.tracks && pipeline.tracks.length > 1 && (
                    <section className="pdm-section">
                        <h4>트랙 리스트 ({pipeline.tracks.length})</h4>
                        <ol className="pdm-tracks">
                            {pipeline.tracks.map(t => (
                                <li key={t.id}>
                                    <span className="pdm-track-time">[{fmtTimestamp(t.start_offset_sec)}]</span>
                                    {' '}{t.title}
                                    <span className="pdm-track-dur"> ({fmtDuration(t.duration_sec)})</span>
                                </li>
                            ))}
                        </ol>
                    </section>
                )}

                {pipeline.feedback && pipeline.feedback.length > 0 && (
                    <section className="pdm-section">
                        <h4>피드백 히스토리 ({pipeline.feedback.length})</h4>
                        <ul className="pdm-feedback">
                            {pipeline.feedback.map(f => (
                                <li key={f.id}>
                                    <code>[{f.step}]</code> {f.feedback_text}
                                    <small> {f.received_at?.replace('T', ' ')}</small>
                                </li>
                            ))}
                        </ul>
                    </section>
                )}

                {pipeline.youtube_video_id && (
                    <a href={`https://youtu.be/${pipeline.youtube_video_id}`}
                       target="_blank" rel="noreferrer" className="pdm-youtube">
                        🎬 YouTube에서 보기
                    </a>
                )}
            </div>
        </div>
    );
}
  • Step 2: PipelineCard.jsx — 미리보기 + 클릭 핸들러
import { useState } from 'react';
import { cancelPipeline, publishPipeline } from '../../../api';
import PipelineDetailModal from './PipelineDetailModal';

const STEP_LABELS = ['커버','영상','썸네','메타','검토','발행'];

function stepIndex(state) {
    if (!state) return -1;
    if (state.startsWith('cover'))   return 0;
    if (state.startsWith('video'))   return 1;
    if (state.startsWith('thumb'))   return 2;
    if (state.startsWith('meta'))    return 3;
    if (state.startsWith('ai_review') || state === 'publish_pending') return 4;
    if (state.startsWith('publish')) return 5;
    if (state === 'published')       return 6;
    return -1;
}

export default function PipelineCard({ pipeline, onChanged }) {
    const [showDetail, setShowDetail] = useState(false);
    const i = stepIndex(pipeline.state);
    const title = pipeline.compile_title || pipeline.track_title || `Pipeline #${pipeline.id}`;

    const handleCardClick = (e) => {
        // 버튼/링크 클릭은 무시
        if (e.target.closest('button') || e.target.closest('a')) return;
        setShowDetail(true);
    };

    return (
        <>
            <div className="pipeline-card" onClick={handleCardClick}>
                <div className="pipeline-card__head">
                    <h4>{title}</h4>
                    {pipeline.visual_style && (
                        <span className="pipeline-style-badge">{pipeline.visual_style}</span>
                    )}
                    {!['published','cancelled','failed'].includes(pipeline.state) && (
                        <button onClick={async () => { await cancelPipeline(pipeline.id); onChanged(); }}>
                            취소
                        </button>
                    )}
                </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 className="pipeline-progress">
                    {STEP_LABELS.map((lbl, idx) => (
                        <div key={lbl}
                            className={`pipeline-dot ${idx <= i ? 'is-done' : ''} ${idx === i ? 'is-current' : ''}`}>
                            <span>{lbl}</span>
                        </div>
                    ))}
                </div>

                <div className="pipeline-state">현재: {pipeline.state}</div>

                {pipeline.review && (
                    <div className="pipeline-review">
                        AI 검토: <strong>{pipeline.review.verdict}</strong>
                        ({pipeline.review.weighted_total}/100)
                    </div>
                )}

                {pipeline.state === 'publish_pending' && (
                    <button className="button primary"
                        onClick={async () => { await publishPipeline(pipeline.id); onChanged(); }}>
                        YouTube 업로드
                    </button>
                )}

                {pipeline.youtube_video_id && (
                    <a href={`https://youtu.be/${pipeline.youtube_video_id}`}
                       target="_blank" rel="noreferrer">
                        유튜브에서 보기
                    </a>
                )}
            </div>

            {showDetail && <PipelineDetailModal pipeline={pipeline} onClose={() => setShowDetail(false)} />}
        </>
    );
}
  • Step 3: CSS 추가 (MusicStudio.css append)
/* === Pipeline Detail Modal === */
.modal-body--lg { max-width: 720px; max-height: 90vh; overflow-y: auto; }
.pdm-header { display:flex; align-items:center; gap:12px; margin-bottom:16px; }
.pdm-header h3 { flex:1; margin:0; }
.pdm-badge { padding:2px 8px; background:rgba(56,189,248,.2); color:#bae6fd; border-radius:6px; font-size:11px; }
.pdm-close { background:none; border:none; font-size:24px; cursor:pointer; color:var(--ms-muted); }

.pdm-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:16px; }
.pdm-figure { margin:0; }
.pdm-figure img { width:100%; border-radius:8px; }
.pdm-figure figcaption { font-size:11px; color:var(--ms-muted); text-align:center; margin-top:4px; }

.pdm-video { margin-bottom:16px; }
.pdm-video video { border-radius:8px; }

.pdm-section { margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--ms-line, #2a2a3a); }
.pdm-section h4 { margin:0 0 8px; font-size:14px; }
.pdm-pre { background:rgba(0,0,0,.3); padding:8px; border-radius:6px; font-size:12px; white-space:pre-wrap; overflow-x:auto; }

.pdm-verdict { padding:2px 8px; margin-left:8px; border-radius:6px; font-size:12px; font-weight:bold; }
.pdm-verdict--pass { background:rgba(34,197,94,.2); color:#86efac; }
.pdm-verdict--fail { background:rgba(248,113,113,.2); color:#fca5a5; }
.pdm-score { color:var(--ms-muted); font-size:12px; margin-left:8px; }
.pdm-review-table { width:100%; border-collapse:collapse; font-size:13px; }
.pdm-review-table td { padding:4px 8px; border-bottom:1px solid var(--ms-line, #2a2a3a); }
.pdm-review-table td:nth-child(2) { text-align:right; font-weight:bold; }
.pdm-summary { font-size:12px; color:var(--ms-muted); margin-top:8px; }

.pdm-tracks { padding-left:24px; }
.pdm-tracks li { margin-bottom:4px; font-size:13px; }
.pdm-track-time { color:var(--ms-accent, #38bdf8); font-family: var(--ms-ff-mono, monospace); }
.pdm-track-dur { color:var(--ms-muted); font-size:11px; }

.pdm-feedback { padding-left:0; list-style:none; }
.pdm-feedback li { padding:6px 8px; background:rgba(0,0,0,.2); border-radius:6px; margin-bottom:4px; font-size:12px; }
.pdm-feedback code { color:#fb923c; font-size:11px; }
.pdm-feedback small { display:block; color:var(--ms-muted); margin-top:2px; }

.pdm-youtube { display:inline-block; padding:8px 16px; background:#ff0000; color:white;
    border-radius:8px; text-decoration:none; font-weight:bold; }

/* PipelineCard mini previews */
.pipeline-previews { display:flex; gap:8px; margin:8px 0; align-items:center; }
.pipeline-preview-mini { width:64px; height:64px; object-fit:cover; border-radius:6px;
    border:1px solid var(--ms-line, #2a2a3a); }
.pipeline-video-icon { font-size:24px; color:var(--ms-accent, #38bdf8); margin-left:4px; }
.pipeline-style-badge { padding:1px 6px; background:rgba(56,189,248,.15); color:#bae6fd;
    border-radius:4px; font-size:10px; }
  • Step 4: Build verify

npm run build 통과.

  • Step 5: Commit
git add src/pages/music/components/PipelineCard.jsx \
        src/pages/music/components/PipelineDetailModal.jsx \
        src/pages/music/MusicStudio.css
git commit -m "feat(web-ui): PipelineDetailModal + 카드 mini 미리보기"

Task 15: SetupTab — visual_defaults 5개 옵션 확장

Files:

  • Modify: web-ui/src/pages/music/components/SetupTab.jsx

  • Step 1: 영상 비주얼 카드 확장

기존 setup-card 중 "영상 비주얼 기본값"을 다음으로 교체:

<section className="setup-card">
    <h3>영상 비주얼 기본값</h3>

    <label>해상도
        <select value={setup.visual_defaults.resolution || '1920x1080'}
            onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, resolution: e.target.value}}))}>
            <option value="1920x1080">1920×1080 (가로)</option>
            <option value="1080x1920">1080×1920 (세로/Shorts)</option>
        </select>
    </label>

    <label>기본 시각 스타일
        <select value={setup.visual_defaults.default_visual_style || 'essential'}
            onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, default_visual_style: e.target.value}}))}>
            <option value="essential">essential (배경 + 중앙 비주얼)</option>
            <option value="single">single (커버 + 가장자리 파형)</option>
        </select>
    </label>

    <label>기본 배경 모드
        <select value={setup.visual_defaults.default_background_mode || 'static'}
            onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, default_background_mode: e.target.value}}))}>
            <option value="static">정적 사진</option>
            <option value="video_loop">영상 루프 (Pexels)</option>
        </select>
    </label>

    <label>기본 배경 키워드 (비우면 장르 기반 자동)
        <input value={setup.visual_defaults.default_background_keyword || ''}
            onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, default_background_keyword: e.target.value}}))}
            placeholder="lofi cafe, rainy window, mountain ..." />
    </label>

    <label>배경 이미지 소스 (정적 모드)
        <select value={setup.visual_defaults.background_image_source || 'ai'}
            onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, background_image_source: e.target.value}}))}>
            <option value="ai">AI 생성 (DALL·E)</option>
            <option value="pexels">Pexels 스톡 사진</option>
        </select>
    </label>

    <label>
        <input type="checkbox"
            checked={setup.visual_defaults.subtitle_track_titles ?? true}
            onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, subtitle_track_titles: e.target.checked}}))}/>
        Mix에서 곡명 자막 표시 (트랙 시작  5)
    </label>

    <button onClick={() => save({ visual_defaults: setup.visual_defaults })}>저장</button>
</section>
  • Step 2: Build verify + commit
npm run build
git add src/pages/music/components/SetupTab.jsx
git commit -m "feat(web-ui): SetupTab visual_defaults 6개 옵션 확장"

Task 16: 프론트 푸시 + 배포

  • Step 1: 모든 web-ui commit 한 번에 push
git -C C:/Users/jaeoh/Desktop/workspace/web-ui push origin main
  • Step 2: NAS에 빌드 + robocopy

로컬에서:

cd C:/Users/jaeoh/Desktop/workspace/web-ui
npm run release:nas
  • Step 3: 캐시 무력화 — 브라우저 강력 새로고침

Ctrl+Shift+R.


Task 17: 수동 E2E 검증 체크리스트

  • 단일 트랙 essential 영상

    • 진행 탭 → "+ 새 파이프라인" → 단일 트랙 라디오 → 트랙 선택 → 시작
    • 카드에 cover 미니 썸네일 표시 ✓
    • 카드 클릭 → 상세 모달 cover 큰 이미지 표시 ✓
    • 텔레그램 "승인" → 영상 단계
    • 영상 단계 후 카드에 video ▶ 아이콘 표시
    • 상세 모달의 <video controls> 재생 가능
    • essential 시각 스타일: 풀스크린 cover + 중앙 막대 + ring 데코 확인
    • YouTube에 비공개 업로드 ✓
  • Mix essential 영상 (1시간+)

    • 컴파일 탭 → 트랙 510개 + crossfade 3s → 컴파일 (1시간+)
    • 완료된 컴파일 카드의 "🎬 영상 만들기" 클릭
    • 진행 탭 자동 이동 + 새 파이프라인 카드
    • cover 단계: 컴파일 mix는 genre="mix" → cover_prompts의 "mix" 또는 "default" 사용
    • 영상 단계: ~3-5분 NVENC 인코딩
    • 메타데이터 단계: description에 챕터 ([00:00] T1) 자동 포함 ✓
    • 발행 후 YouTube에서 챕터 마커 자동 인식 ✓
    • 상세 모달에 트랙 리스트 표시 ✓
  • video_loop 모드

    • 구성 탭 default_background_mode = video_loop, keyword = rainy window cafe 저장
    • 또는 파이프라인 시작 모달의 고급 옵션에서 override
    • 새 파이프라인 시작 → cover 단계에서 Pexels 영상 다운로드 (loop.mp4)
    • 영상 단계: 루프 영상 무한 반복 + 중앙 비주얼라이저
    • YouTube 발행 후 시각 확인
  • AI 검토 verdict 표시

    • 상세 모달에 4축 점수 표 + verdict 배지 (pass: 초록, fail: 빨강)
    • summary 텍스트 표시
  • 반려 + 피드백 흐름

    • 어느 단계에서 텔레그램 "반려, 더 어둡게" 답장
    • 카드의 진행도가 같은 단계에 머무르고 재생성
    • 새 알림 도착 (feedback_count++ 덕분)
    • 상세 모달의 피드백 히스토리에 새 항목 추가

Self-Review

Spec coverage:

  • §3 사용자 흐름 → Task 12 (Compile 버튼), 13 (모달), 14 (카드/모달), 17 (E2E)
  • §4 데이터 모델 → Task 1
  • §5 API 변경 → Task 7
  • §6 NAS 백엔드 → Task 2-7
  • §7 Windows 백엔드 → Task 8-9
  • §8 프론트엔드 → Task 12-15
  • §9 환경변수 → 변경 없음 (기존 PEXELS_API_KEY 재활용)
  • §10 에러 처리 → Task 7 (XOR 검증), Task 3-4 (Pexels 폴백)
  • §11 테스트 → Task 1-10 단위, Task 17 E2E
  • §12 마이그레이션 → Task 1 (idempotent ALTER), Task 8 (visualizer_ring 자동 생성)

Placeholder scan: 없음.

Type consistency:

  • _resolve_input 반환 dict 키: audio_path, duration_sec, tracks, title, genre, moods → Task 2 정의, Task 4-5 사용 일치
  • tracks 항목: id, title, start_offset_sec, duration_sec → Task 2/5/8 일치
  • EncodeError(stage, message) Windows측 일관 (이전 task에서 정의됨)
  • pipeline.compile_title / pipeline.track_title 백엔드 응답 필드 → Task 1 (db.get_pipeline JOIN), Task 14 (UI 표시) 일치 — note: db.py JOIN에서 compile_title도 추가해야 함

Spec gap 발견: db._parse_pipeline_row가 video_pipelines + music_library JOIN만 함. compile_title도 JOIN으로 같이 가져와야 PipelineCard/Modal에서 사용 가능. Task 1에 추가:

→ Task 1 Step 3에 추가: _parse_pipeline_rowcompile_title 컬럼 인식하게 수정. 마찬가지로 get_pipeline / list_pipelines 쿼리에 compile_jobs LEFT JOIN 추가.

Type consistency check 끝. 위 gap은 spec에 명시되어 있지만 실제 마이그레이션 task의 step 안에 명시 안 됐음. 보완:

Task 1 Step 3 마지막에 다음 추가:

# get_pipeline 쿼리 확장 — compile_title도 JOIN으로 가져오기
def get_pipeline(pid: int) -> dict | None:
    with _conn() as conn:
        row = conn.execute("""
            SELECT vp.*, ml.title AS track_title, cj.title AS compile_title
            FROM video_pipelines vp
            LEFT JOIN music_library ml ON ml.id = vp.track_id
            LEFT JOIN compile_jobs cj ON cj.id = vp.compile_job_id
            WHERE vp.id = ?
        """, (pid,)).fetchone()
    if not row:
        return None
    return _parse_pipeline_row(row)


def list_pipelines(active_only: bool = False) -> list[dict]:
    sql = """
        SELECT vp.*, ml.title AS track_title, cj.title AS compile_title
        FROM video_pipelines vp
        LEFT JOIN music_library ml ON ml.id = vp.track_id
        LEFT JOIN compile_jobs cj ON cj.id = vp.compile_job_id
    """
    if active_only:
        sql += " WHERE vp.state NOT IN ('published','cancelled','failed','awaiting_manual')"
    sql += " ORDER BY vp.created_at DESC"
    with _conn() as conn:
        rows = conn.execute(sql).fetchall()
    return [_parse_pipeline_row(r) for r in rows]

_parse_pipeline_row는 dict(row)이므로 추가 컬럼 자동 포함.

추가 테스트도 Task 1에 포함:

def test_pipeline_response_includes_compile_title(fresh_db):
    # compile_jobs 1행 + pipeline 1행 (compile_job_id=1)
    import sqlite3
    conn = sqlite3.connect(db.DB_PATH)
    cur = conn.cursor()
    cur.execute("""CREATE TABLE IF NOT EXISTS compile_jobs (
        id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, status TEXT,
        track_ids_json TEXT, crossfade_sec INTEGER, audio_path TEXT, created_at TEXT)""")
    cur.execute("INSERT INTO compile_jobs (id, title, status) VALUES (1, 'My Mix', 'succeeded')")
    conn.commit()
    conn.close()
    pid = db.create_pipeline(compile_job_id=1)
    p = db.get_pipeline(pid)
    assert p["compile_title"] == "My Mix"

Plan complete. 17 task로 분해 — 각 task TDD + commit. Subagent-driven 실행 권장.