"""영상 비주얼 생성 — 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 = "visualizer") -> 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) payload = { "cover_path_nas": nas_cover, "audio_path_nas": nas_audio, "output_path_nas": nas_output, "resolution": resolution, "duration_sec": duration_sec, "style": style, } logger.info("Windows 인코더 호출: pipeline=%d audio=%s", pipeline_id, audio_path) 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 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