diff --git a/backend/app/api/materials.py b/backend/app/api/materials.py index 29b3443..19984b8 100644 --- a/backend/app/api/materials.py +++ b/backend/app/api/materials.py @@ -1,19 +1,33 @@ from fastapi import APIRouter, UploadFile, File, HTTPException from app.core.config import settings import shutil -import uuid +import re +import time from pathlib import Path router = APIRouter() + +def sanitize_filename(filename: str) -> str: + """清理文件名,移除不安全字符""" + # 移除路径分隔符和特殊字符 + safe_name = re.sub(r'[<>:"/\\|?*]', '_', filename) + # 限制长度 + if len(safe_name) > 100: + ext = Path(safe_name).suffix + safe_name = safe_name[:100 - len(ext)] + ext + return safe_name + + @router.post("/") async def upload_material(file: UploadFile = File(...)): if not file.filename.lower().endswith(('.mp4', '.mov', '.avi')): raise HTTPException(400, "Invalid format") - file_id = str(uuid.uuid4()) - ext = Path(file.filename).suffix - save_path = settings.UPLOAD_DIR / "materials" / f"{file_id}{ext}" + # 使用时间戳+原始文件名(保留原始名称,避免冲突) + timestamp = int(time.time()) + safe_name = sanitize_filename(file.filename) + save_path = settings.UPLOAD_DIR / "materials" / f"{timestamp}_{safe_name}" # Save file with open(save_path, "wb") as buffer: @@ -21,11 +35,14 @@ async def upload_material(file: UploadFile = File(...)): # Calculate size size_mb = save_path.stat().st_size / (1024 * 1024) + + # 提取显示名称(去掉时间戳前缀) + display_name = safe_name return { - "id": file_id, - "name": file.filename, - "path": f"uploads/materials/{file_id}{ext}", + "id": save_path.stem, + "name": display_name, + "path": f"uploads/materials/{save_path.name}", "size_mb": size_mb, "type": "video" } @@ -38,9 +55,16 @@ async def list_materials(): for f in materials_dir.glob("*"): try: stat = f.stat() + # 提取显示名称:去掉时间戳前缀 (格式: {timestamp}_{原始文件名}) + display_name = f.name + if '_' in f.name: + parts = f.name.split('_', 1) + if parts[0].isdigit(): + display_name = parts[1] # 原始文件名 + files.append({ "id": f.stem, - "name": f.name, + "name": display_name, "path": f"uploads/materials/{f.name}", "size_mb": stat.st_size / (1024 * 1024), "type": "video", @@ -51,3 +75,26 @@ async def list_materials(): # Sort by creation time desc files.sort(key=lambda x: x.get("created_at", 0), reverse=True) return {"materials": files} + + +@router.delete("/{material_id}") +async def delete_material(material_id: str): + """删除素材文件""" + materials_dir = settings.UPLOAD_DIR / "materials" + + # 查找匹配的文件(ID 是文件名不含扩展名) + found = None + for f in materials_dir.glob("*"): + if f.stem == material_id: + found = f + break + + if not found: + raise HTTPException(404, "Material not found") + + try: + found.unlink() + return {"success": True, "message": "素材已删除"} + except Exception as e: + raise HTTPException(500, f"删除失败: {str(e)}") + diff --git a/backend/app/api/videos.py b/backend/app/api/videos.py index 8fd08c4..5c8ac3d 100644 --- a/backend/app/api/videos.py +++ b/backend/app/api/videos.py @@ -141,3 +141,58 @@ async def lipsync_health(): """获取 LipSync 服务健康状态""" lipsync = _get_lipsync_service() return await lipsync.check_health() + + +@router.get("/generated") +async def list_generated_videos(): + """从文件系统读取生成的视频列表(持久化)""" + output_dir = settings.OUTPUT_DIR + videos = [] + + if output_dir.exists(): + for f in output_dir.glob("*_output.mp4"): + try: + stat = f.stat() + videos.append({ + "id": f.stem, + "name": f.name, + "path": f"/outputs/{f.name}", + "size_mb": stat.st_size / (1024 * 1024), + "created_at": stat.st_ctime + }) + except Exception: + continue + + # Sort by creation time desc (newest first) + videos.sort(key=lambda x: x.get("created_at", 0), reverse=True) + return {"videos": videos} + + +@router.delete("/generated/{video_id}") +async def delete_generated_video(video_id: str): + """删除生成的视频""" + output_dir = settings.OUTPUT_DIR + + # 查找匹配的文件 + found = None + for f in output_dir.glob("*.mp4"): + if f.stem == video_id: + found = f + break + + if not found: + raise HTTPException(404, "Video not found") + + try: + found.unlink() + # 同时删除相关的临时文件(如果存在) + task_id = video_id.replace("_output", "") + for suffix in ["_audio.mp3", "_lipsync.mp4"]: + temp_file = output_dir / f"{task_id}{suffix}" + if temp_file.exists(): + temp_file.unlink() + + return {"success": True, "message": "视频已删除"} + except Exception as e: + raise HTTPException(500, f"删除失败: {str(e)}") + diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 062c840..a31cddf 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -27,10 +27,63 @@ body { /* 隐藏滚动条但保留滚动功能 */ html { - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE 和 Edge */ + scrollbar-width: none; + /* Firefox */ + -ms-overflow-style: none; + /* IE 和 Edge */ } html::-webkit-scrollbar { - display: none; /* Chrome, Safari, Opera */ + display: none; + /* Chrome, Safari, Opera */ } + +/* 自定义滚动条样式 - 深色主题 */ +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: rgba(147, 51, 234, 0.5) transparent; +} + +.custom-scrollbar::-webkit-scrollbar { + width: 6px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: rgba(147, 51, 234, 0.5); + border-radius: 3px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: rgba(147, 51, 234, 0.8); +} + +/* 完全隐藏滚动条 */ +.hide-scrollbar { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.hide-scrollbar::-webkit-scrollbar { + display: none; +} + +/* 自定义 select 下拉菜单 */ +.custom-select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%239ca3af' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 36px; +} + +.custom-select option { + background: #1a1a2e; + color: white; + padding: 12px; +} \ No newline at end of file diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index a54a9c5..9d3a1ac 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -26,6 +26,14 @@ interface Task { download_url?: string; } +interface GeneratedVideo { + id: string; + name: string; + path: string; + size_mb: number; + created_at: number; +} + export default function Home() { const [materials, setMaterials] = useState([]); const [selectedMaterial, setSelectedMaterial] = useState(""); @@ -41,6 +49,8 @@ export default function Home() { const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [uploadError, setUploadError] = useState(null); + const [generatedVideos, setGeneratedVideos] = useState([]); + const [selectedVideoId, setSelectedVideoId] = useState(null); // 可选音色 const voices = [ @@ -51,9 +61,10 @@ export default function Home() { { id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" }, ]; - // 加载素材列表 + // 加载素材列表和历史视频 useEffect(() => { fetchMaterials(); + fetchGeneratedVideos(); }, []); const fetchMaterials = async () => { @@ -87,6 +98,60 @@ export default function Home() { } }; + // 获取已生成的视频列表(持久化) + const fetchGeneratedVideos = async () => { + try { + const res = await fetch(`${API_BASE}/api/videos/generated`); + if (res.ok) { + const data = await res.json(); + setGeneratedVideos(data.videos || []); + } + } catch (error) { + console.error("获取历史视频失败:", error); + } + }; + + // 删除素材 + const deleteMaterial = async (materialId: string) => { + if (!confirm("确定要删除这个素材吗?")) return; + try { + const res = await fetch(`${API_BASE}/api/materials/${materialId}`, { + method: "DELETE", + }); + if (res.ok) { + fetchMaterials(); + if (selectedMaterial === materialId) { + setSelectedMaterial(""); + } + } else { + alert("删除失败"); + } + } catch (error) { + alert("删除失败: " + error); + } + }; + + // 删除生成的视频 + const deleteVideo = async (videoId: string) => { + if (!confirm("确定要删除这个视频吗?")) return; + try { + const res = await fetch(`${API_BASE}/api/videos/generated/${videoId}`, { + method: "DELETE", + }); + if (res.ok) { + fetchGeneratedVideos(); + if (selectedVideoId === videoId) { + setSelectedVideoId(null); + setGeneratedVideo(null); + } + } else { + alert("删除失败"); + } + } catch (error) { + alert("删除失败: " + error); + } + }; + // 上传视频 const handleUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -181,6 +246,7 @@ export default function Home() { if (taskData.status === "completed") { setGeneratedVideo(`${API_BASE}${taskData.download_url}`); setIsGenerating(false); + fetchGeneratedVideos(); // 刷新历史视频列表 } else if (taskData.status === "failed") { alert("视频生成失败: " + taskData.message); setIsGenerating(false); @@ -320,21 +386,35 @@ export default function Home() { ) : (
{materials.map((m) => ( - + + +
))} )} @@ -471,16 +551,66 @@ export default function Home() { )} + + {/* 历史视频列表 */} +
+
+

+ 📂 历史视频 +

+ +
+ {generatedVideos.length === 0 ? ( +
+

暂无生成的视频

+
+ ) : ( +
+ {generatedVideos.map((v) => ( +
+ + +
+ ))} +
+ )} +
- - {/* Footer */} -
-
- ViGent - 基于 MuseTalk + EdgeTTS -
-
); } diff --git a/frontend/src/app/publish/page.tsx b/frontend/src/app/publish/page.tsx index 1e4ed5f..60c52c6 100644 --- a/frontend/src/app/publish/page.tsx +++ b/frontend/src/app/publish/page.tsx @@ -52,20 +52,18 @@ export default function PublishPage() { const fetchVideos = async () => { try { - // 获取已生成的视频列表 (从 outputs 目录) - const res = await fetch(`${API_BASE}/api/videos/tasks`); + // 使用持久化的视频列表 API(从文件系统读取) + const res = await fetch(`${API_BASE}/api/videos/generated`); const data = await res.json(); - const completedVideos = data.tasks - ?.filter((t: any) => t.status === "completed") - .map((t: any) => ({ - name: `${t.task_id}_output.mp4`, - path: `outputs/${t.task_id}_output.mp4`, - })) || []; + const videos = (data.videos || []).map((v: any) => ({ + name: new Date(v.created_at * 1000).toLocaleString('zh-CN') + ` (${v.size_mb.toFixed(1)}MB)`, + path: v.path.startsWith('/') ? v.path.slice(1) : v.path, // 移除开头的 / + })); - setVideos(completedVideos); - if (completedVideos.length > 0) { - setSelectedVideo(completedVideos[0].path); + setVideos(videos); + if (videos.length > 0) { + setSelectedVideo(videos[0].path); } } catch (error) { console.error("获取视频失败:", error); @@ -281,7 +279,7 @@ export default function PublishPage() {