- orchestrator._run_video: track.file_path 우선 사용 (audio_url 변환 불필요) - _local_path: /media/music/ → /app/data/ (마운트가 /app/data 직접이라 music 서브디렉토리 없음) - video.py/thumb.py: stderr truncation [-800:]/[-500:] — 진짜 에러 보이게
56 lines
1.8 KiB
Python
56 lines
1.8 KiB
Python
"""영상 비주얼 생성 — visualizer/슬라이드쇼 스타일."""
|
|
import os
|
|
import subprocess
|
|
import logging
|
|
|
|
from . import storage
|
|
|
|
logger = logging.getLogger("music-lab.video")
|
|
|
|
VIDEO_TIMEOUT_S = 300 # 5분
|
|
|
|
|
|
class VideoGenerationError(Exception):
|
|
pass
|
|
|
|
|
|
def generate(*, pipeline_id: int, audio_path: str, cover_path: str,
|
|
genre: str, duration_sec: int, resolution: str = "1920x1080",
|
|
style: str = "visualizer") -> dict:
|
|
"""영상 생성. 성공 시 mp4 저장 + URL 반환. 실패 시 예외."""
|
|
w, h = resolution.split("x")
|
|
out_path = os.path.join(storage.pipeline_dir(pipeline_id), "video.mp4")
|
|
|
|
if style == "visualizer":
|
|
cmd = _build_visualizer_cmd(audio_path, cover_path, out_path, w, h)
|
|
else:
|
|
# 차후: 슬라이드쇼 등 다른 스타일 — 현재는 visualizer 폴백
|
|
cmd = _build_visualizer_cmd(audio_path, cover_path, out_path, w, h)
|
|
|
|
logger.info("ffmpeg 실행: %s", " ".join(cmd))
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=VIDEO_TIMEOUT_S)
|
|
if result.returncode != 0:
|
|
raise VideoGenerationError(f"ffmpeg 실패: {result.stderr[-800:]}")
|
|
|
|
return {
|
|
"url": storage.media_url(pipeline_id, "video.mp4"),
|
|
"used_fallback": False,
|
|
"duration_sec": duration_sec,
|
|
}
|
|
|
|
|
|
def _build_visualizer_cmd(audio: str, bg: str, out: str, w: str, h: str) -> list:
|
|
return [
|
|
"ffmpeg", "-y",
|
|
"-loop", "1", "-i", bg,
|
|
"-i", audio,
|
|
"-filter_complex",
|
|
f"[0:v]scale={w}:{h}[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", "libx264", "-preset", "fast", "-crf", "23",
|
|
"-c:a", "aac", "-b:a", "192k",
|
|
"-shortest", out,
|
|
]
|