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:
121
music-lab/app/compiler.py
Normal file
121
music-lab/app/compiler.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from .db import get_compile_job, get_track_file_path, get_track_by_id, update_compile_job
|
||||
|
||||
VIDEO_DATA_DIR = os.getenv("VIDEO_DATA_DIR", "/app/data/videos")
|
||||
|
||||
|
||||
def _build_concat_cmd(file_paths: list[str], crossfade_sec: float, output_path: str) -> list:
|
||||
"""FFmpeg command: N audio files → single audio with acrossfade."""
|
||||
n = len(file_paths)
|
||||
if n == 1:
|
||||
# Single track: just copy
|
||||
return ["ffmpeg", "-y", "-i", file_paths[0], "-c:a", "libmp3lame", "-q:a", "2", output_path]
|
||||
|
||||
cmd = []
|
||||
for fp in file_paths:
|
||||
cmd += ["-i", fp]
|
||||
|
||||
# Build acrossfade filter chain
|
||||
filter_parts = []
|
||||
prev = "0"
|
||||
for i in range(1, n):
|
||||
out_label = f"a{i:02d}"
|
||||
filter_parts.append(f"[{prev}][{i}]acrossfade=d={crossfade_sec}:c1=tri:c2=tri[{out_label}]")
|
||||
prev = out_label
|
||||
|
||||
filter_str = ";".join(filter_parts)
|
||||
|
||||
return (
|
||||
["ffmpeg", "-y"]
|
||||
+ cmd
|
||||
+ ["-filter_complex", filter_str, "-map", f"[{prev}]", "-c:a", "libmp3lame", "-q:a", "2", output_path]
|
||||
)
|
||||
|
||||
|
||||
def _make_gradient_bg(width: int, height: int, output_path: str) -> None:
|
||||
"""Simple dark gradient background image via FFmpeg."""
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y",
|
||||
"-f", "lavfi",
|
||||
"-i", f"color=c=0x111827:size={width}x{height}:rate=1",
|
||||
"-frames:v", "1",
|
||||
output_path,
|
||||
],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def _get_audio_duration(path: str) -> float:
|
||||
"""Return duration in seconds via ffprobe."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1", path],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
return float(result.stdout.strip())
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def run_compile(job_id: int) -> None:
|
||||
"""Main compile worker — called as BackgroundTask."""
|
||||
job = get_compile_job(job_id)
|
||||
if not job:
|
||||
return
|
||||
|
||||
update_compile_job(job_id, status="rendering")
|
||||
|
||||
try:
|
||||
track_ids = job["track_ids"]
|
||||
if not track_ids:
|
||||
raise ValueError("트랙이 선택되지 않았습니다")
|
||||
|
||||
# Resolve file paths
|
||||
file_paths = []
|
||||
for tid in track_ids:
|
||||
fp = get_track_file_path(tid)
|
||||
if not fp or not os.path.exists(fp):
|
||||
raise ValueError(f"트랙 파일 없음 (id={tid})")
|
||||
file_paths.append(fp)
|
||||
|
||||
out_dir = os.path.join(VIDEO_DATA_DIR, f"compile_{job_id}")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# Step 1: concat audio
|
||||
audio_path = os.path.join(out_dir, "audio.mp3")
|
||||
concat_cmd = _build_concat_cmd(file_paths, job["crossfade_sec"], audio_path)
|
||||
subprocess.run(concat_cmd, check=True, capture_output=True)
|
||||
|
||||
duration = _get_audio_duration(audio_path)
|
||||
|
||||
# Step 2: background image
|
||||
bg_path = os.path.join(out_dir, "bg.jpg")
|
||||
_make_gradient_bg(1920, 1080, bg_path)
|
||||
|
||||
# Step 3: audio + bg → MP4
|
||||
output_path = os.path.join(out_dir, "output.mp4")
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y",
|
||||
"-loop", "1", "-i", bg_path,
|
||||
"-i", audio_path,
|
||||
"-c:v", "libx264", "-tune", "stillimage", "-preset", "fast",
|
||||
"-c:a", "aac", "-b:a", "192k",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-shortest",
|
||||
output_path,
|
||||
],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
|
||||
update_compile_job(job_id, status="done", output_path=output_path, duration_sec=duration)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
err = (e.stderr or b"").decode()[-300:]
|
||||
update_compile_job(job_id, status="failed", error=err)
|
||||
except Exception as e:
|
||||
update_compile_job(job_id, status="failed", error=str(e))
|
||||
Reference in New Issue
Block a user