Files

548 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import hashlib
import logging
import mimetypes
import os
import shutil
import subprocess
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import NamedTuple
from urllib.parse import urlparse
import httpx
from fastapi import HTTPException, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.upload_resource import UploadResourceTypeEnum
from app.schemas.virtual_portrait_v3.upload import VpV3UploadOut
from app.services.video_cover_service import get_ffmpeg_bin
logger = logging.getLogger("videogen")
# 北京时间(UTC+8)统一基准:V3 业务所有时间写入 / 时间比较唯一参考
_BJ_TZ = timezone(timedelta(hours=8))
def _bj_now() -> datetime:
"""返回当前北京时间(UTC+8naive datetime(去掉 tzinfo,与 DB naive 存储一致)。"""
return datetime.now(_BJ_TZ).replace(tzinfo=None)
VP_V3_IMAGE_MAX_BYTES = 10 * 1024 * 1024
VP_V3_VIDEO_MAX_BYTES = 100 * 1024 * 1024
VP_V3_MODULE_NAME = "vp_v3_virtual"
IMAGE_EXT_ALLOWED = {"jpg", "jpeg", "png", "webp", "bmp"}
VIDEO_EXT_ALLOWED = {"mp4", "mov", "m4v", "webm"}
IMAGE_MIME_ALLOWED = {
"image/jpeg", "image/jpg", "image/png", "image/webp", "image/bmp",
}
VIDEO_MIME_ALLOWED = {
"video/mp4", "video/quicktime", "video/x-m4v", "video/webm",
}
# URL 下载相关默认值
URL_DOWNLOAD_CONNECT_TIMEOUT_SEC = 15
URL_DOWNLOAD_READ_TIMEOUT_SEC = 300 # 大文件下载可以久一点,读的时候会按大小上限中断
URL_DOWNLOAD_MAX_REDIRECTS = 5
URL_DOWNLOAD_CHUNK_BYTES = 1024 * 1024 # 1MB
class DownloadedAsset(NamedTuple):
"""URL 下载到本地后的结果。"""
url: str # 对外访问 URL(最终要存到 VpV3Asset.source_url 的)
filename: str # 落盘后的文件名
file_size_bytes: int # 实际文件大小
mime_type: str | None # 从响应头/扩展名推断出的 MIME
duration_seconds: float | None # 视频:ffprobe 探测到的时长(Image 为 None)
suggested_name: str | None # 从 URL 或 Content-Disposition 推断的展示名(无扩展名)
def _max_bytes(asset_type: str) -> int:
return VP_V3_VIDEO_MAX_BYTES if asset_type == UploadResourceTypeEnum.F_VIDEO.value else VP_V3_IMAGE_MAX_BYTES
def _allowed_mime_set(asset_type: str) -> set[str]:
return VIDEO_MIME_ALLOWED if asset_type == UploadResourceTypeEnum.F_VIDEO.value else IMAGE_MIME_ALLOWED
def _safe_ext(filename: str, asset_type: str) -> str:
ext = (os.path.splitext(filename or "")[1].lower().lstrip(".") or "").strip()
allowed = VIDEO_EXT_ALLOWED if asset_type == UploadResourceTypeEnum.F_VIDEO.value else IMAGE_EXT_ALLOWED
if ext and ext in allowed:
return ext
# fallback
return "mp4" if asset_type == UploadResourceTypeEnum.F_VIDEO.value else "png"
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 _probe_duration_optional(video_path: Path) -> float | None:
"""尝试 ffprobe 探测视频时长,失败不抛,返回 None 让调用方自己处理。"""
import json as _json
try:
ffprobe_bin = _get_ffprobe_bin()
# 检查 ffprobe 是否可用
if not shutil.which(ffprobe_bin) and ffprobe_bin == "ffprobe":
logger.warning("ffprobe 未在系统 PATH 中找到,无法探测视频时长。请安装 ffprobe 并添加到 PATH。")
return None
timeout = int(getattr(settings, "SHOT_FFPROBE_TIMEOUT_SECONDS", 20) or 20)
cmd = [
ffprobe_bin,
"-v", "error",
"-show_entries", "format=duration",
"-of", "json",
str(video_path),
]
completed = subprocess.run(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, timeout=timeout, check=False,
)
if completed.returncode != 0:
logger.warning(
"ffprobe 执行失败: returncode=%s stderr=%s",
completed.returncode, completed.stderr[:200],
)
return None
if not completed.stdout:
return None
data = _json.loads(completed.stdout or "{}")
dur_raw = (data.get("format") or {}).get("duration")
if dur_raw is None:
return None
dur = float(dur_raw)
if dur <= 0:
return None
return dur
except subprocess.TimeoutExpired:
logger.warning("ffprobe 超时: %s", str(video_path))
return None
except Exception as exc: # noqa: BLE001
logger.warning("ffprobe 探测视频时长失败: %s", exc)
return None
def _build_destination(*, api_key_id: str, asset_type: str, original_filename: str, duration_seconds: float | None) -> tuple[Path, str, str]:
"""构建 vp_v3 上传存储路径 + 对外访问 URL。
存储路径:UPLOAD_LOCAL_PATH/api/private_portrait_virtual/{asset_type}/{yyyy}/{mm}/{dd}/{uuid}.{ext}
"""
now = _bj_now()
ext = _safe_ext(original_filename, asset_type)
safe_uuid = uuid.uuid4().hex
year = f"{now.year:04d}"
month = f"{now.month:02d}"
day = f"{now.day:02d}"
sub_type = "videos" if asset_type == UploadResourceTypeEnum.F_VIDEO.value else "images"
rel_dir = Path("api") / "private_portrait_virtual" / sub_type / year / month / day
filename = f"vp_v3_{safe_uuid}.{ext}"
base_dir = Path(settings.UPLOAD_LOCAL_PATH) if settings.UPLOAD_LOCAL_PATH else Path("./storage/uploads")
final_path = base_dir / rel_dir / filename
# URL 前缀 /uploads/...
base_url = settings.UPLOAD_BASE_URL.rstrip("/") if settings and getattr(settings, "UPLOAD_BASE_URL", None) else "/uploads"
rel_url = f"/{rel_dir.as_posix()}/{filename}".replace("//", "/")
url = base_url + rel_url
return final_path, url, filename
def _guess_filename_from_url(url: str, cd_header: str | None) -> str:
"""优先从 Content-Disposition 拿文件名,其次从 URL path 拿,再 fallback 到 uuid 名。"""
# 1. Content-Disposition
if cd_header:
# filename="a.jpg" 或 filename*=UTF-8''a.jpg
import re as _re
m1 = _re.search(r"""filename\*\s*=\s*UTF-8''([^;]+)""", cd_header, flags=_re.IGNORECASE)
if m1:
from urllib.parse import unquote
try:
return unquote(m1.group(1).strip().strip('"').strip("'"))
except Exception: # noqa: BLE001
pass
m2 = _re.search(r"""filename\s*=\s*"([^"]+)""", cd_header, flags=_re.IGNORECASE)
if m2:
return m2.group(1)
m3 = _re.search(r"""filename\s*=\s*([^;]+)""", cd_header, flags=_re.IGNORECASE)
if m3:
return m3.group(1).strip().strip('"').strip("'")
# 2. URL path
try:
parsed = urlparse(url)
base = os.path.basename(parsed.path or "")
if base and "." in base:
return base
except Exception: # noqa: BLE001
pass
return f"vp_v3_{uuid.uuid4().hex[:12]}"
def _guess_ext_from_mime(mime: str | None, asset_type: str) -> str | None:
if not mime:
return None
# 按 asset_type 优先匹配
allowed_exts = VIDEO_EXT_ALLOWED if asset_type == UploadResourceTypeEnum.F_VIDEO.value else IMAGE_EXT_ALLOWED
guesses = mimetypes.guess_all_extensions(mime.strip().lower()) or []
for g in guesses:
ext = g.lower().lstrip(".")
if ext in allowed_exts:
return ext
# 额外的手写映射
extra_map = {
"image/jpeg": "jpg", "image/jpg": "jpg",
"video/quicktime": "mov", "video/x-m4v": "m4v",
}
if mime.lower() in extra_map and extra_map[mime.lower()] in allowed_exts:
return extra_map[mime.lower()]
return None
# ---------------------------------------------------------------------------
# 1) 上传本地文件(保留旧 API 但走下载流程的也可以共用保存逻辑)
# ---------------------------------------------------------------------------
async def upload_asset_file(
db: AsyncSession,
*,
api_key_id: str,
file: UploadFile,
asset_type: str,
duration_seconds: float | None = None,
) -> VpV3UploadOut:
"""V3 虚拟素材上传(独立实现,不经过用户容量账本 UploadResource)。
- 校验 MIME/扩展名/大小
- 落盘到 /uploads/images|videos/vp_v3/{api_key_id_short}/{yyyy}/{mm}/{dd}/
- 返回 url + 虚拟 resource_idhash 形式)
"""
if asset_type not in {UploadResourceTypeEnum.F_IMAGE.value, UploadResourceTypeEnum.F_VIDEO.value}:
raise HTTPException(status_code=400, detail="虚拟素材上传仅支持图片或视频")
max_size = _max_bytes(asset_type)
temp_path: str | None = None
try:
# 1. 落临时文件并限制大小
size_acc = 0
temp_dir = settings.UPLOAD_TEMP_DIR if settings and getattr(settings, "UPLOAD_TEMP_DIR", None) else "./uploads/_tmp_vp_v3"
Path(temp_dir).mkdir(parents=True, exist_ok=True)
temp_path = os.path.join(temp_dir, f"vp_v3_{uuid.uuid4().hex}")
with open(temp_path, "wb") as f:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
size_acc += len(chunk)
if size_acc > max_size:
raise HTTPException(
status_code=400,
detail=f"文件大小超出限制:{asset_type} 最大 {max_size // (1024*1024)} MB",
)
f.write(chunk)
file_size_bytes = size_acc
if file_size_bytes <= 0:
raise HTTPException(status_code=400, detail="空文件不允许上传")
# 2. 构建最终路径 + URL
final_path, url, safe_filename = _build_destination(
api_key_id=api_key_id,
asset_type=asset_type,
original_filename=file.filename or safe_filename,
duration_seconds=duration_seconds,
)
final_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(temp_path, final_path)
temp_path = None
# 3. 虚拟 resource_id(用于素材删除时的文件清理定位:hash(url))
resource_id = "vpv3_" + hashlib.sha256(url.encode()).hexdigest()[:24]
return VpV3UploadOut(
url=url,
filename=safe_filename,
type=asset_type,
resource_id=resource_id,
file_size_bytes=file_size_bytes,
duration_seconds=duration_seconds,
)
except HTTPException:
raise
except Exception as exc: # noqa: BLE001
logger.exception("vp_v3 上传失败:%s", exc)
raise HTTPException(status_code=500, detail=f"上传失败:{exc}") from exc
finally:
if temp_path and os.path.exists(temp_path):
try:
os.remove(temp_path)
except Exception: # noqa: BLE001
pass
# ---------------------------------------------------------------------------
# 2) URL 下载到本地(新流程:创建素材时一步到位,由 create_asset 内部调用)
# ---------------------------------------------------------------------------
async def download_url_to_local(
*,
api_key_id: str,
asset_type: str,
source_url: str,
requested_filename: str | None = None,
) -> DownloadedAsset:
"""把传入的远程 URLhttp/https)下载到本地 vp_v3 上传目录,返回本地 URL + 元信息。
完整的错误处理:
- 非法 URL → 400
- 连接/超时 → 502(外部资源不可达)
- HTTP 4xx/5xx → 502 带状态码
- Content-Type 不在允许列表 → 415
- 超出大小上限(读内容时逐 chunk 检查)→ 413
- 下载一半失败 → 清理临时文件,不留下半截
- 视频可选探测 ffprobe,失败不抛错(调用方自行用 payload.video_duration
"""
if asset_type not in {UploadResourceTypeEnum.F_IMAGE.value, UploadResourceTypeEnum.F_VIDEO.value}:
raise HTTPException(status_code=400, detail="虚拟素材仅支持图片或视频")
# URL 合法性
if not source_url or not isinstance(source_url, str):
raise HTTPException(status_code=400, detail="source_url 不能为空")
parsed = urlparse(source_url.strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise HTTPException(status_code=400, detail="source_url 必须是合法的 http(s) URL")
# 拒绝私有/内网地址(SSRF 防御的最小集;生产环境可再严格)
import ipaddress
host_only = parsed.hostname or ""
try:
ip_obj = ipaddress.ip_address(host_only)
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved or ip_obj.is_link_local:
raise HTTPException(status_code=400, detail="source_url 不允许指向内网/本机地址")
except ValueError:
# 不是 IP,是域名 → 放行
pass
max_bytes = _max_bytes(asset_type)
allowed_mimes = _allowed_mime_set(asset_type)
# 用户代理:标成我们服务的 UA,避免一些图片防盗链 403
user_agent = (
"Mozilla/5.0 (compatible; VideoGenVPV3/1.0; +https://minzhongzc.com/)"
if getattr(settings, "VP_V3_DOWNLOAD_UA", None) is None
else str(getattr(settings, "VP_V3_DOWNLOAD_UA"))
)
temp_path: str | None = None
final_path: Path | None = None
# 外层初始化,保证 client.stream 内部 raise 的情况下外层仍然可访问
inferred_filename: str = f"vp_v3_{uuid.uuid4().hex[:12]}"
mime: str | None = None
size_acc: int = 0
try:
# --- 第一步:下载到临时文件,限制大小 + 校验响应头 ---
temp_dir = settings.UPLOAD_TEMP_DIR if settings and getattr(settings, "UPLOAD_TEMP_DIR", None) else "./uploads/_tmp_vp_v3"
Path(temp_dir).mkdir(parents=True, exist_ok=True)
temp_path = os.path.join(temp_dir, f"vp_v3_url_{uuid.uuid4().hex}")
transport = httpx.AsyncHTTPTransport(retries=1)
timeout = httpx.Timeout(
connect=URL_DOWNLOAD_CONNECT_TIMEOUT_SEC,
read=URL_DOWNLOAD_READ_TIMEOUT_SEC,
write=URL_DOWNLOAD_CONNECT_TIMEOUT_SEC,
pool=URL_DOWNLOAD_CONNECT_TIMEOUT_SEC,
)
headers = {"User-Agent": user_agent, "Accept": "*/*"}
async with httpx.AsyncClient(
timeout=timeout,
transport=transport,
follow_redirects=True,
max_redirects=URL_DOWNLOAD_MAX_REDIRECTS,
verify=bool(getattr(settings, "VP_V3_DOWNLOAD_VERIFY_SSL", True)),
) as client:
async with client.stream("GET", source_url.strip(), headers=headers) as resp:
# HTTP 状态码
if resp.status_code >= 400:
detail = f"远程资源返回状态码 {resp.status_code}"
try:
snippet = (await resp.aread())[:200]
if snippet:
detail += f",响应片段:{snippet.decode('utf-8', errors='ignore')}"
except Exception: # noqa: BLE001
pass
raise HTTPException(
status_code=502,
detail=f"source_url 下载失败(HTTP {resp.status_code}):" + detail,
)
# Content-Type 校验(没有就 fallback 到扩展名推断)
content_type_raw = resp.headers.get("Content-Type") or ""
mime = (content_type_raw.split(";")[0] or "").strip().lower() or None
if mime and mime not in allowed_mimes:
# 一些 CDN 会用 application/octet-stream,这种情况跳过 MIME 检查,用扩展名兜底
if mime != "application/octet-stream":
raise HTTPException(
status_code=415,
detail=(
f"不支持的 Content-Type{mime}。"
f"{asset_type} 仅支持:{', '.join(sorted(allowed_mimes))}"
),
)
# Content-Length 预估检查(存在且超出就直接拒,不下载)
content_length = resp.headers.get("Content-Length")
if content_length:
try:
cl = int(content_length)
if cl > max_bytes:
raise HTTPException(
status_code=413,
detail=(
f"远程资源太大(Content-Length={cl}),超过 "
f"{asset_type} 上限 {max_bytes} 字节"
),
)
except ValueError:
pass
# filename 推断(用于扩展名 + 展示名)
cd = resp.headers.get("Content-Disposition")
inferred_filename = _guess_filename_from_url(source_url, cd)
if requested_filename:
# 若用户传了 name 就优先用它做展示名,但扩展名仍然以 mime/url 推断为准
try:
base_display = os.path.splitext(os.path.basename(requested_filename))[0]
old_ext = os.path.splitext(inferred_filename)[1] if inferred_filename else ""
inferred_filename = base_display + (old_ext or "")
except Exception: # noqa: BLE001
pass
# 扩展名再精化:如果 MIME 能得出扩展名,优先用
ext_from_mime = _guess_ext_from_mime(mime, asset_type)
if ext_from_mime:
try:
stem = os.path.splitext(inferred_filename)[0]
inferred_filename = f"{stem}.{ext_from_mime}"
except Exception: # noqa: BLE001
pass
# 流式下载到 temp_path,逐 chunk 检查大小
size_acc = 0
with open(temp_path, "wb") as f:
async for chunk in resp.aiter_bytes():
if not chunk:
continue
size_acc += len(chunk)
if size_acc > max_bytes:
raise HTTPException(
status_code=413,
detail=(
f"远程资源大小超过 {asset_type} 上限 "
f"{max_bytes // (1024*1024)} MB"
),
)
f.write(chunk)
# ======== 以下在 client.stream 退出后、但仍在 httpx.AsyncClient 上下文内执行 ========
# 空文件检查
file_size_bytes = size_acc
if file_size_bytes <= 0:
raise HTTPException(status_code=400, detail="远程 URL 返回空文件")
# --- 第二步:视频可选 ffprobe 探测时长 ---
duration: float | None = None
if asset_type == UploadResourceTypeEnum.F_VIDEO.value:
duration = _probe_duration_optional(Path(temp_path))
# --- 第三步:落到最终目录(与 _build_destination 一致的目录结构/权限) ---
final_path, url_out, final_filename = _build_destination(
api_key_id=api_key_id,
asset_type=asset_type,
original_filename=inferred_filename,
duration_seconds=duration,
)
final_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(temp_path, final_path)
temp_path = None
# 最终 MIME:按扩展名反推一个(如果之前没拿到)
if not mime:
mime, _ = mimetypes.guess_type(final_filename)
if not mime:
mime = "image/png" if asset_type == UploadResourceTypeEnum.F_IMAGE.value else "video/mp4"
suggested_name: str | None = None
try:
stem = os.path.splitext(inferred_filename or final_filename)[0]
if stem and not stem.startswith("vp_v3_"):
suggested_name = stem[:100] or None
except Exception: # noqa: BLE001
pass
return DownloadedAsset(
url=url_out,
filename=final_filename,
file_size_bytes=file_size_bytes,
mime_type=mime,
duration_seconds=duration,
suggested_name=suggested_name,
)
except HTTPException:
raise
except (httpx.TimeoutException, httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
raise HTTPException(status_code=502, detail=f"远程 URL 连接/读取超时:{exc}") from exc
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"远程 URL 下载失败:{exc}") from exc
except Exception as exc: # noqa: BLE001
logger.exception("vp_v3 URL 下载异常:url=%s err=%s", source_url, exc)
raise HTTPException(status_code=500, detail=f"URL 下载保存失败:{exc}") from exc
finally:
# 任何失败都清掉半截临时文件;但最终文件已经 move 过去了的就不动
if temp_path and os.path.exists(temp_path):
try:
os.remove(temp_path)
except Exception: # noqa: BLE001
pass
def delete_local_file_by_url(local_url: str) -> bool:
"""素材删除时根据 source_url 删除本地落盘文件(非强制,失败不抛)。"""
if not local_url:
return False
try:
base_url = settings.UPLOAD_BASE_URL.rstrip("/") if settings and getattr(settings, "UPLOAD_BASE_URL", None) else "/uploads"
rel_url = local_url
if rel_url.startswith(base_url):
rel_url = rel_url[len(base_url):]
if not rel_url.startswith("/"):
return False
# /uploads/api/private_portrait_virtual/... → /api/private_portrait_virtual/... → UPLOAD_LOCAL_PATH/api/private_portrait_virtual/...
sub_part = rel_url[len("/uploads"):] if rel_url.startswith("/uploads") else rel_url
base_dir = Path(settings.UPLOAD_LOCAL_PATH) if settings.UPLOAD_LOCAL_PATH else Path("./storage/uploads")
target = (base_dir / sub_part.lstrip("/")).resolve()
base_dir_resolved = base_dir.resolve()
# 仅允许删除 base_dir 下的文件(目录穿越防御)
if not str(target).startswith(str(base_dir_resolved)):
return False
if target.is_file():
target.unlink(missing_ok=True)
return True
except Exception: # noqa: BLE001
logger.exception("vp_v3 清理本地文件失败:%s", local_url)
return False