from __future__ import annotations import asyncio import os import subprocess from pathlib import Path from app.config import settings from app.services.video_cover_service import get_ffmpeg_bin from app.services.video_upscale.media_service import build_part_mp4_path, is_valid_file, probe_video, safe_remove class LocalVideoUpscaleError(RuntimeError): pass def _build_filter(target_width: int, target_height: int) -> str: if target_width <= 0 or target_height <= 0 or target_width % 2 or target_height % 2: raise LocalVideoUpscaleError("目标宽高必须是正偶数") return ( "hqdn3d=0.8:0.6:2.5:1.8," f"scale={target_width}:{target_height}:" "force_original_aspect_ratio=increase:force_divisible_by=2:flags=lanczos," f"crop={target_width}:{target_height}:(iw-ow)/2:(ih-oh)/2," "unsharp=5:5:0.40:5:5:0.0," "eq=contrast=1.02:saturation=1.03," "setsar=1" ) def _run_ffmpeg_sync( *, source_path: str, part_path: str, target_width: int, target_height: int, timeout_seconds: int, ) -> None: ffmpeg_bin = get_ffmpeg_bin() # 明确复用 config.py 的 FFMPEG_BIN。 cmd = [ ffmpeg_bin, "-hide_banner", "-nostdin", "-y", "-i", source_path, "-map", "0:v:0", "-map", "0:a?", "-vf", _build_filter(target_width, target_height), "-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-c:a", "aac", "-b:a", "192k", part_path, ] try: result = subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=max(30, int(timeout_seconds)), shell=False, ) except subprocess.TimeoutExpired as exc: raise LocalVideoUpscaleError(f"本地 FFmpeg 超分超时: {timeout_seconds} 秒") from exc except OSError as exc: raise LocalVideoUpscaleError(f"本地 FFmpeg 无法启动: {exc}") from exc if result.returncode != 0: raise LocalVideoUpscaleError(f"本地 FFmpeg 超分失败: {(result.stderr or '').strip()[-4000:]}") async def execute_local_ffmpeg_crop( *, source_path: str, final_path: str, target_width: int, target_height: int, timeout_seconds: int | None = None, ) -> str: if not is_valid_file(source_path): raise LocalVideoUpscaleError(f"超分源视频不存在或为空: {source_path}") if is_valid_file(final_path): info = await probe_video(final_path) if info.width == target_width and info.height == target_height: return final_path os.makedirs(os.path.dirname(final_path), exist_ok=True) part_path = build_part_mp4_path(final_path) safe_remove(part_path) try: await asyncio.to_thread( _run_ffmpeg_sync, source_path=source_path, part_path=part_path, target_width=target_width, target_height=target_height, timeout_seconds=int(timeout_seconds or settings.VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS), ) if not is_valid_file(part_path): raise LocalVideoUpscaleError("本地 FFmpeg 输出文件为空") info = await probe_video(part_path) if info.width != target_width or info.height != target_height: raise LocalVideoUpscaleError( f"本地 FFmpeg 输出尺寸不正确: {info.width}x{info.height},预期 {target_width}x{target_height}" ) os.replace(part_path, final_path) return final_path except Exception: safe_remove(part_path) raise