1. video.py _container_to_nas, orchestrator.py _local_path에서 path 변환 전 ?쿼리 strip — 이전 commit 20c5268의 cache-buster ?v=...가 Windows path로 그대로 전달되어 input_validation 실패하던 문제 픽스 2. cover.py _generate_with_dalle가 background_keyword를 prompt에 포함 — 사용자가 PipelineStartModal에서 '배경 키워드' 입력 시 처음부터 원하는 분위기 cover 생성
107 lines
4.0 KiB
Python
107 lines
4.0 KiB
Python
"""영상 비주얼 생성 — Windows GPU 서버 (NVENC) 호출.
|
|
|
|
Windows 서버 다운/실패 시 즉시 예외 (NAS 로컬 폴백 없음 — 의도적 결정).
|
|
"""
|
|
import os
|
|
import logging
|
|
import httpx
|
|
|
|
from . import storage
|
|
|
|
logger = logging.getLogger("music-lab.video")
|
|
|
|
ENCODER_URL = os.getenv("WINDOWS_VIDEO_ENCODER_URL", "")
|
|
ENCODER_TIMEOUT_S = 200 # Windows 서버 ffmpeg 180s + 마진
|
|
|
|
# NAS 호스트 절대경로 prefix — docker bind mount의 host 측
|
|
NAS_VIDEOS_ROOT = os.getenv("NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos")
|
|
NAS_MUSIC_ROOT = os.getenv("NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music")
|
|
|
|
|
|
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 = "essential",
|
|
background_mode: str = "static",
|
|
background_path: str | None = None,
|
|
tracks: list[dict] | None = None) -> dict:
|
|
"""원격 Windows GPU 서버 호출. 다운/실패 시 즉시 예외."""
|
|
if not ENCODER_URL:
|
|
raise VideoGenerationError(
|
|
"WINDOWS_VIDEO_ENCODER_URL 미설정 — Windows 인코더 서버 주소 필요"
|
|
)
|
|
|
|
out_path = os.path.join(storage.pipeline_dir(pipeline_id), "video.mp4")
|
|
nas_audio = _container_to_nas(audio_path)
|
|
nas_cover = _container_to_nas(cover_path)
|
|
nas_output = _container_to_nas(out_path)
|
|
nas_bg = _container_to_nas(background_path) if background_path else None
|
|
|
|
payload = {
|
|
"cover_path_nas": nas_cover,
|
|
"audio_path_nas": nas_audio,
|
|
"output_path_nas": nas_output,
|
|
"resolution": resolution,
|
|
"duration_sec": duration_sec,
|
|
"style": style,
|
|
"background_mode": background_mode,
|
|
"background_path_nas": nas_bg,
|
|
"tracks": tracks or [],
|
|
}
|
|
|
|
logger.info("Windows 인코더 호출: pipeline=%d audio=%s style=%s bg_mode=%s",
|
|
pipeline_id, audio_path, style, background_mode)
|
|
try:
|
|
with httpx.Client(timeout=ENCODER_TIMEOUT_S) as client:
|
|
resp = client.post(f"{ENCODER_URL}/encode_video", json=payload)
|
|
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout, httpx.NetworkError) as e:
|
|
raise VideoGenerationError(f"Windows 인코더 연결 실패: {e}")
|
|
|
|
if resp.status_code != 200:
|
|
try:
|
|
body = resp.json()
|
|
# FastAPI HTTPException wraps in {"detail": ...}
|
|
detail = body.get("detail", body) if isinstance(body, dict) else body
|
|
except Exception:
|
|
detail = {"error": resp.text[:300]}
|
|
if isinstance(detail, dict):
|
|
stage = detail.get("stage", "?")
|
|
error = detail.get("error", str(detail))
|
|
else:
|
|
stage = "?"
|
|
error = str(detail)
|
|
raise VideoGenerationError(
|
|
f"Windows 인코더 오류 ({resp.status_code}): {stage} — {error}"
|
|
)
|
|
|
|
data = resp.json()
|
|
if not data.get("ok"):
|
|
raise VideoGenerationError(f"Windows 인코더 응답 ok=false: {data}")
|
|
|
|
return {
|
|
"url": storage.media_url(pipeline_id, "video.mp4"),
|
|
"used_fallback": False,
|
|
"duration_sec": duration_sec,
|
|
"encode_duration_ms": data.get("duration_ms"),
|
|
"encoder": data.get("encoder", "h264_nvenc"),
|
|
}
|
|
|
|
|
|
def _container_to_nas(container_path: str) -> str:
|
|
""" /app/data/videos/3/cover.jpg → /volume1/docker/webpage/data/videos/3/cover.jpg
|
|
/app/data/abc.mp3 → /volume1/docker/webpage/data/music/abc.mp3
|
|
"""
|
|
if not container_path:
|
|
return ""
|
|
# Strip query string (e.g., cache-buster ?v=...)
|
|
container_path = container_path.split("?", 1)[0]
|
|
if container_path.startswith("/app/data/videos/"):
|
|
return container_path.replace("/app/data/videos/", NAS_VIDEOS_ROOT + "/", 1)
|
|
if container_path.startswith("/app/data/"):
|
|
rel = container_path[len("/app/data/"):]
|
|
return NAS_MUSIC_ROOT + "/" + rel
|
|
return container_path
|