优化一下视频背景上传功能
This commit is contained in:
parent
2c8aa22548
commit
7ab19ef4fa
@ -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):
|
||||
"""文件信息响应模型"""
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
@ -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": "文件上传成功"
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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, // 1表示图片,2表示视频
|
||||
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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user