Files
video-gen/video-gen-api/app/services/video_upscale/local_ffmpeg_service.py
T
2026-07-20 13:48:17 +08:00

180 lines
5.4 KiB
Python

from __future__ import annotations
import asyncio
import os
from collections.abc import Awaitable, Callable
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 _build_command(
*,
source_path: str,
part_path: str,
target_width: int,
target_height: int,
) -> list[str]:
return [
get_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,
]
async def _terminate_process(process: asyncio.subprocess.Process) -> None:
if process.returncode is not None:
return
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=5)
except asyncio.TimeoutError:
process.kill()
await process.wait()
async def _run_ffmpeg(
*,
source_path: str,
part_path: str,
target_width: int,
target_height: int,
timeout_seconds: int,
execution_guard: Callable[[], Awaitable[None]] | None,
) -> None:
try:
process = await asyncio.create_subprocess_exec(
*_build_command(
source_path=source_path,
part_path=part_path,
target_width=target_width,
target_height=target_height,
),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except OSError as exc:
raise LocalVideoUpscaleError(f"本地 FFmpeg 无法启动: {exc}") from exc
communicate_task = asyncio.create_task(process.communicate())
deadline = asyncio.get_running_loop().time() + max(30, int(timeout_seconds))
try:
while not communicate_task.done():
if execution_guard is not None:
await execution_guard()
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
raise LocalVideoUpscaleError(f"本地 FFmpeg 超分超时: {timeout_seconds} 秒")
try:
await asyncio.wait_for(asyncio.shield(communicate_task), timeout=min(2.0, remaining))
except asyncio.TimeoutError:
continue
stdout, stderr = await communicate_task
except BaseException:
await _terminate_process(process)
if not communicate_task.done():
communicate_task.cancel()
try:
await communicate_task
except BaseException:
pass
raise
if process.returncode != 0:
error_text = (stderr or b"").decode("utf-8", errors="replace").strip()
raise LocalVideoUpscaleError(f"本地 FFmpeg 超分失败: {error_text[-4000:]}")
async def execute_local_ffmpeg_crop(
*,
source_path: str,
final_path: str,
target_width: int,
target_height: int,
timeout_seconds: int | None = None,
execution_guard: Callable[[], Awaitable[None]] | 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 _run_ffmpeg(
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),
execution_guard=execution_guard,
)
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}"
)
if execution_guard is not None:
await execution_guard()
os.replace(part_path, final_path)
return final_path
except Exception:
safe_remove(part_path)
raise