"""영상 비주얼 생성 — 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_BASE_S = 300 # 짧은 영상용 base def _encoder_timeout(duration_sec: int) -> int: """duration에 비례한 HTTP timeout — Windows ffmpeg timeout (~0.3x duration + 180)에 60s 마진 추가. 1분 → 300s, 30분 → 780s, 60분 → 1320s, 120분 → 2400s """ return max(ENCODER_TIMEOUT_BASE_S, int(duration_sec * 0.3) + 240) # 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 [], } timeout_s = _encoder_timeout(duration_sec) logger.info("Windows 인코더 호출 (timeout=%ds): pipeline=%d duration=%ds style=%s bg_mode=%s", timeout_s, pipeline_id, duration_sec, style, background_mode) try: with httpx.Client(timeout=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