Files
video-gen/video-gen-api/app/services/video_upscale/local_ffmpeg_service.py
T
2026-07-16 15:02:03 +08:00

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