180 lines
5.4 KiB
Python
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
|