diff --git a/backend/api/models/storage.py b/backend/api/models/storage.py index fa32b58..2afc9fb 100644 --- a/backend/api/models/storage.py +++ b/backend/api/models/storage.py @@ -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): """文件信息响应模型""" diff --git a/backend/api/storage_router.py b/backend/api/storage_router.py index 57374b4..e7ffa8b 100644 --- a/backend/api/storage_router.py +++ b/backend/api/storage_router.py @@ -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: diff --git a/backend/requirements.txt b/backend/requirements.txt index a50729d..9a2a535 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 \ No newline at end of file +alibabacloud-dypnsapi20170525>=2.0.0 +Pillow>=10.0.0 \ No newline at end of file diff --git a/backend/services/storage_service.py b/backend/services/storage_service.py index 6793068..692bd96 100644 --- a/backend/services/storage_service.py +++ b/backend/services/storage_service.py @@ -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": "文件上传成功" diff --git a/backend/test_output.mp3 b/backend/test_output.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 0742f4a..e2e55f6 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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'), diff --git a/frontend/src/components/BackgroundSelector.vue b/frontend/src/components/BackgroundSelector.vue index ace4b6d..6fd4cda 100644 --- a/frontend/src/components/BackgroundSelector.vue +++ b/frontend/src/components/BackgroundSelector.vue @@ -10,7 +10,11 @@ @click="selectBackground(background)" >