优化一下视频背景上传功能

This commit is contained in:
taiyi 2026-04-15 22:04:48 +08:00
parent 2c8aa22548
commit 7ab19ef4fa
8 changed files with 117 additions and 19 deletions

View File

@ -14,6 +14,7 @@ class UploadResponse(BaseModel):
file_type: str = Field(..., description="文件类型")
file_size: int = Field(..., description="文件大小(字节)")
message: str = Field(..., description="响应消息")
thumbnail_url: Optional[str] = Field(None, description="自动生成的缩略图URL")
class FileInfoResponse(BaseModel):
"""文件信息响应模型"""

View File

@ -78,7 +78,8 @@ async def upload_file(
file_url=result["file_url"],
file_type=result.get("metadata", {}).get("file_type", "unknown"),
file_size=file_size,
message=result.get("message", "上传成功")
message=result.get("message", "上传成功"),
thumbnail_url=result.get("thumbnail_url") or result.get("metadata", {}).get("thumbnail_url")
)
except HTTPException:
@ -148,7 +149,8 @@ async def upload_file_base64(
file_url=result["file_url"],
file_type=result.get("metadata", {}).get("file_type", "unknown"),
file_size=len(file_data),
message=result.get("message", "上传成功")
message=result.get("message", "上传成功"),
thumbnail_url=result.get("thumbnail_url") or result.get("metadata", {}).get("thumbnail_url")
)
except HTTPException:

View File

@ -21,4 +21,5 @@ pydub>=0.25.1
httpx>=0.25.2
tiktoken>=0.5.2
volcenginesdkarkruntime>=1.0.0
alibabacloud-dypnsapi20170525>=2.0.0
alibabacloud-dypnsapi20170525>=2.0.0
Pillow>=10.0.0

View File

@ -6,6 +6,7 @@
import os
import uuid
import hashlib
import subprocess
from typing import Optional, Dict, Any, List
from datetime import datetime
from pathlib import Path
@ -156,6 +157,41 @@ class StorageService:
return "unknown"
def _is_video_file(self, filename: str) -> bool:
return self._detect_file_type(filename) == "video"
def _generate_video_thumbnail(self, video_path: Path, stored_filename: str) -> Optional[str]:
"""为视频生成封面图并返回可访问URL。"""
try:
if not video_path.exists():
return None
thumb_path = video_path.with_suffix(".jpg")
thumb_path.parent.mkdir(parents=True, exist_ok=True)
# 优先使用 ffmpeg 截取第 1 帧
ffmpeg_cmd = [
"ffmpeg",
"-y",
"-i", str(video_path),
"-ss", "00:00:01",
"-vframes", "1",
str(thumb_path)
]
result = subprocess.run(ffmpeg_cmd, capture_output=True, timeout=30)
if result.returncode != 0 or not thumb_path.exists() or thumb_path.stat().st_size == 0:
if thumb_path.exists():
thumb_path.unlink(missing_ok=True)
return None
return f"/uploads/{thumb_path.relative_to(self.storage_dir).as_posix()}"
except FileNotFoundError:
logger.warning("ffmpeg not found, skip auto thumbnail generation")
return None
except Exception as e:
logger.warning(f"Generate video thumbnail failed: {e}")
return None
def _validate_file(self, filename: str, file_size: int, file_data: bytes) -> Dict[str, Any]:
"""验证文件"""
# 应用层不限制文件大小,剩余限制只可能来自代理、服务器或磁盘空间。
@ -231,17 +267,33 @@ class StorageService:
if metadata:
file_metadata.update(metadata)
# 上传到存储后端
if self.storage_type == "local":
result = await self._upload_to_local(file_data, stored_filename, file_metadata)
elif self.storage_type == "cos":
result = await self._upload_to_cos(file_data, stored_filename, file_metadata)
elif self.storage_type == "oss":
result = await self._upload_to_oss(file_data, stored_filename, file_metadata)
elif self.storage_type == "s3":
result = await self._upload_to_s3(file_data, stored_filename, file_metadata)
thumbnail_url = None
if validation["file_type"] == "video":
if self.storage_type == "local":
temp_path = self.storage_dir / stored_filename
temp_path.parent.mkdir(parents=True, exist_ok=True)
with open(temp_path, "wb") as f:
f.write(file_data)
thumbnail_url = self._generate_video_thumbnail(temp_path, stored_filename)
result = await self._upload_to_local(file_data, stored_filename, file_metadata)
else:
result = await self._upload_to_local(file_data, stored_filename, file_metadata)
else:
raise ValueError(f"Unsupported storage type: {self.storage_type}")
# 上传到存储后端
if self.storage_type == "local":
result = await self._upload_to_local(file_data, stored_filename, file_metadata)
elif self.storage_type == "cos":
result = await self._upload_to_cos(file_data, stored_filename, file_metadata)
elif self.storage_type == "oss":
result = await self._upload_to_oss(file_data, stored_filename, file_metadata)
elif self.storage_type == "s3":
result = await self._upload_to_s3(file_data, stored_filename, file_metadata)
else:
raise ValueError(f"Unsupported storage type: {self.storage_type}")
if validation["file_type"] == "video" and thumbnail_url:
result["thumbnail_url"] = thumbnail_url
result.setdefault("metadata", {})["thumbnail_url"] = thumbnail_url
logger.info(f"File uploaded successfully: {filename} -> {stored_filename}")
return result
@ -280,6 +332,10 @@ class StorageService:
file_obj.write(chunk)
file_size += len(chunk)
thumbnail_url = None
if validation["file_type"] == "video":
thumbnail_url = self._generate_video_thumbnail(file_path, stored_filename)
file_metadata = {
"original_filename": filename,
"stored_filename": stored_filename,
@ -288,7 +344,8 @@ class StorageService:
"file_hash": self._calculate_file_hash_from_path(file_path),
"mime_type": validation["mime_type"],
"upload_time": datetime.now().isoformat(),
"user_id": user_id or ""
"user_id": user_id or "",
"thumbnail_url": thumbnail_url or ""
}
if metadata:
@ -301,6 +358,7 @@ class StorageService:
"success": True,
"filename": stored_filename,
"file_url": access_url,
"thumbnail_url": thumbnail_url,
"file_path": str(file_path),
"metadata": file_metadata,
"message": "文件上传成功"

View File

@ -161,7 +161,8 @@ export const smsAPI = {
export const storageAPI = {
upload: (formData) => apiClient.post(API_ENDPOINTS.STORAGE.UPLOAD, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 0
}),
getFile: (filename) => apiClient.get(API_ENDPOINTS.STORAGE.FILE(filename)),
download: (filename) => apiClient.get(API_ENDPOINTS.STORAGE.FILE(filename) + '/download'),

View File

@ -10,7 +10,11 @@
@click="selectBackground(background)"
>
<div class="background-image">
<img :src="background.resource_url" :alt="background.name" />
<img
:src="getBackgroundPreview(background)"
:alt="background.name"
@error="handlePreviewError"
/>
<div class="overlay" v-if="background.is_locked && !background.is_owned">
<el-icon><Lock /></el-icon>
<span>{{ background.price_points }} 积分</span>
@ -48,6 +52,11 @@ export default {
}
},
emits: ['background-selected'],
data() {
return {
defaultThumbnail: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDIwMCAyMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3Qgd2lkdGg9IjIwMCIgaGVpZ2h0PSIyMDAiIGZpbGw9IiNGM0Y0RjYiLz48cGF0aCBkPSJNODcgMTI1TDEwMCA5MEwxMzMgMTI1SDg3WiIgZmlsbD0iIzlDQTNBRiIvPjwvc3ZnPg=='
}
},
methods: {
selectBackground(background) {
// Only allow selection if background is not locked or user owns it
@ -57,6 +66,24 @@ export default {
// Show notification about locked background
this.$message.warning(`此背景需要 ${background.price_points} 积分解锁`)
}
},
isImageUrl(url) {
if (!url || typeof url !== 'string') return false
const cleanUrl = url.split('?')[0].split('#')[0].toLowerCase()
return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'].some(ext => cleanUrl.endsWith(ext))
},
getBackgroundPreview(background) {
if (background?.resource_type === 2) {
return (background.thumbnail && this.isImageUrl(background.thumbnail))
? background.thumbnail
: this.defaultThumbnail
}
return background?.thumbnail || background?.resource_url || this.defaultThumbnail
},
handlePreviewError(event) {
if (event?.target && event.target.src !== this.defaultThumbnail) {
event.target.src = this.defaultThumbnail
}
}
}
}

View File

@ -64,10 +64,13 @@
<video
v-if="bg.type === 'video'"
:src="bg.resource_url"
:poster="bg.thumbnail || ''"
:alt="bg.name"
class="card-video"
muted
loop
playsinline
preload="metadata"
/>
<!-- 图片背景 -->
<img
@ -313,11 +316,14 @@
<video
v-if="previewBackground?.type === 'video'"
:src="previewBackground?.resource_url"
:poster="previewBackground?.thumbnail || ''"
class="preview-video"
controls
autoplay
loop
muted
playsinline
preload="metadata"
/>
<!-- 图片背景 -->
<img
@ -403,7 +409,7 @@ export default {
visibility: bg.visibility === 1 ? 'public' : (bg.visibility === 2 ? 'vip' : 'private'),
category: bg.category || 'default',
device_type: bg.device_type || 1,
thumbnail: bg.thumbnail || '',
thumbnail: bg.thumbnail || (bg.resource_type === 2 ? '' : bg.resource_url) || '',
bgm_url: bg.bgm_url || '',
created_at: bg.created_at,
user_ids: [],
@ -929,10 +935,12 @@ export default {
}
//
const fileUrl = uploadResponse.data?.file_url
const thumbnailUrl = uploadResponse.data?.thumbnail_url || (uploadForm.type === 'video' ? '' : fileUrl)
const backgroundData = {
name: uploadForm.name,
description: uploadForm.description,
resource_url: uploadResponse.data?.file_url,
resource_url: fileUrl,
resource_type: uploadForm.type === 'video' ? 2 : 1, // 12
visibility: uploadForm.visibility === 'public' ? 1 : (uploadForm.visibility === 'vip' ? 2 : 0), // 0=private, 1=public, 2=vip
is_locked: uploadForm.visibility !== 'public',
@ -940,7 +948,7 @@ export default {
price_points: uploadForm.visibility === 'vip' ? 100 : 0,
category: uploadForm.category,
device_type: uploadForm.device_type, // 1: , 2:
thumbnail: uploadResponse.data?.file_url // 使URL
thumbnail: thumbnailUrl
}
try {