208 lines
7.0 KiB
Python
208 lines
7.0 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import parse_qsl, urlsplit
|
|
|
|
import httpx
|
|
|
|
from app.config import settings
|
|
from app.services.resource_signed_url_service import build_resource_signed_url
|
|
from app.services.video_cover_service import get_ffmpeg_bin
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class VideoMediaInfo:
|
|
width: int
|
|
height: int
|
|
duration_seconds: float
|
|
fps: float
|
|
codec_name: str | None = None
|
|
color_transfer: str | None = None
|
|
color_primaries: str | None = None
|
|
color_space: str | None = None
|
|
|
|
|
|
def build_part_mp4_path(final_path: str) -> str:
|
|
path = Path(final_path)
|
|
return str(path.with_name(f"{path.stem}.{uuid.uuid4().hex}.part.mp4"))
|
|
|
|
|
|
def safe_remove(path: str | None) -> bool:
|
|
if not path:
|
|
return True
|
|
try:
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
return not os.path.exists(path)
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def is_valid_file(path: str | None) -> bool:
|
|
if not path:
|
|
return False
|
|
try:
|
|
return os.path.isfile(path) and os.path.getsize(path) > 0
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def get_ffprobe_bin() -> str:
|
|
ffmpeg = Path(get_ffmpeg_bin())
|
|
sibling = ffmpeg.with_name("ffprobe.exe" if ffmpeg.suffix.lower() == ".exe" else "ffprobe")
|
|
if sibling.exists():
|
|
return str(sibling)
|
|
found = shutil.which("ffprobe") or shutil.which("ffprobe.exe")
|
|
if found:
|
|
return found
|
|
raise RuntimeError("未找到 ffprobe,请确保其与 FFMPEG_BIN 同目录或已加入 PATH")
|
|
|
|
|
|
def _parse_fps(value: str | None) -> float:
|
|
if not value:
|
|
return 0.0
|
|
try:
|
|
if "/" in value:
|
|
left, right = value.split("/", 1)
|
|
denominator = float(right)
|
|
return float(left) / denominator if denominator else 0.0
|
|
return float(value)
|
|
except Exception:
|
|
return 0.0
|
|
|
|
|
|
def probe_video_sync(path: str, timeout_seconds: int = 30) -> VideoMediaInfo:
|
|
if not is_valid_file(path):
|
|
raise RuntimeError(f"视频文件不存在或为空: {path}")
|
|
cmd = [
|
|
get_ffprobe_bin(), "-v", "error", "-select_streams", "v:0",
|
|
"-show_entries", "stream=width,height,codec_name,avg_frame_rate,color_transfer,color_primaries,color_space:format=duration",
|
|
"-of", "json", path,
|
|
]
|
|
result = subprocess.run(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=max(5, int(timeout_seconds)),
|
|
shell=False,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"ffprobe 校验失败: {(result.stderr or '').strip()[-2000:]}")
|
|
try:
|
|
payload = json.loads(result.stdout or "{}")
|
|
stream = (payload.get("streams") or [])[0]
|
|
width = int(stream.get("width") or 0)
|
|
height = int(stream.get("height") or 0)
|
|
duration = float((payload.get("format") or {}).get("duration") or 0)
|
|
except Exception as exc:
|
|
raise RuntimeError(f"ffprobe 响应解析失败: {exc}") from exc
|
|
if width <= 0 or height <= 0:
|
|
raise RuntimeError("ffprobe 未读取到有效视频宽高")
|
|
return VideoMediaInfo(
|
|
width=width,
|
|
height=height,
|
|
duration_seconds=duration,
|
|
fps=_parse_fps(stream.get("avg_frame_rate")),
|
|
codec_name=stream.get("codec_name"),
|
|
color_transfer=stream.get("color_transfer"),
|
|
color_primaries=stream.get("color_primaries"),
|
|
color_space=stream.get("color_space"),
|
|
)
|
|
|
|
|
|
async def probe_video(path: str, timeout_seconds: int = 30) -> VideoMediaInfo:
|
|
return await asyncio.to_thread(probe_video_sync, path, timeout_seconds)
|
|
|
|
|
|
def parse_tos_signed_url_expiry(url: str | None) -> tuple[datetime | None, datetime | None]:
|
|
if not url:
|
|
return None, None
|
|
params = {key.lower(): value for key, value in parse_qsl(urlsplit(url).query, keep_blank_values=True)}
|
|
date_text = params.get("x-tos-date")
|
|
expires_text = params.get("x-tos-expires")
|
|
if not date_text or not expires_text:
|
|
return None, None
|
|
try:
|
|
signed_at = datetime.strptime(date_text, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
|
|
expires_seconds = int(expires_text)
|
|
if expires_seconds <= 0:
|
|
return None, None
|
|
return signed_at, signed_at + timedelta(seconds=expires_seconds)
|
|
except Exception:
|
|
return None, None
|
|
|
|
|
|
async def probe_remote_url(url: str) -> bool:
|
|
timeout = httpx.Timeout(
|
|
connect=max(1, int(settings.VIDEO_UPSCALE_REMOTE_URL_PROBE_CONNECT_TIMEOUT_SECONDS or 3)),
|
|
read=max(1, int(settings.VIDEO_UPSCALE_REMOTE_URL_PROBE_READ_TIMEOUT_SECONDS or 5)),
|
|
write=5,
|
|
pool=5,
|
|
)
|
|
try:
|
|
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
|
async with client.stream("GET", url, headers={"Range": "bytes=0-0"}) as response:
|
|
if response.status_code not in {200, 206}:
|
|
return False
|
|
async for chunk in response.aiter_bytes():
|
|
return bool(chunk)
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def build_source_resource_url(source_local_path: str) -> str:
|
|
root = Path(settings.STORAGE_LOCAL_PATH).resolve()
|
|
path = Path(source_local_path).resolve()
|
|
try:
|
|
relative = path.relative_to(root).as_posix()
|
|
except ValueError as exc:
|
|
raise RuntimeError("超分源视频不在 STORAGE_LOCAL_PATH 下,无法生成签名 URL") from exc
|
|
return f"{settings.BASE_URL.rstrip('/')}/generate/videos/{relative}"
|
|
|
|
|
|
def build_local_source_signed_url(source_local_path: str, expire_seconds: int) -> str:
|
|
return build_resource_signed_url(
|
|
build_source_resource_url(source_local_path),
|
|
expire_seconds=max(600, int(expire_seconds)),
|
|
)
|
|
|
|
|
|
async def download_video_to_path(url: str, final_path: str, timeout_seconds: int) -> str:
|
|
if is_valid_file(final_path):
|
|
try:
|
|
await probe_video(final_path)
|
|
return final_path
|
|
except Exception:
|
|
safe_remove(final_path)
|
|
os.makedirs(os.path.dirname(final_path), exist_ok=True)
|
|
part_path = build_part_mp4_path(final_path)
|
|
timeout = httpx.Timeout(max(30, int(timeout_seconds)))
|
|
try:
|
|
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
|
async with client.stream("GET", url) as response:
|
|
response.raise_for_status()
|
|
with open(part_path, "wb") as file_obj:
|
|
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
|
|
file_obj.write(chunk)
|
|
if not is_valid_file(part_path):
|
|
raise RuntimeError("视频下载完成但临时文件为空")
|
|
await probe_video(part_path)
|
|
os.replace(part_path, final_path)
|
|
return final_path
|
|
except Exception:
|
|
safe_remove(part_path)
|
|
raise
|