feat(music-lab): 다중 트랙 컴파일 백엔드 (FFmpeg concat+crossfade → MP4)

- db.py: compile_jobs 테이블 추가 + CRUD 5종 (create/get/list/update/delete)
- compiler.py: acrossfade 필터 체인 + 그라디언트 배경 + MP4 렌더링 워커
- main.py: /api/music/compile POST·GET·DELETE + /api/music/compiles GET + /api/music/compile/{id}/export GET

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 16:54:53 +09:00
parent 7c8d079f74
commit 096e291ed8
3 changed files with 268 additions and 0 deletions

View File

@@ -169,6 +169,21 @@ def init_db() -> None:
)
""")
# ── compile_jobs 테이블 ───────────────────────────────────────────
conn.execute("""
CREATE TABLE IF NOT EXISTS compile_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
track_ids TEXT NOT NULL DEFAULT '[]',
crossfade_sec REAL NOT NULL DEFAULT 3.0,
status TEXT NOT NULL DEFAULT 'pending',
output_path TEXT,
duration_sec REAL,
error TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
# ── music_tasks CRUD ──────────────────────────────────────────────────────────
@@ -707,3 +722,72 @@ def get_trend_reports(limit: int = 10) -> list:
}
for r in rows
]
# ── Compile Jobs ─────────────────────────────────────────
def create_compile_job(title: str, track_ids: list, crossfade_sec: float) -> int:
with _conn() as conn:
cur = conn.execute(
"INSERT INTO compile_jobs (title, track_ids, crossfade_sec) VALUES (?,?,?)",
(title, json.dumps(track_ids), crossfade_sec),
)
return cur.lastrowid
def get_compile_jobs() -> list:
with _conn() as conn:
rows = conn.execute(
"SELECT id, title, track_ids, crossfade_sec, status, duration_sec, created_at "
"FROM compile_jobs ORDER BY created_at DESC LIMIT 50"
).fetchall()
return [
{
"id": r["id"],
"title": r["title"],
"track_ids": json.loads(r["track_ids"]),
"crossfade_sec": r["crossfade_sec"],
"status": r["status"],
"duration_sec": r["duration_sec"],
"created_at": r["created_at"],
}
for r in rows
]
def get_compile_job(job_id: int) -> Optional[Dict[str, Any]]:
with _conn() as conn:
r = conn.execute(
"SELECT * FROM compile_jobs WHERE id = ?", (job_id,)
).fetchone()
if not r:
return None
return {
"id": r["id"],
"title": r["title"],
"track_ids": json.loads(r["track_ids"]),
"crossfade_sec": r["crossfade_sec"],
"status": r["status"],
"output_path": r["output_path"],
"duration_sec": r["duration_sec"],
"error": r["error"],
"created_at": r["created_at"],
}
def update_compile_job(job_id: int, **kwargs) -> None:
allowed = {"status", "output_path", "duration_sec", "error"}
fields = {k: v for k, v in kwargs.items() if k in allowed}
if not fields:
return
set_clause = ", ".join(f"{k} = ?" for k in fields)
with _conn() as conn:
conn.execute(
f"UPDATE compile_jobs SET {set_clause} WHERE id = ?",
(*fields.values(), job_id),
)
def delete_compile_job(job_id: int) -> None:
with _conn() as conn:
conn.execute("DELETE FROM compile_jobs WHERE id = ?", (job_id,))