Compare commits

...

3 Commits

Author SHA1 Message Date
Kevin Wong
ad7ff7a385 界面优化 2026-01-22 11:14:42 +08:00
Kevin Wong
c7e2b4d363 文档更新 2026-01-22 09:54:32 +08:00
Kevin Wong
d5baa79448 文档更新 2026-01-22 09:52:29 +08:00
8 changed files with 400 additions and 66 deletions

View File

@@ -198,6 +198,21 @@ ViGent/Docs/
--- ---
## 🧾 全局文档更新清单 (Checklist)
> **每次提交重要变更时,请核对以下文件是否需要同步:**
| 优先级 | 文件路径 | 检查重点 |
| :---: | :--- | :--- |
| 🔥 **High** | `Docs/DevLogs/DayN.md` | **(最新日志)** 详细记录变更、修复、代码片段 |
| 🔥 **High** | `Docs/task_complete.md` | **(任务总览)** 更新 `[x]`、进度条、时间线 |
| ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 |
| ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 |
| 🧊 **Low** | `Docs/implementation_plan.md` | **(实施计划)** 核对计划与实际实现的差异 |
| 🧊 **Low** | `frontend/README.md` | **(前端文档)** 新页面路由、组件用法、UI变更 |
---
## 📅 DayN.md 更新规则(日常更新) ## 📅 DayN.md 更新规则(日常更新)
### 新建判断 (对话开始前) ### 新建判断 (对话开始前)

View File

@@ -10,7 +10,7 @@
- 🎬 **唇形同步** - LatentSync 1.6 驱动512×512 高分辨率 Diffusion 模型 - 🎬 **唇形同步** - LatentSync 1.6 驱动512×512 高分辨率 Diffusion 模型
- 🎙️ **TTS 配音** - EdgeTTS 多音色支持(云溪、晓晓等) - 🎙️ **TTS 配音** - EdgeTTS 多音色支持(云溪、晓晓等)
- 📱 **一键发布** - Playwright 自动发布到抖音小红书、B站等 - 📱 **全自动发布** - 扫码登录 + Cookie持久化支持多平台(B站/抖音/小红书)定时发布
- 🖥️ **Web UI** - Next.js 现代化界面 - 🖥️ **Web UI** - Next.js 现代化界面
- 🚀 **性能优化** - 视频预压缩、常驻模型服务 (0s加载) - 🚀 **性能优化** - 视频预压缩、常驻模型服务 (0s加载)

View File

@@ -1,19 +1,33 @@
from fastapi import APIRouter, UploadFile, File, HTTPException from fastapi import APIRouter, UploadFile, File, HTTPException
from app.core.config import settings from app.core.config import settings
import shutil import shutil
import uuid import re
import time
from pathlib import Path from pathlib import Path
router = APIRouter() 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("/") @router.post("/")
async def upload_material(file: UploadFile = File(...)): async def upload_material(file: UploadFile = File(...)):
if not file.filename.lower().endswith(('.mp4', '.mov', '.avi')): if not file.filename.lower().endswith(('.mp4', '.mov', '.avi')):
raise HTTPException(400, "Invalid format") raise HTTPException(400, "Invalid format")
file_id = str(uuid.uuid4()) # 使用时间戳+原始文件名(保留原始名称,避免冲突)
ext = Path(file.filename).suffix timestamp = int(time.time())
save_path = settings.UPLOAD_DIR / "materials" / f"{file_id}{ext}" safe_name = sanitize_filename(file.filename)
save_path = settings.UPLOAD_DIR / "materials" / f"{timestamp}_{safe_name}"
# Save file # Save file
with open(save_path, "wb") as buffer: with open(save_path, "wb") as buffer:
@@ -21,11 +35,14 @@ async def upload_material(file: UploadFile = File(...)):
# Calculate size # Calculate size
size_mb = save_path.stat().st_size / (1024 * 1024) size_mb = save_path.stat().st_size / (1024 * 1024)
# 提取显示名称(去掉时间戳前缀)
display_name = safe_name
return { return {
"id": file_id, "id": save_path.stem,
"name": file.filename, "name": display_name,
"path": f"uploads/materials/{file_id}{ext}", "path": f"uploads/materials/{save_path.name}",
"size_mb": size_mb, "size_mb": size_mb,
"type": "video" "type": "video"
} }
@@ -38,9 +55,16 @@ async def list_materials():
for f in materials_dir.glob("*"): for f in materials_dir.glob("*"):
try: try:
stat = f.stat() 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({ files.append({
"id": f.stem, "id": f.stem,
"name": f.name, "name": display_name,
"path": f"uploads/materials/{f.name}", "path": f"uploads/materials/{f.name}",
"size_mb": stat.st_size / (1024 * 1024), "size_mb": stat.st_size / (1024 * 1024),
"type": "video", "type": "video",
@@ -51,3 +75,26 @@ async def list_materials():
# Sort by creation time desc # Sort by creation time desc
files.sort(key=lambda x: x.get("created_at", 0), reverse=True) files.sort(key=lambda x: x.get("created_at", 0), reverse=True)
return {"materials": files} 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)}")

View File

@@ -141,3 +141,58 @@ async def lipsync_health():
"""获取 LipSync 服务健康状态""" """获取 LipSync 服务健康状态"""
lipsync = _get_lipsync_service() lipsync = _get_lipsync_service()
return await lipsync.check_health() 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)}")

View File

@@ -1,36 +1,72 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # ViGent2 Frontend
## Getting Started ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
First, run the development server: ## ✨ 核心功能
### 1. 视频生成 (`/`)
- **素材管理**: 拖拽上传人物视频,实时预览。
- **文案配音**: 集成 EdgeTTS支持多音色选择 (云溪 / 晓晓)。
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
- **结果预览**: 生成完成后直接播放下载。
### 2. 全自动发布 (`/publish`) [Day 7 新增]
- **多平台管理**: 统一管理 B站、抖音、小红书账号状态。
- **扫码登录**:
- 集成后端 Playwright 生成的 QR Code。
- 实时检测扫码状态 (Wait/Success)。
- Cookie 自动保存与状态同步。
- **发布配置**: 设置视频标题、标签、简介。
- **定时任务**: 支持 "立即发布" 或 "定时发布"。
## 🛠️ 技术栈
- **框架**: Next.js 14 (App Router)
- **样式**: TailwindCSS
- **图标**: Lucide React
- **组件**: 自定义现代化组件 (Glassmorphism 风格)
- **API**: Fetch API (对接后端 FastAPI :8006)
## 🚀 开发指南
### 安装依赖
```bash
npm install
```
### 启动开发服务器
默认运行在 **3002** 端口 (通过 `package.json` 配置):
```bash ```bash
npm run dev npm run dev
# or # 访问: http://localhost:3002
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. ### 目录结构
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ```
src/
├── app/
│ ├── page.tsx # 视频生成主页
│ ├── publish/ # 发布管理页
│ │ └── page.tsx
│ └── layout.tsx # 全局布局 (导航栏)
├── components/ # UI 组件
│ ├── VideoUploader.tsx # 视频上传
│ ├── StatusBadge.tsx # 状态徽章
│ └── ...
└── lib/ # 工具函数
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. ## 🔌 后端对接
## Learn More - **Base URL**: `http://localhost:8006`
- **代理配置**: Next.js Rewrites (如需) 或直接 CORS。
To learn more about Next.js, take a look at the following resources: ## 🎨 设计规范
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. - **主色调**: 深紫/黑色系 (Dark Mode)
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - **交互**: 悬停微动画 (Hover Effects)
- **响应式**: 适配桌面端大屏操作
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -27,10 +27,63 @@ body {
/* 隐藏滚动条但保留滚动功能 */ /* 隐藏滚动条但保留滚动功能 */
html { html {
scrollbar-width: none; /* Firefox */ scrollbar-width: none;
-ms-overflow-style: none; /* IE 和 Edge */ /* Firefox */
-ms-overflow-style: none;
/* IE 和 Edge */
} }
html::-webkit-scrollbar { 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;
}

View File

@@ -26,6 +26,14 @@ interface Task {
download_url?: string; download_url?: string;
} }
interface GeneratedVideo {
id: string;
name: string;
path: string;
size_mb: number;
created_at: number;
}
export default function Home() { export default function Home() {
const [materials, setMaterials] = useState<Material[]>([]); const [materials, setMaterials] = useState<Material[]>([]);
const [selectedMaterial, setSelectedMaterial] = useState<string>(""); const [selectedMaterial, setSelectedMaterial] = useState<string>("");
@@ -41,6 +49,8 @@ export default function Home() {
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
// 可选音色 // 可选音色
const voices = [ const voices = [
@@ -51,9 +61,10 @@ export default function Home() {
{ id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" }, { id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" },
]; ];
// 加载素材列表 // 加载素材列表和历史视频
useEffect(() => { useEffect(() => {
fetchMaterials(); fetchMaterials();
fetchGeneratedVideos();
}, []); }, []);
const fetchMaterials = async () => { 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<HTMLInputElement>) => { const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -181,6 +246,7 @@ export default function Home() {
if (taskData.status === "completed") { if (taskData.status === "completed") {
setGeneratedVideo(`${API_BASE}${taskData.download_url}`); setGeneratedVideo(`${API_BASE}${taskData.download_url}`);
setIsGenerating(false); setIsGenerating(false);
fetchGeneratedVideos(); // 刷新历史视频列表
} else if (taskData.status === "failed") { } else if (taskData.status === "failed") {
alert("视频生成失败: " + taskData.message); alert("视频生成失败: " + taskData.message);
setIsGenerating(false); setIsGenerating(false);
@@ -320,21 +386,35 @@ export default function Home() {
) : ( ) : (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{materials.map((m) => ( {materials.map((m) => (
<button <div
key={m.id} key={m.id}
onClick={() => setSelectedMaterial(m.id)} className={`p-4 rounded-xl border-2 transition-all text-left relative group ${selectedMaterial === m.id
className={`p-4 rounded-xl border-2 transition-all text-left ${selectedMaterial === m.id
? "border-purple-500 bg-purple-500/20" ? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30" : "border-white/10 bg-white/5 hover:border-white/30"
}`} }`}
> >
<div className="text-white font-medium truncate"> <button
{m.scene || m.name} onClick={() => setSelectedMaterial(m.id)}
</div> className="w-full text-left"
<div className="text-gray-400 text-sm mt-1"> >
{m.size_mb.toFixed(1)} MB <div className="text-white font-medium truncate pr-6">
</div> {m.scene || m.name}
</button> </div>
<div className="text-gray-400 text-sm mt-1">
{m.size_mb.toFixed(1)} MB
</div>
</button>
<button
onClick={(e) => {
e.stopPropagation();
deleteMaterial(m.id);
}}
className="absolute top-2 right-2 p-1 text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
title="删除素材"
>
🗑
</button>
</div>
))} ))}
</div> </div>
)} )}
@@ -471,16 +551,66 @@ export default function Home() {
</> </>
)} )}
</div> </div>
{/* 历史视频列表 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
📂
</h2>
<button
onClick={fetchGeneratedVideos}
className="px-3 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300"
>
🔄
</button>
</div>
{generatedVideos.length === 0 ? (
<div className="text-center py-4 text-gray-500">
<p></p>
</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar">
{generatedVideos.map((v) => (
<div
key={v.id}
className={`p-3 rounded-lg border transition-all flex items-center justify-between group ${selectedVideoId === v.id
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<button
onClick={() => {
setSelectedVideoId(v.id);
setGeneratedVideo(`${API_BASE}${v.path}`);
}}
className="flex-1 text-left"
>
<div className="text-white text-sm truncate">
{new Date(v.created_at * 1000).toLocaleString('zh-CN')}
</div>
<div className="text-gray-400 text-xs">
{v.size_mb.toFixed(1)} MB
</div>
</button>
<button
onClick={(e) => {
e.stopPropagation();
deleteVideo(v.id);
}}
className="p-1 text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
title="删除视频"
>
🗑
</button>
</div>
))}
</div>
)}
</div>
</div> </div>
</div> </div>
</main> </main>
{/* Footer */}
<footer className="border-t border-white/10 mt-12">
<div className="max-w-6xl mx-auto px-6 py-4 text-center text-gray-500 text-sm">
ViGent - MuseTalk + EdgeTTS
</div>
</footer>
</div> </div>
); );
} }

View File

@@ -52,20 +52,18 @@ export default function PublishPage() {
const fetchVideos = async () => { const fetchVideos = async () => {
try { try {
// 获取已生成的视频列表 (从 outputs 目录) // 使用持久化的视频列表 API从文件系统读取
const res = await fetch(`${API_BASE}/api/videos/tasks`); const res = await fetch(`${API_BASE}/api/videos/generated`);
const data = await res.json(); const data = await res.json();
const completedVideos = data.tasks const videos = (data.videos || []).map((v: any) => ({
?.filter((t: any) => t.status === "completed") name: new Date(v.created_at * 1000).toLocaleString('zh-CN') + ` (${v.size_mb.toFixed(1)}MB)`,
.map((t: any) => ({ path: v.path.startsWith('/') ? v.path.slice(1) : v.path, // 移除开头的 /
name: `${t.task_id}_output.mp4`, }));
path: `outputs/${t.task_id}_output.mp4`,
})) || [];
setVideos(completedVideos); setVideos(videos);
if (completedVideos.length > 0) { if (videos.length > 0) {
setSelectedVideo(completedVideos[0].path); setSelectedVideo(videos[0].path);
} }
} catch (error) { } catch (error) {
console.error("获取视频失败:", error); console.error("获取视频失败:", error);
@@ -281,7 +279,7 @@ export default function PublishPage() {
<select <select
value={selectedVideo} value={selectedVideo}
onChange={(e) => setSelectedVideo(e.target.value)} onChange={(e) => setSelectedVideo(e.target.value)}
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white" className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white custom-select cursor-pointer hover:border-purple-500/50 transition-colors"
> >
{videos.map((v) => ( {videos.map((v) => (
<option key={v.path} value={v.path}> <option key={v.path} value={v.path}>