212 lines
7.2 KiB
Python
212 lines
7.2 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from app.config import settings
|
|
|
|
VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"}
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class UploadVideoAsset:
|
|
url: str
|
|
path: Path
|
|
duration_seconds: float
|
|
|
|
|
|
def _project_root() -> Path:
|
|
return Path.cwd()
|
|
|
|
|
|
def _abs_path(value: str | Path) -> Path:
|
|
path = Path(value)
|
|
if not path.is_absolute():
|
|
path = _project_root() / path
|
|
return path.resolve()
|
|
|
|
|
|
def upload_root() -> Path:
|
|
return _abs_path(settings.UPLOAD_LOCAL_PATH)
|
|
|
|
|
|
def shot_segment_root() -> Path:
|
|
return _abs_path(getattr(settings, "SHOT_SEGMENT_LOCAL_PATH", "./storage/uploads/shot_segments"))
|
|
|
|
|
|
def _strip_base_url(url: str) -> str:
|
|
text = str(url or "").strip()
|
|
if not text:
|
|
return text
|
|
|
|
base_url = str(getattr(settings, "BASE_URL", "") or "").strip().rstrip("/")
|
|
if base_url and text.startswith(base_url + "/"):
|
|
return text[len(base_url):]
|
|
|
|
parsed = urlparse(text)
|
|
if parsed.scheme in ("http", "https"):
|
|
# 只接受本系统 BASE_URL 下的上传资源;外部 URL 不允许 ffmpeg 本地切片。
|
|
raise HTTPException(status_code=400, detail="拆镜源视频必须来自本系统上传接口,不能传外部 http/https URL")
|
|
|
|
return text
|
|
|
|
|
|
def _safe_relative_from_upload_url(url: str) -> str:
|
|
value = _strip_base_url(url)
|
|
value = value.split("?", 1)[0].split("#", 1)[0]
|
|
|
|
if value.startswith("/uploads/"):
|
|
rel = value.replace("/uploads/", "", 1)
|
|
elif value.startswith("uploads/"):
|
|
rel = value.replace("uploads/", "", 1)
|
|
else:
|
|
raise HTTPException(status_code=400, detail="拆镜源视频链接必须是 /uploads/ 下的上传资源")
|
|
|
|
rel = rel.lstrip("/")
|
|
if not rel or ".." in Path(rel).parts:
|
|
raise HTTPException(status_code=400, detail="上传视频路径非法")
|
|
return rel
|
|
|
|
|
|
def resolve_upload_video_path(video_url: str) -> Path:
|
|
rel = _safe_relative_from_upload_url(video_url)
|
|
root = upload_root()
|
|
path = (root / rel).resolve()
|
|
|
|
try:
|
|
path.relative_to(root)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail="上传视频路径越界") from exc
|
|
|
|
if path.suffix.lower() not in VIDEO_EXTENSIONS:
|
|
raise HTTPException(status_code=400, detail="上传资源不是支持的视频格式")
|
|
if not path.exists() or not path.is_file():
|
|
raise HTTPException(status_code=404, detail=f"上传视频文件不存在: {video_url}")
|
|
return path
|
|
|
|
|
|
def build_upload_url_from_path(path: str | Path) -> str:
|
|
root = upload_root()
|
|
checked_path = _abs_path(path)
|
|
try:
|
|
rel = checked_path.relative_to(root).as_posix()
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=500, detail="生成上传资源 URL 失败:路径不在 uploads 目录下") from exc
|
|
return f"/uploads/{rel}"
|
|
|
|
|
|
def get_ffmpeg_bin() -> str:
|
|
return str(getattr(settings, "FFMPEG_BIN", "") or "ffmpeg")
|
|
|
|
|
|
def get_ffprobe_bin() -> str:
|
|
configured = str(getattr(settings, "FFPROBE_BIN", "") or "").strip()
|
|
if configured:
|
|
return configured
|
|
ffmpeg_bin = get_ffmpeg_bin()
|
|
if ffmpeg_bin.endswith("ffmpeg.exe"):
|
|
return ffmpeg_bin[:-10] + "ffprobe.exe"
|
|
if ffmpeg_bin.endswith("ffmpeg"):
|
|
return ffmpeg_bin[:-6] + "ffprobe"
|
|
return "ffprobe"
|
|
|
|
|
|
def probe_video_duration_seconds(video_path: str | Path) -> float:
|
|
path = _abs_path(video_path)
|
|
timeout = int(getattr(settings, "SHOT_FFPROBE_TIMEOUT_SECONDS", 20) or 20)
|
|
cmd = [
|
|
get_ffprobe_bin(),
|
|
"-v", "error",
|
|
"-show_entries", "format=duration",
|
|
"-of", "json",
|
|
str(path),
|
|
]
|
|
try:
|
|
completed = subprocess.run(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
timeout=timeout,
|
|
check=False,
|
|
)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=400, detail=f"ffprobe 获取视频时长失败: {exc}") from exc
|
|
|
|
if completed.returncode != 0:
|
|
raise HTTPException(status_code=400, detail=f"ffprobe 获取视频时长失败: {completed.stderr.strip()}")
|
|
|
|
try:
|
|
data = json.loads(completed.stdout or "{}")
|
|
duration = float((data.get("format") or {}).get("duration") or 0)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=400, detail="ffprobe 返回的视频时长无法解析") from exc
|
|
|
|
if duration <= 0:
|
|
raise HTTPException(status_code=400, detail="视频时长无效")
|
|
return round(duration, 3)
|
|
|
|
|
|
def validate_upload_video_asset(video_url: str, frontend_duration_seconds: float | None = None) -> UploadVideoAsset:
|
|
path = resolve_upload_video_path(video_url)
|
|
real_duration = probe_video_duration_seconds(path)
|
|
|
|
if frontend_duration_seconds is not None and frontend_duration_seconds > 0:
|
|
tolerance = float(getattr(settings, "SHOT_DURATION_TOLERANCE_SECONDS", 1.0) or 1.0)
|
|
# 超出误差时以后端 ffprobe 为准,不拒绝,避免前端浮点或浏览器 metadata 偏差导致创建失败。
|
|
if abs(float(frontend_duration_seconds) - real_duration) <= tolerance:
|
|
real_duration = round(float(frontend_duration_seconds), 3)
|
|
|
|
return UploadVideoAsset(url=_strip_base_url(video_url), path=path, duration_seconds=real_duration)
|
|
|
|
|
|
def validate_split_range(*, start_second: float, end_second: float, video_duration_seconds: float) -> tuple[float, float, float]:
|
|
start = round(float(start_second), 3)
|
|
end = round(float(end_second), 3)
|
|
|
|
if start < 0:
|
|
raise HTTPException(status_code=400, detail="开始秒不能小于0")
|
|
if end <= start:
|
|
raise HTTPException(status_code=400, detail="结束秒必须大于开始秒")
|
|
|
|
tolerance = float(getattr(settings, "SHOT_SPLIT_END_TOLERANCE_SECONDS", 0.5) or 0.5)
|
|
if end > float(video_duration_seconds) + tolerance:
|
|
raise HTTPException(status_code=400, detail="结束秒不能超过视频总时长")
|
|
|
|
duration = round(end - start, 3)
|
|
min_seconds = float(getattr(settings, "SHOT_SPLIT_MIN_SECONDS", 1) or 1)
|
|
max_seconds = float(getattr(settings, "SHOT_SPLIT_MAX_SECONDS", 120) or 120)
|
|
if duration < min_seconds:
|
|
raise HTTPException(status_code=400, detail=f"拆镜片段不能低于 {min_seconds:g} 秒")
|
|
if duration > max_seconds:
|
|
raise HTTPException(status_code=400, detail=f"拆镜片段不能超过 {max_seconds:g} 秒")
|
|
return start, end, duration
|
|
|
|
|
|
def format_second(value: float) -> int | float:
|
|
checked = float(value)
|
|
if checked.is_integer():
|
|
return int(checked)
|
|
return round(checked, 2)
|
|
|
|
|
|
def build_time_node(start_second: float, end_second: float) -> str:
|
|
return f"{format_second(start_second)}-{format_second(end_second)}秒"
|
|
|
|
|
|
def ensure_shot_segment_dir(date_dir: str) -> Path:
|
|
root = shot_segment_root()
|
|
output_dir = (root / date_dir).resolve()
|
|
try:
|
|
output_dir.relative_to(root)
|
|
except ValueError as exc:
|
|
raise RuntimeError("拆镜输出目录越界") from exc
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
return output_dir
|