129 lines
3.9 KiB
Python
129 lines
3.9 KiB
Python
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
|