From f074cbec2d53f12cc4722d2b44c262c8b4550b83 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sun, 10 May 2026 18:49:16 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=EB=B0=B0=EC=B9=98=20=EC=9D=8C=EC=95=85?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20+=20=EC=9E=90=EB=8F=99=20=EC=98=81?= =?UTF-8?q?=EC=83=81=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20spe?= =?UTF-8?q?c=20+=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-05-10-batch-music-generation.md | 815 ++++++++++++++++++ ...026-05-10-batch-music-generation-design.md | 505 +++++++++++ 2 files changed, 1320 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-10-batch-music-generation.md create mode 100644 docs/superpowers/specs/2026-05-10-batch-music-generation-design.md diff --git a/docs/superpowers/plans/2026-05-10-batch-music-generation.md b/docs/superpowers/plans/2026-05-10-batch-music-generation.md new file mode 100644 index 0000000..466e1ec --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-batch-music-generation.md @@ -0,0 +1,815 @@ +# 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 작성** + +```python +"""장르별 음악 파라미터 랜덤 풀.""" +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()`에 추가: +```python +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` 끝에 헬퍼: +```python +_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 작성** + +```python +# 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** + +```bash +cd music-lab && python -m pytest tests/test_batch_db.py -v +``` +Expected: 7 PASS. + +```bash +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 작성** + +```python +"""배치 음악 생성 + 자동 컴파일·영상 파이프라인.""" +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 추가** + +```python +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: 테스트 작성** + +```python +# 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** + +```bash +cd music-lab && python -m pytest tests/ -v +``` +Expected: 모두 PASS. + +```bash +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 헬퍼** + +```javascript +// === 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 신규** + +```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 ( +
+
+ 배치 #{batch.id} — {batch.genre} ·{' '} + {batch.completed}/{batch.count} 완료 ·{' '} + {STATUS_LABELS[batch.status] || batch.status} +
+ {batch.error &&
에러: {batch.error}
} +
    + {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 ( +
  1. + {completed ? '✓' : current ? '⏳' : '○'} + {' '}Track {n}: {tr?.title || (current ? '생성 중...' : '대기')} +
  2. + ); + })} +
+ {batch.compile_job_id && ( +
📀 컴파일 #{batch.compile_job_id}
+ )} + {batch.pipeline_id && ( +
+ 🎬 영상 파이프라인 #{batch.pipeline_id} — + {' '}YouTube 탭 → 진행 탭에서 확인 +
+ )} +
+ ); +} +``` + +- [ ] **Step 3: MusicStudio.jsx Create 탭에 배치 섹션 추가** + +Create 탭 jsx 영역 (handleGenerate 근처) 위 또는 옆에: + +```jsx +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 안: +
setBatchOpen(e.target.open)}> + 🎲 배치 생성 (장르 → 1-10트랙 + 자동 영상) +
+ + + + +

+ 예상: 약 {Math.ceil(batchCount * 1.5)}-{batchCount * 2}분 · + 비용 ~${(batchCount * 0.005 + (batchAutoPipe ? 0.05 : 0)).toFixed(2)} +

+ +
+ {currentBatch && } +
+``` + +- [ ] **Step 4: CSS 추가** + +```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** + +```bash +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 +``` + +```bash +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에 명시함. diff --git a/docs/superpowers/specs/2026-05-10-batch-music-generation-design.md b/docs/superpowers/specs/2026-05-10-batch-music-generation-design.md new file mode 100644 index 0000000..dc37758 --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-batch-music-generation-design.md @@ -0,0 +1,505 @@ +# 배치 음악 생성 + 자동 영상 파이프라인 설계 + +> 작성일: 2026-05-10 +> 관련: `2026-05-09-essential-mix-pipeline-design.md` (영상 파이프라인 베이스) + +--- + +## 1. 배경 + +현재 Create 탭은 사용자가 모든 파라미터(genre/mood/instruments/BPM/key/scale/duration/prompt) 수동 입력 후 1트랙 생성. 1시간+ mix 영상 만들려면 동일 장르 트랙 10개를 일일이 만들어야 함. + +목표: **장르 1개만 입력 → 10트랙 자동 생성 → 자동 컴파일 → 자동 영상 파이프라인 시작 → 텔레그램 승인만 하면 발행 완료**. + +전체 흐름: +``` +[사용자] Create 탭 → 배치 모드 → 장르 + 트랙 수 선택 → 생성 시작 + ↓ Suno API 순차 호출 (트랙당 ~1-2분) + ↓ Track 1: "{Genre} Mix Track 1", 랜덤 mood/instr/BPM/key + ↓ Track 2: "{Genre} Mix Track 2", ... + ↓ ... Track 10 + ↓ 모두 완료 → compile_job 자동 생성 (acrossfade 3s) + ↓ compile 완료 → video_pipeline 자동 시작 (cover step) + ↓ 텔레그램에 "🎵 [{Genre} Mix] 커버 검토" 알림 +[사용자] 5번 승인으로 영상 발행 +``` + +--- + +## 2. 비목표 + +- 병렬 음악 생성 — VRAM 부담 회피, 순차로 단순하게 +- 트랙별 prompt 자동 작성(Claude) — Suno는 genre+mood+instruments만으로도 충분 +- 트랙별 길이 가변 — 모든 트랙 동일 `target_duration_sec` (default 180s) +- 사용자가 진행 중 트랙 prompt 편집 — 한 번 시작하면 끝까지 + +--- + +## 3. 사용자 흐름 + +### 3-1. Create 탭의 신규 "배치 생성" 섹션 + +``` +┌─ 🎲 배치 생성 (장르 + 자동 영상까지) ─────────────────┐ +│ │ +│ 장르 [▼ lo-fi ] │ +│ 트랙 수 [● 1 — 10] (10) │ +│ 트랙당 길이 [● 60 — 300s] (180s) │ +│ ☑ 모든 트랙 생성 후 자동 영상 파이프라인 시작 │ +│ │ +│ 예상 시간: 약 15-25분 (트랙당 1-2분 × 10) │ +│ 예상 비용: ~$0.10 (Suno 10트랙 + DALL·E + Claude) │ +│ │ +│ [🎵 배치 생성 시작] │ +│ │ +│ ── 진행 상태 ────────────────────────────────────── │ +│ 배치 #3 — lo-fi · 7/10 완료 · 2:43 경과 │ +│ ✓ Track 1: Lo-Fi Mix Track 1 (chill, piano+synth) │ +│ ✓ Track 2: Lo-Fi Mix Track 2 (relaxing, piano+drums) │ +│ ... │ +│ ⏳ Track 8: 생성 중... │ +│ ○ Track 9: 대기 │ +│ ○ Track 10: 대기 │ +└──────────────────────────────────────────────────────┘ +``` + +### 3-2. 완료 후 + +10트랙 모두 Library에 저장됨. compile_job_id가 자동 생성되고 영상 파이프라인이 cover step부터 시작 → 텔레그램 알림. 진행 탭에 카드 1장 추가. + +--- + +## 4. 데이터 모델 + +### 4-1. 신규 테이블 `music_batch_jobs` + +```sql +CREATE TABLE music_batch_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + genre TEXT NOT NULL, + count INTEGER NOT NULL, -- 1-10 + target_duration_sec INTEGER NOT NULL DEFAULT 180, + auto_pipeline INTEGER NOT NULL DEFAULT 1, -- 0/1 boolean + completed INTEGER NOT NULL DEFAULT 0, + track_ids_json TEXT NOT NULL DEFAULT '[]', + current_track_index INTEGER NOT NULL DEFAULT 0, -- 진행 중 트랙 (1..count) + current_track_status TEXT, -- queued | generating | failed + status TEXT NOT NULL DEFAULT 'queued', + -- queued: 시작 전 + -- generating: 트랙 생성 중 + -- generated: 모든 트랙 생성 완료 (compile 시작 전) + -- compiling: compile 진행 중 + -- piped: 영상 파이프라인 시작됨 (=cover_pending 상태) + -- failed: 어느 단계에서 실패 + -- cancelled: 사용자 취소 + error TEXT, + compile_job_id INTEGER, + pipeline_id INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +``` + +`init_db()`에 `CREATE TABLE IF NOT EXISTS` 추가. + +### 4-2. 헬퍼 함수 (`db.py` 추가) + +- `create_batch_job(genre, count, target_duration_sec, auto_pipeline) -> int` +- `get_batch_job(id) -> dict | None` +- `update_batch_job(id, **fields)` — allowlist 검증 +- `list_batch_jobs(active_only=False) -> list[dict]` +- `append_batch_track(batch_id, track_id)` — 완료된 트랙 ID 추가, completed++ + +--- + +## 5. 백엔드 — 랜덤 풀 + 배치 실행 + +### 5-1. `app/random_pools.py` (신규) + +장르별 음악적으로 어울리는 랜덤 풀 정의: + +```python +"""장르별 음악 파라미터 랜덤 풀.""" +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": { # 알 수 없는 장르 fallback + "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: random.Random | None = None) -> dict: + """랜덤 음악 파라미터 1세트 생성.""" + 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"]), + } +``` + +향후(P3): 장르별 풀을 `youtube_setup`/별도 테이블로 옮겨 SetupTab에서 편집 가능하게. + +### 5-2. `app/batch_generator.py` (신규) — 순차 실행 오케스트레이터 + +```python +"""배치 음악 생성 + 자동 컴파일·영상 파이프라인.""" +import asyncio +import logging +import json + +from . import db +from .suno_provider import run_suno_generation +from .random_pools import randomize + +logger = logging.getLogger("music-lab.batch") + +POLL_INTERVAL_S = 5 +TRACK_GEN_TIMEOUT_S = 240 # 트랙당 최대 4분 + + +async def run_batch(batch_id: int) -> None: + """1) genre로 N트랙 순차 Suno 생성 + 2) 모두 완료 후 compile_job 자동 생성·실행 + 3) compile 완료 후 영상 파이프라인 시작 (cover step) + """ + 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") + + # Suno 호출 (기존 task 패턴 활용) + task_id = _start_suno(title=title, genre=genre, + duration_sec=duration, **params) + track_id = await _wait_for_track(task_id, timeout=TRACK_GEN_TIMEOUT_S) + + if track_id: + track_ids.append(track_id) + db.append_batch_track(batch_id, track_id) + else: + logger.warning("배치 %d 트랙 %d 실패 — 계속 진행", batch_id, i) + db.update_batch_job(batch_id, current_track_status="failed") + # 정책: 실패한 트랙은 skip하고 계속 (나머지 9개라도 만든다) + + 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 # 음악만 만들고 종료 + + # === 자동 compile === + db.update_batch_job(batch_id, status="compiling") + 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) + + # 기존 compiler 호출 (동기 → asyncio.to_thread) + from . import compiler + await asyncio.to_thread(compiler.run, compile_id) + + 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 실패 (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") +``` + +- `_start_suno(...)` — 기존 `run_suno_generation` 호출, task_id 반환 +- `_wait_for_track(task_id, timeout)` — task 완료 폴링, 성공 시 music_library의 새 track id 반환 + +### 5-3. 변경되는 기존 모듈 + +`app/main.py`에 신규 endpoint 3개 + BackgroundTask. 변경 없는 기존 endpoint들은 그대로. + +`db.py`에 헬퍼 함수 5개 추가 + `init_db()`에 `music_batch_jobs` CREATE 추가. + +--- + +## 6. API 엔드포인트 + +### 6-1. `POST /api/music/generate-batch` + +Request: +```json +{ + "genre": "lo-fi", + "count": 10, + "target_duration_sec": 180, + "auto_pipeline": true +} +``` + +Validation: +- `count` 1-10 +- `target_duration_sec` 60-300 +- `genre` 필수 + +Response 201: +```json +{ + "id": 3, + "status": "queued", + ... +} +``` + +배치 작업은 BackgroundTask로 실행 (~15-25분 소요). + +### 6-2. `GET /api/music/generate-batch/{id}` + +진행 상태 조회. 응답 예: +```json +{ + "id": 3, + "genre": "lo-fi", + "count": 10, + "completed": 7, + "current_track_index": 8, + "current_track_status": "generating", + "status": "generating", + "track_ids": [12, 13, 14, 15, 16, 17, 18], + "tracks": [ + {"id": 12, "title": "Lo-Fi Mix Track 1", ...}, + ... + ], + "compile_job_id": null, + "pipeline_id": null, + "created_at": "2026-05-10T17:00:00", + "updated_at": "2026-05-10T17:08:30" +} +``` + +`tracks` 필드는 LEFT JOIN으로 채워짐 (각 트랙 메타 포함). + +### 6-3. `GET /api/music/generate-batch?status=active` + +전체 배치 목록. `active`면 queued/generating/compiling/piped 만. + +--- + +## 7. 프론트엔드 — Create 탭 배치 섹션 + +### 7-1. `MusicStudio.jsx` Create 영역에 신규 collapsible + +Create form 위 또는 옆에 새 섹션 (`
` 또는 토글): + +```jsx +
+ 🎲 배치 생성 (1-10트랙 + 자동 영상) + +
+ + + + + + + + +

+ 예상: 약 {batchCount * 1.5 | 0}-{batchCount * 2}분 · 비용 ~${(batchCount * 0.005 + (autoPipeline ? 0.05 : 0)).toFixed(2)} +

+ + +
+ + {currentBatch && } +
+``` + +### 7-2. 신규 컴포넌트 `BatchProgress.jsx` + +```jsx +export default function BatchProgress({ batch }) { + return ( +
+
+ 배치 #{batch.id} — {batch.genre} · + {' '}{batch.completed}/{batch.count} 완료 · + {' '}status: {batch.status} +
+
    + {Array.from({ length: batch.count }, (_, i) => i + 1).map(n => { + const completed = n <= batch.completed; + const current = n === batch.current_track_index && batch.status === 'generating'; + const track = (batch.tracks || []).find(t => t._batch_index === n); + return ( +
  1. + {completed ? '✓' : current ? '⏳' : '○'} + {' '}Track {n}: {track ? track.title : (current ? '생성 중...' : '대기')} +
  2. + ); + })} +
+ {batch.compile_job_id &&
📀 컴파일 #{batch.compile_job_id}
} + {batch.pipeline_id && ( +
+ 🎬 영상 파이프라인 #{batch.pipeline_id} — + 진행 탭에서 확인 +
+ )} +
+ ); +} +``` + +### 7-3. 폴링 + +배치 시작 시 5초 간격 `getBatchJob(id)` 호출. status가 `piped`/`failed`/`cancelled`되면 폴링 중지. + +### 7-4. `api.js` 헬퍼 + +```javascript +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}`); +``` + +--- + +## 8. 에러 처리 + +| 시나리오 | 동작 | +|---------|------| +| Suno API 트랙 1개 실패 | 로그 + skip + 다음 트랙 진행. 최종 track_ids에 누락. | +| 모든 트랙 실패 | status=failed, error 기록 | +| compile 실패 | status=failed, compile_job_id 보존 | +| 영상 파이프라인 cover step 실패 | pipeline 자체에서 failed로 마크. batch는 piped 상태 그대로 (파이프라인 측에서 처리) | +| count > 10 또는 < 1 | 400 | +| genre 누락 | 400 | +| Suno API key 미설정 | 400 ("SUNO_API_KEY 미설정") | + +--- + +## 9. 테스트 전략 + +### 9-1. 단위 테스트 + +- `random_pools.randomize(genre)` — 각 장르별 결과가 풀 안에 있는지, 시드 고정 시 재현 가능 +- `db.create_batch_job` / `update_batch_job` / `append_batch_track` — 정상 흐름 +- `_wait_for_track` — task 성공/실패/timeout mock + +### 9-2. 통합 테스트 + +- `POST /api/music/generate-batch` 호출 → 201 반환 + 배치 row 생성 +- `GET /api/music/generate-batch/{id}` 응답 schema +- `run_batch` mocked Suno + mocked compiler + mocked orchestrator → 전체 흐름 happy path + +### 9-3. 수동 E2E + +- Create 탭 → 배치 생성 → 장르 선택 → 시작 → 진행 표시 확인 +- 10트랙 완료 → Library에 10개 추가 확인 → compile_job 자동 생성 확인 → 진행 탭에 새 카드 등장 확인 + +--- + +## 10. 산출물 + +| 영역 | 파일 | +|------|------| +| Spec/Plan | 본 문서 + plan | +| NAS music-lab | `db.py` (테이블/헬퍼), `random_pools.py` (신규), `batch_generator.py` (신규), `main.py` (3 endpoints) | +| Frontend | `MusicStudio.jsx` (Create 배치 섹션), `BatchProgress.jsx` (신규), `MusicStudio.css`, `api.js` 헬퍼 | +| 테스트 | NAS 단위 + 통합, 수동 E2E | + +--- + +## 11. 후속 (P3) + +- 장르별 풀 SetupTab에서 편집 가능 +- 트랙별 prompt에 시나리오/카페 분위기 등 자동 추가 (트랙간 다양성 증대) +- 배치 일시정지/재개 +- 한 배치 안에서 Track-N별 재생성 (실패한 트랙만) +- 트랙 길이 가변 (랜덤 분포)