Files
web-page-backend/docs/superpowers/plans/2026-05-10-batch-music-generation.md

29 KiB

Batch Music Generation — Implementation Plan

For agentic workers: Use superpowers:subagent-driven-development. Steps use - [ ] checkboxes.

Goal: 장르 1개로 N(1-10) 트랙 Suno 자동 순차 생성 + 자동 컴파일 + 영상 파이프라인 자동 시작.

Architecture: music-lab 신규 batch_generator 모듈이 BackgroundTask로 N회 Suno 호출 → compile_job 자동 생성 → orchestrator.run_step("cover") 자동 호출.

Spec: docs/superpowers/specs/2026-05-10-batch-music-generation-design.md


File Structure

경로 책임
music-lab/app/db.py (modify) music_batch_jobs 테이블 + 5 헬퍼
music-lab/app/random_pools.py (new) 장르별 mood/instr/BPM/key/scale 랜덤 풀 + randomize()
music-lab/app/batch_generator.py (new) run_batch(batch_id) 순차 오케스트레이션
music-lab/app/main.py (modify) 3개 endpoint (POST /generate-batch, GET /:id, GET 목록)
web-ui/src/api.js (modify) 3개 헬퍼
web-ui/src/pages/music/components/BatchProgress.jsx (new) 진행 표시 컴포넌트
web-ui/src/pages/music/MusicStudio.jsx (modify) Create 탭에 배치 섹션 + 폴링
web-ui/src/pages/music/MusicStudio.css (modify) 배치 섹션 스타일

Task 1: DB 테이블 + 헬퍼 + random_pools

Files:

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

  • Create: music-lab/app/random_pools.py

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

  • Step 1: random_pools.py 작성

"""장르별 음악 파라미터 랜덤 풀."""
import random

POOLS = {
    "lo-fi": {
        "moods": ["chill", "relaxing", "dreamy", "melancholic", "mellow", "nostalgic", "peaceful"],
        "instruments_pool": ["piano", "synth", "drums", "vinyl", "rhodes", "soft bass", "ambient pads"],
        "instruments_count": (3, 4),
        "bpm": (70, 90),
        "keys": ["C", "D", "F", "G", "A"],
        "scales": ["minor", "major"],
        "prompt_modifiers": ["cozy bedroom vibes", "rainy night", "late night study", "cafe ambience"],
    },
    "phonk": {
        "moods": ["dark", "aggressive", "moody", "intense", "hypnotic"],
        "instruments_pool": ["808 bass", "hi-hat", "synth lead", "vocal chops", "bass drops", "trap drums"],
        "instruments_count": (3, 4),
        "bpm": (130, 160),
        "keys": ["C", "D", "F", "G"],
        "scales": ["minor"],
        "prompt_modifiers": ["drift atmosphere", "dark neon", "midnight drive"],
    },
    "ambient": {
        "moods": ["peaceful", "meditative", "ethereal", "spacious", "dreamy"],
        "instruments_pool": ["pad synths", "atmospheric guitar", "soft strings", "field recordings", "drone bass"],
        "instruments_count": (2, 3),
        "bpm": (50, 75),
        "keys": ["C", "D", "E", "G", "A"],
        "scales": ["major", "minor"],
        "prompt_modifiers": ["misty mountain morning", "deep space", "still water", "forest dawn"],
    },
    "pop": {
        "moods": ["uplifting", "happy", "energetic", "romantic", "catchy"],
        "instruments_pool": ["acoustic guitar", "piano", "drums", "bass", "synth", "vocals harmonies"],
        "instruments_count": (3, 5),
        "bpm": (95, 130),
        "keys": ["C", "D", "E", "F", "G", "A"],
        "scales": ["major"],
        "prompt_modifiers": ["radio-ready", "summer vibe", "feel-good"],
    },
    "default": {
        "moods": ["chill", "relaxing", "uplifting", "mellow"],
        "instruments_pool": ["piano", "synth", "drums", "guitar", "bass", "strings"],
        "instruments_count": (3, 4),
        "bpm": (80, 110),
        "keys": ["C", "D", "F", "G", "A"],
        "scales": ["minor", "major"],
        "prompt_modifiers": [""],
    },
}


def randomize(genre: str, rng=None) -> dict:
    rng = rng or random.Random()
    pool = POOLS.get(genre.lower(), POOLS["default"])
    n_instr = rng.randint(*pool["instruments_count"])
    instruments = rng.sample(pool["instruments_pool"], min(n_instr, len(pool["instruments_pool"])))
    return {
        "moods": [rng.choice(pool["moods"])],
        "instruments": instruments,
        "bpm": rng.randint(*pool["bpm"]),
        "key": rng.choice(pool["keys"]),
        "scale": rng.choice(pool["scales"]),
        "prompt_modifier": rng.choice(pool["prompt_modifiers"]),
    }
  • Step 2: DB 테이블 + 헬퍼 추가 (db.py)

init_db()에 추가:

cursor.execute("""
    CREATE TABLE IF NOT EXISTS music_batch_jobs (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        genre TEXT NOT NULL,
        count INTEGER NOT NULL,
        target_duration_sec INTEGER NOT NULL DEFAULT 180,
        auto_pipeline INTEGER NOT NULL DEFAULT 1,
        completed INTEGER NOT NULL DEFAULT 0,
        track_ids_json TEXT NOT NULL DEFAULT '[]',
        current_track_index INTEGER NOT NULL DEFAULT 0,
        current_track_status TEXT,
        status TEXT NOT NULL DEFAULT 'queued',
        error TEXT,
        compile_job_id INTEGER,
        pipeline_id INTEGER,
        created_at TEXT NOT NULL,
        updated_at TEXT NOT NULL
    )
""")

db.py 끝에 헬퍼:

_BATCH_ALLOWED_COLS = frozenset([
    "completed", "track_ids_json", "current_track_index",
    "current_track_status", "status", "error",
    "compile_job_id", "pipeline_id",
])


def create_batch_job(genre: str, count: int, target_duration_sec: int = 180,
                     auto_pipeline: bool = True) -> int:
    with _conn() as conn:
        now = _now()
        cur = conn.cursor()
        cur.execute("""
            INSERT INTO music_batch_jobs
              (genre, count, target_duration_sec, auto_pipeline,
               status, created_at, updated_at)
            VALUES (?, ?, ?, ?, 'queued', ?, ?)
        """, (genre, count, target_duration_sec, 1 if auto_pipeline else 0, now, now))
        return cur.lastrowid


def get_batch_job(batch_id: int) -> dict | None:
    with _conn() as conn:
        row = conn.execute(
            "SELECT * FROM music_batch_jobs WHERE id = ?", (batch_id,)
        ).fetchone()
    if not row:
        return None
    d = dict(row)
    d["track_ids"] = json.loads(d.get("track_ids_json") or "[]")
    return d


def update_batch_job(batch_id: int, **fields) -> None:
    unknown = set(fields) - _BATCH_ALLOWED_COLS
    if unknown:
        raise ValueError(f"unknown batch job columns: {unknown}")
    cols = ", ".join(f"{k} = ?" for k in fields)
    vals = list(fields.values()) + [_now(), batch_id]
    with _conn() as conn:
        conn.execute(
            f"UPDATE music_batch_jobs SET {cols}, updated_at = ? WHERE id = ?",
            vals,
        )


def append_batch_track(batch_id: int, track_id: int) -> None:
    """track_ids_json에 새 track_id 추가 + completed += 1 (atomic)."""
    with _conn() as conn:
        row = conn.execute(
            "SELECT track_ids_json, completed FROM music_batch_jobs WHERE id = ?",
            (batch_id,),
        ).fetchone()
        if not row:
            return
        ids = json.loads(row["track_ids_json"] or "[]")
        ids.append(track_id)
        conn.execute(
            "UPDATE music_batch_jobs SET track_ids_json = ?, completed = ?, updated_at = ? WHERE id = ?",
            (json.dumps(ids), row["completed"] + 1, _now(), batch_id),
        )


def list_batch_jobs(active_only: bool = False) -> list[dict]:
    sql = "SELECT * FROM music_batch_jobs"
    if active_only:
        sql += " WHERE status NOT IN ('failed','cancelled','piped')"
    sql += " ORDER BY created_at DESC"
    with _conn() as conn:
        rows = conn.execute(sql).fetchall()
    out = []
    for r in rows:
        d = dict(r)
        d["track_ids"] = json.loads(d.get("track_ids_json") or "[]")
        out.append(d)
    return out
  • Step 3: Test 작성
# tests/test_batch_db.py
import pytest
from app import db


@pytest.fixture
def fresh_db(monkeypatch, tmp_path):
    monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "music.db"))
    db.init_db()
    return db


def test_create_batch_job(fresh_db):
    bid = db.create_batch_job(genre="lo-fi", count=10)
    j = db.get_batch_job(bid)
    assert j["genre"] == "lo-fi"
    assert j["count"] == 10
    assert j["status"] == "queued"
    assert j["track_ids"] == []
    assert j["auto_pipeline"] == 1


def test_update_batch_job(fresh_db):
    bid = db.create_batch_job(genre="phonk", count=5)
    db.update_batch_job(bid, status="generating", current_track_index=2)
    j = db.get_batch_job(bid)
    assert j["status"] == "generating"
    assert j["current_track_index"] == 2


def test_update_batch_rejects_unknown_col(fresh_db):
    bid = db.create_batch_job(genre="lo-fi", count=1)
    with pytest.raises(ValueError):
        db.update_batch_job(bid, evil_col="x")


def test_append_batch_track(fresh_db):
    bid = db.create_batch_job(genre="lo-fi", count=3)
    db.append_batch_track(bid, 101)
    db.append_batch_track(bid, 102)
    j = db.get_batch_job(bid)
    assert j["track_ids"] == [101, 102]
    assert j["completed"] == 2


def test_list_batch_jobs_active_filter(fresh_db):
    b1 = db.create_batch_job(genre="lo-fi", count=1)
    b2 = db.create_batch_job(genre="phonk", count=1)
    db.update_batch_job(b1, status="failed")
    actives = db.list_batch_jobs(active_only=True)
    assert all(j["status"] not in ("failed",) for j in actives)
    assert any(j["id"] == b2 for j in actives)
    assert not any(j["id"] == b1 for j in actives)


def test_random_pools_randomize():
    from app.random_pools import randomize, POOLS
    import random
    rng = random.Random(42)
    result = randomize("lo-fi", rng)
    assert result["bpm"] in range(70, 91)
    assert result["key"] in POOLS["lo-fi"]["keys"]
    assert result["scale"] in POOLS["lo-fi"]["scales"]
    assert len(result["moods"]) == 1
    assert result["moods"][0] in POOLS["lo-fi"]["moods"]
    assert 3 <= len(result["instruments"]) <= 4


def test_random_pools_unknown_genre_uses_default():
    from app.random_pools import randomize, POOLS
    import random
    result = randomize("nonexistent", random.Random(0))
    assert result["bpm"] in range(80, 111)  # default range
  • Step 4: Run + commit
cd music-lab && python -m pytest tests/test_batch_db.py -v

Expected: 7 PASS.

git add music-lab/app/db.py music-lab/app/random_pools.py music-lab/tests/test_batch_db.py
git commit -m "feat(music-lab): music_batch_jobs 테이블 + 장르별 랜덤 풀"

Task 2: batch_generator + 3 엔드포인트

Files:

  • Create: music-lab/app/batch_generator.py

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

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

  • Step 1: batch_generator.py 작성

"""배치 음악 생성 + 자동 컴파일·영상 파이프라인."""
import asyncio
import logging

from . import db
from .random_pools import randomize

logger = logging.getLogger("music-lab.batch")

POLL_INTERVAL_S = 5
TRACK_GEN_TIMEOUT_S = 240


async def run_batch(batch_id: int) -> None:
    job = db.get_batch_job(batch_id)
    if not job:
        return
    genre = job["genre"]
    count = job["count"]
    duration = job["target_duration_sec"]
    auto_pipe = bool(job["auto_pipeline"])

    db.update_batch_job(batch_id, status="generating")

    track_ids: list[int] = []
    for i in range(1, count + 1):
        title = f"{genre.title()} Mix Track {i}"
        params = randomize(genre)
        db.update_batch_job(batch_id,
                            current_track_index=i,
                            current_track_status="generating")

        track_id = await _generate_one_track(title=title, genre=genre,
                                              duration_sec=duration,
                                              params=params)
        if track_id:
            track_ids.append(track_id)
            db.append_batch_track(batch_id, track_id)
            db.update_batch_job(batch_id, current_track_status="succeeded")
        else:
            db.update_batch_job(batch_id, current_track_status="failed")
            logger.warning("배치 %d 트랙 %d 실패 — 계속 진행", batch_id, i)

    if not track_ids:
        db.update_batch_job(batch_id, status="failed",
                            error="모든 트랙 생성 실패")
        return

    db.update_batch_job(batch_id, status="generated")

    if not auto_pipe:
        return

    # 자동 컴파일
    db.update_batch_job(batch_id, status="compiling")
    try:
        compile_id = db.create_compile_job(
            title=f"{genre.title()} Mix",
            track_ids=track_ids,
            crossfade_sec=3,
        )
        db.update_batch_job(batch_id, compile_job_id=compile_id)
    except Exception as e:
        db.update_batch_job(batch_id, status="failed", error=f"compile create: {e}")
        return

    from . import compiler
    try:
        await asyncio.to_thread(compiler.run, compile_id)
    except Exception as e:
        db.update_batch_job(batch_id, status="failed", error=f"compile run: {e}")
        return

    job_after = db.get_compile_job(compile_id)
    if not job_after or job_after.get("status") not in ("done", "succeeded"):
        db.update_batch_job(
            batch_id, status="failed",
            error=f"compile not done (status={job_after.get('status') if job_after else 'unknown'})"
        )
        return

    # 자동 영상 파이프라인
    pipeline_id = db.create_pipeline(compile_job_id=compile_id)
    db.update_batch_job(batch_id, pipeline_id=pipeline_id, status="piped")

    from .pipeline import orchestrator
    await orchestrator.run_step(pipeline_id, "cover")


async def _generate_one_track(*, title: str, genre: str, duration_sec: int,
                                params: dict) -> int | None:
    """기존 Suno generate 호출 + 완료까지 polling. 성공 시 새 track id 반환."""
    from .suno_provider import run_suno_generation
    from .db import create_task, get_task
    import uuid

    task_id = str(uuid.uuid4())
    suno_params = {
        "title": title,
        "genre": genre,
        "moods": params["moods"],
        "instruments": params["instruments"],
        "duration_sec": duration_sec,
        "bpm": params["bpm"],
        "key": params["key"],
        "scale": params["scale"],
        "prompt": params.get("prompt_modifier", ""),
    }
    create_task(task_id, suno_params, provider="suno")

    # Suno background task 직접 호출 (BackgroundTasks 미사용 — 우리가 await)
    asyncio.create_task(asyncio.to_thread(run_suno_generation, task_id, suno_params))

    # Polling
    waited = 0
    while waited < TRACK_GEN_TIMEOUT_S:
        await asyncio.sleep(POLL_INTERVAL_S)
        waited += POLL_INTERVAL_S
        task = get_task(task_id)
        if not task:
            continue
        if task.get("status") == "succeeded":
            tr = task.get("track")
            return tr.get("id") if tr else None
        if task.get("status") == "failed":
            return None
    return None  # timeout

NOTE: This assumes existing db.create_task, db.get_task, suno_provider.run_suno_generation are reusable. Read existing code to confirm function signatures, adjust if needed (especially task["track"]["id"] vs other format).

  • Step 2: main.py에 3 endpoint 추가
from app.batch_generator import run_batch as _run_batch


class BatchGenerateRequest(BaseModel):
    genre: str
    count: int = 10
    target_duration_sec: int = 180
    auto_pipeline: bool = True


@app.post("/api/music/generate-batch", status_code=201)
async def generate_batch(req: BatchGenerateRequest, bg: BackgroundTasks):
    if not (1 <= req.count <= 10):
        raise HTTPException(400, "count는 1-10 사이")
    if not (60 <= req.target_duration_sec <= 300):
        raise HTTPException(400, "target_duration_sec는 60-300 사이")
    if not req.genre:
        raise HTTPException(400, "genre 필수")
    if not SUNO_API_KEY:
        raise HTTPException(400, "SUNO_API_KEY 미설정")

    batch_id = _db_module.create_batch_job(
        genre=req.genre, count=req.count,
        target_duration_sec=req.target_duration_sec,
        auto_pipeline=req.auto_pipeline,
    )
    bg.add_task(_run_batch, batch_id)
    return _db_module.get_batch_job(batch_id)


@app.get("/api/music/generate-batch/{batch_id}")
def get_batch(batch_id: int):
    j = _db_module.get_batch_job(batch_id)
    if not j:
        raise HTTPException(404)
    # tracks 메타 LEFT JOIN (id, title, audio_url)
    if j["track_ids"]:
        ids_csv = ",".join(str(i) for i in j["track_ids"])
        # 간단한 in-Python 매핑 (sqlite IN (...))
        import sqlite3
        conn = sqlite3.connect(_db_module.DB_PATH)
        conn.row_factory = sqlite3.Row
        rows = conn.execute(
            f"SELECT id, title, audio_url, duration_sec FROM music_library WHERE id IN ({ids_csv})"
        ).fetchall()
        conn.close()
        j["tracks"] = [dict(r) for r in rows]
    else:
        j["tracks"] = []
    return j


@app.get("/api/music/generate-batch")
def list_batches(status: str = "all"):
    return {"batches": _db_module.list_batch_jobs(active_only=(status == "active"))}

(SUNO_API_KEY는 main.py에 이미 import돼있다고 가정. 없으면 _db_module 패턴처럼 처리.)

  • Step 3: 테스트 작성
# tests/test_batch_endpoints.py
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from fastapi.testclient import TestClient
from app.main import app
from app import db


@pytest.fixture
def client(monkeypatch, tmp_path):
    monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "music.db"))
    db.init_db()
    monkeypatch.setenv("SUNO_API_KEY", "test")
    return TestClient(app)


def test_create_batch_201(client):
    with patch("app.main._run_batch", new=AsyncMock()):
        r = client.post("/api/music/generate-batch",
                         json={"genre": "lo-fi", "count": 3})
    assert r.status_code == 201
    body = r.json()
    assert body["genre"] == "lo-fi"
    assert body["count"] == 3
    assert body["status"] == "queued"


def test_create_batch_rejects_count_too_high(client):
    r = client.post("/api/music/generate-batch",
                     json={"genre": "lo-fi", "count": 11})
    assert r.status_code == 400


def test_create_batch_rejects_count_zero(client):
    r = client.post("/api/music/generate-batch",
                     json={"genre": "lo-fi", "count": 0})
    assert r.status_code == 400


def test_create_batch_rejects_no_genre(client):
    r = client.post("/api/music/generate-batch", json={"count": 3})
    # Pydantic missing 필드 → 422 (FastAPI default validation)
    assert r.status_code in (400, 422)


def test_get_batch_returns_tracks(client):
    bid = db.create_batch_job(genre="lo-fi", count=2)
    db.append_batch_track(bid, 999)  # phantom track id (not in library)
    r = client.get(f"/api/music/generate-batch/{bid}")
    assert r.status_code == 200
    body = r.json()
    assert body["track_ids"] == [999]
    # tracks 배열은 비어있음 (해당 track 미존재)
    assert body["tracks"] == []


def test_list_batches(client):
    db.create_batch_job(genre="lo-fi", count=1)
    db.create_batch_job(genre="phonk", count=2)
    r = client.get("/api/music/generate-batch")
    assert len(r.json()["batches"]) == 2
  • Step 4: Run + commit + push
cd music-lab && python -m pytest tests/ -v

Expected: 모두 PASS.

git -C C:/Users/jaeoh/Desktop/workspace/web-backend add music-lab/app/batch_generator.py \
                                                       music-lab/app/main.py \
                                                       music-lab/tests/test_batch_endpoints.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(music-lab): 배치 음악 생성 endpoint + orchestrator"
git -C C:/Users/jaeoh/Desktop/workspace/web-backend push origin main

Task 3: Frontend Create 탭 배치 섹션

Files:

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

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

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

  • Modify: web-ui/src/pages/music/MusicStudio.css

  • Step 1: api.js 헬퍼

// === Batch generation ===
export const startBatchGen   = (payload)        => apiPost('/api/music/generate-batch', payload);
export const getBatchJob     = (id)             => apiGet(`/api/music/generate-batch/${id}`);
export const listBatchJobs   = (status='all')   => apiGet(`/api/music/generate-batch?status=${status}`);
  • Step 2: BatchProgress.jsx 신규
const STATUS_LABELS = {
    queued: '대기 중', generating: '음악 생성 중', generated: '음악 완료, 컴파일 대기',
    compiling: '컴파일 중', piped: '영상 파이프라인 시작됨',
    failed: '실패', cancelled: '취소',
};

export default function BatchProgress({ batch }) {
    if (!batch) return null;
    const trackList = Array.from({ length: batch.count }, (_, i) => i + 1);
    return (
        <div className="ms-batch-progress">
            <div className="ms-batch-header">
                배치 #{batch.id}  {batch.genre} ·{' '}
                {batch.completed}/{batch.count} 완료 ·{' '}
                <strong>{STATUS_LABELS[batch.status] || batch.status}</strong>
            </div>
            {batch.error && <div className="ms-error">에러: {batch.error}</div>}
            <ol className="ms-batch-tracks">
                {trackList.map(n => {
                    const completed = n <= batch.completed;
                    const current = n === batch.current_track_index && batch.status === 'generating';
                    const tr = (batch.tracks || [])[n - 1];
                    return (
                        <li key={n} className={completed ? 'done' : current ? 'current' : 'pending'}>
                            {completed ? '✓' : current ? '⏳' : '○'}
                            {' '}Track {n}: {tr?.title || (current ? '생성 중...' : '대기')}
                        </li>
                    );
                })}
            </ol>
            {batch.compile_job_id && (
                <div className="ms-batch-link">📀 컴파일 #{batch.compile_job_id}</div>
            )}
            {batch.pipeline_id && (
                <div className="ms-batch-link">
                    🎬 영상 파이프라인 #{batch.pipeline_id} 
                    {' '}<em>YouTube   진행 탭에서 확인</em>
                </div>
            )}
        </div>
    );
}
  • Step 3: MusicStudio.jsx Create 탭에 배치 섹션 추가

Create 탭 jsx 영역 (handleGenerate 근처) 위 또는 옆에:

import BatchProgress from './components/BatchProgress';
import { startBatchGen, getBatchJob } from '../../api';

// 컴포넌트 내부 state:
const [batchOpen, setBatchOpen] = useState(false);
const [batchGenre, setBatchGenre] = useState('lo-fi');
const [batchCount, setBatchCount] = useState(10);
const [batchDuration, setBatchDuration] = useState(180);
const [batchAutoPipe, setBatchAutoPipe] = useState(true);
const [currentBatch, setCurrentBatch] = useState(null);
const [batchPolling, setBatchPolling] = useState(false);
const batchPollRef = useRef(null);

const startBatch = async () => {
    try {
        const res = await startBatchGen({
            genre: batchGenre,
            count: batchCount,
            target_duration_sec: batchDuration,
            auto_pipeline: batchAutoPipe,
        });
        setCurrentBatch(res);
        setBatchPolling(true);
    } catch (e) {
        alert(`배치 시작 실패: ${e.message || e}`);
    }
};

useEffect(() => {
    if (!batchPolling || !currentBatch?.id) return;
    const tick = async () => {
        const j = await getBatchJob(currentBatch.id).catch(() => null);
        if (j) {
            setCurrentBatch(j);
            if (['piped', 'failed', 'cancelled'].includes(j.status)) {
                setBatchPolling(false);
                if (j.pipeline_id) loadLibrary?.();  // refresh library to show new tracks
            }
        }
    };
    batchPollRef.current = setInterval(tick, 5000);
    return () => clearInterval(batchPollRef.current);
}, [batchPolling, currentBatch?.id]);

// ... Create 탭 jsx 안:
<details className="ms-batch-section" open={batchOpen} onToggle={(e) => setBatchOpen(e.target.open)}>
    <summary>🎲 배치 생성 (장르  1-10트랙 + 자동 영상)</summary>
    <div className="ms-batch-form">
        <label>장르
            <select value={batchGenre} onChange={e => setBatchGenre(e.target.value)}>
                <option value="lo-fi">Lo-Fi</option>
                <option value="phonk">Phonk</option>
                <option value="ambient">Ambient</option>
                <option value="pop">Pop</option>
            </select>
        </label>
        <label>트랙 : {batchCount}
            <input type="range" min={1} max={10} value={batchCount}
                onChange={e => setBatchCount(parseInt(e.target.value))} />
        </label>
        <label>트랙당 길이: {batchDuration}
            <input type="range" min={60} max={300} step={10} value={batchDuration}
                onChange={e => setBatchDuration(parseInt(e.target.value))} />
        </label>
        <label className="ms-batch-checkbox">
            <input type="checkbox" checked={batchAutoPipe}
                onChange={e => setBatchAutoPipe(e.target.checked)} />
            모든 트랙 생성  자동 영상 파이프라인 시작
        </label>
        <p className="ms-batch-estimate">
            예상:  {Math.ceil(batchCount * 1.5)}-{batchCount * 2} ·
            비용 ~${(batchCount * 0.005 + (batchAutoPipe ? 0.05 : 0)).toFixed(2)}
        </p>
        <button className="button primary" onClick={startBatch}
            disabled={batchPolling}>
            🎵 배치 생성 시작
        </button>
    </div>
    {currentBatch && <BatchProgress batch={currentBatch} />}
</details>
  • Step 4: CSS 추가
/* === Batch generation section === */
.ms-batch-section { margin: 16px 0; padding: 12px; background: rgba(0,0,0,.2);
    border: 1px solid var(--ms-line, #2a2a3a); border-radius: 12px; }
.ms-batch-section summary { cursor: pointer; font-weight: bold; color: var(--ms-text, #f0f0f5); }
.ms-batch-form { display: flex; flex-direction: column; gap: 10px; padding: 12px 0; }
.ms-batch-form label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
.ms-batch-form input[type="range"] { width: 100%; }
.ms-batch-checkbox { flex-direction: row !important; align-items: center; gap: 8px; }
.ms-batch-checkbox input { width: auto; }
.ms-batch-estimate { font-size: 12px; color: var(--ms-muted, #a0a0b0); }

.ms-batch-progress { margin-top: 12px; padding: 12px; background: rgba(0,0,0,.3);
    border-radius: 8px; }
.ms-batch-header { font-size: 13px; margin-bottom: 8px; }
.ms-batch-tracks { padding-left: 24px; font-size: 12px; }
.ms-batch-tracks li { margin: 2px 0; }
.ms-batch-tracks li.done { color: #86efac; }
.ms-batch-tracks li.current { color: var(--ms-accent, #38bdf8); font-weight: bold; }
.ms-batch-tracks li.pending { color: var(--ms-muted, #a0a0b0); }
.ms-batch-link { margin-top: 8px; font-size: 12px; color: var(--ms-muted, #a0a0b0); }
  • Step 5: Build + verify + commit + push + deploy
cd web-ui && npm run build 2>&1 | tail -5
npx eslint src/pages/music/components/BatchProgress.jsx src/pages/music/MusicStudio.jsx 2>&1 | tail
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/api.js \
        src/pages/music/components/BatchProgress.jsx \
        src/pages/music/MusicStudio.jsx \
        src/pages/music/MusicStudio.css
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(web-ui): Create 탭 배치 생성 섹션 + BatchProgress"
git -C C:/Users/jaeoh/Desktop/workspace/web-ui push origin main
cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm run release:nas

Task 4: 수동 E2E 검증

  • Create 탭 → 배치 생성 섹션 펼침 → genre=lo-fi, count=3 (테스트로 적게), duration=120s, auto_pipeline=on → "배치 생성 시작"
  • BatchProgress에 Track 1/2/3 진행 표시 확인
  • ~5분 후 Library에 3개 트랙 추가됨
  • 컴파일 진행 확인 (status: compiling)
  • 영상 파이프라인 시작됨 (status: piped) + pipeline_id 표시
  • YouTube 탭 → 진행 탭에 새 카드, cover 단계 진행 중
  • 텔레그램에 cover 알림 도착
  • 일반 흐름대로 5단계 승인 후 발행

Self-Review

Spec coverage:

  • §3 사용자 흐름 → Task 3 (UI 섹션)
  • §4 데이터 모델 → Task 1
  • §5 백엔드 (random_pools, batch_generator) → Task 1, 2
  • §6 API → Task 2
  • §7 프론트엔드 → Task 3
  • §8 에러 처리 → Task 2 (validation, try/except)
  • §9 테스트 → Task 1, 2
  • §10 산출물 → 4 task로 모두 커버

Placeholder scan: 없음.

Type consistency:

  • batch_id int, count int, genre str — 일관
  • track_ids list[int]
  • status 7값 (queued/generating/generated/compiling/piped/failed/cancelled) 일관

스펙 보정: §5-2 batch_generator의 _generate_one_track에서 db.create_task/db.get_task 사용 — 이 함수들이 기존 db.py에 있는지 미확인. Task 2 Step 1 NOTE에 명시함.