更新代码
This commit is contained in:
@@ -98,6 +98,15 @@ playwright install chromium
|
||||
|
||||
---
|
||||
|
||||
### 可选:AI 标题/标签生成
|
||||
|
||||
> ✅ 如需启用“AI 标题/标签生成”功能,请确保后端可访问外网 API。
|
||||
|
||||
- 需要可访问 `https://open.bigmodel.cn`
|
||||
- API Key 配置在 `backend/app/services/glm_service.py`(建议替换为自己的密钥)
|
||||
|
||||
---
|
||||
|
||||
## 步骤 5: 部署用户认证系统 (Supabase + Auth)
|
||||
|
||||
> 🔐 **包含**: 登录/注册、Supabase 数据库配置、JWT 认证、管理员后台
|
||||
@@ -433,6 +442,7 @@ pm2 logs vigent2-qwen-tts
|
||||
| `fastapi` | Web API 框架 |
|
||||
| `uvicorn` | ASGI 服务器 |
|
||||
| `edge-tts` | 微软 TTS 配音 |
|
||||
| `httpx` | GLM API HTTP 客户端 |
|
||||
| `playwright` | 社交媒体自动发布 |
|
||||
| `biliup` | B站视频上传 |
|
||||
| `loguru` | 日志管理 |
|
||||
|
||||
402
Docs/DevLogs/Day14.md
Normal file
402
Docs/DevLogs/Day14.md
Normal file
@@ -0,0 +1,402 @@
|
||||
# Day 14 - 模型升级 + 标题标签生成 + 前端修复
|
||||
|
||||
**日期**:2026-01-30
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Qwen3-TTS 模型升级 (0.6B → 1.7B)
|
||||
|
||||
### 背景
|
||||
|
||||
为提升声音克隆质量,将 Qwen3-TTS 模型从 0.6B-Base 升级到 1.7B-Base。
|
||||
|
||||
### 变更内容
|
||||
|
||||
| 项目 | 升级前 | 升级后 |
|
||||
|------|--------|--------|
|
||||
| 模型 | 0.6B-Base | **1.7B-Base** |
|
||||
| 大小 | 2.4GB | 6.8GB |
|
||||
| 质量 | 基础 | 更高质量 |
|
||||
|
||||
### 代码修改
|
||||
|
||||
**文件**: `models/Qwen3-TTS/qwen_tts_server.py`
|
||||
|
||||
```python
|
||||
# 升级前
|
||||
MODEL_PATH = Path(__file__).parent / "checkpoints" / "0.6B-Base"
|
||||
|
||||
# 升级后
|
||||
MODEL_PATH = Path(__file__).parent / "checkpoints" / "1.7B-Base"
|
||||
```
|
||||
|
||||
### 模型下载
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/models/Qwen3-TTS
|
||||
|
||||
# 下载 1.7B-Base 模型 (6.8GB)
|
||||
modelscope download --model Qwen/Qwen3-TTS-12Hz-1.7B-Base --local_dir ./checkpoints/1.7B-Base
|
||||
```
|
||||
|
||||
### 结果
|
||||
|
||||
- ✅ 模型加载正常 (GPU0, bfloat16)
|
||||
- ✅ 声音克隆质量提升
|
||||
- ✅ 推理速度可接受
|
||||
|
||||
---
|
||||
|
||||
## 🎨 标题和字幕显示优化
|
||||
|
||||
### 字幕组件优化 (`Subtitles.tsx`)
|
||||
|
||||
**文件**: `remotion/src/components/Subtitles.tsx`
|
||||
|
||||
优化内容:
|
||||
- 调整高亮颜色配置
|
||||
- 优化文字描边效果(多层阴影)
|
||||
- 调整字间距和行高
|
||||
|
||||
```typescript
|
||||
export const Subtitles: React.FC<SubtitlesProps> = ({
|
||||
captions,
|
||||
highlightColor = '#FFFF00', // 高亮颜色
|
||||
normalColor = '#FFFFFF', // 普通文字颜色
|
||||
fontSize = 52,
|
||||
}) => {
|
||||
// 样式优化
|
||||
const style = {
|
||||
textShadow: `
|
||||
2px 2px 4px rgba(0,0,0,0.8),
|
||||
-2px -2px 4px rgba(0,0,0,0.8),
|
||||
...
|
||||
`,
|
||||
letterSpacing: '2px',
|
||||
lineHeight: 1.4,
|
||||
maxWidth: '90%',
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### 标题组件优化 (`Title.tsx`)
|
||||
|
||||
**文件**: `remotion/src/components/Title.tsx`
|
||||
|
||||
优化内容:
|
||||
- 淡入淡出动画效果
|
||||
- 下滑入场动画
|
||||
- 可配置显示时长
|
||||
|
||||
```typescript
|
||||
interface TitleProps {
|
||||
title: string;
|
||||
duration?: number; // 标题显示时长(秒,默认3秒)
|
||||
fadeOutStart?: number; // 开始淡出的时间(秒,默认2秒)
|
||||
}
|
||||
|
||||
// 动画效果
|
||||
// 淡入:0-0.5 秒
|
||||
// 淡出:2-3 秒
|
||||
// 下滑:0-0.5 秒,-20px → 0px
|
||||
```
|
||||
|
||||
### 结果
|
||||
|
||||
- ✅ 字幕显示更清晰
|
||||
- ✅ 标题动画更流畅
|
||||
|
||||
---
|
||||
|
||||
## 🤖 标题标签自动生成功能
|
||||
|
||||
### 功能描述
|
||||
|
||||
使用 AI(智谱 GLM-4-Flash)根据口播文案自动生成视频标题和标签。
|
||||
|
||||
### 后端实现
|
||||
|
||||
#### 1. GLM 服务 (`glm_service.py`)
|
||||
|
||||
**文件**: `backend/app/services/glm_service.py`
|
||||
|
||||
```python
|
||||
class GLMService:
|
||||
"""智谱 GLM AI 服务"""
|
||||
|
||||
async def generate_meta(self, text: str) -> dict:
|
||||
"""根据文案生成标题和标签"""
|
||||
|
||||
prompt = """根据以下口播文案,生成一个吸引人的短视频标题和3个相关标签。
|
||||
|
||||
要求:
|
||||
1. 标题要简洁有力,能吸引观众点击,不超过10个字
|
||||
2. 标签要与内容相关,便于搜索和推荐,只要3个
|
||||
|
||||
返回格式:{"title": "标题", "tags": ["标签1", "标签2", "标签3"]}
|
||||
"""
|
||||
# 调用 GLM-4-Flash API
|
||||
response = await self._call_api(prompt + text)
|
||||
return self._parse_json(response)
|
||||
```
|
||||
|
||||
**JSON 解析容错**:
|
||||
- 支持直接 JSON 解析
|
||||
- 支持提取 JSON 块
|
||||
- 支持 ```json 代码块提取
|
||||
|
||||
#### 2. API 端点 (`ai.py`)
|
||||
|
||||
**文件**: `backend/app/api/ai.py`
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
|
||||
class GenerateMetaRequest(BaseModel):
|
||||
text: str # 口播文案
|
||||
|
||||
class GenerateMetaResponse(BaseModel):
|
||||
title: str # 生成的标题
|
||||
tags: list[str] # 生成的标签列表
|
||||
|
||||
@router.post("/generate-meta", response_model=GenerateMetaResponse)
|
||||
async def generate_meta(request: GenerateMetaRequest):
|
||||
"""AI 生成标题和标签"""
|
||||
result = await glm_service.generate_meta(request.text)
|
||||
return result
|
||||
```
|
||||
|
||||
### 前端实现
|
||||
|
||||
**文件**: `frontend/src/app/page.tsx`
|
||||
|
||||
#### UI 按钮
|
||||
|
||||
```tsx
|
||||
<button
|
||||
onClick={handleGenerateMeta}
|
||||
disabled={isGeneratingMeta || !text.trim()}
|
||||
className="px-2 py-1 text-xs rounded transition-all whitespace-nowrap"
|
||||
>
|
||||
{isGeneratingMeta ? "⏳ 生成中..." : "🤖 AI生成标题标签"}
|
||||
</button>
|
||||
```
|
||||
|
||||
#### 处理逻辑
|
||||
|
||||
```typescript
|
||||
const handleGenerateMeta = async () => {
|
||||
if (!text.trim()) {
|
||||
alert("请先输入口播文案");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingMeta(true);
|
||||
try {
|
||||
const { data } = await api.post('/api/ai/generate-meta', { text: text.trim() });
|
||||
|
||||
// 更新首页标题
|
||||
setVideoTitle(data.title || "");
|
||||
|
||||
// 同步到发布页 localStorage
|
||||
localStorage.setItem(`vigent_${storageKey}_publish_title`, data.title || "");
|
||||
localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(data.tags || []));
|
||||
} catch (err: any) {
|
||||
alert(`AI 生成失败: ${err.message}`);
|
||||
} finally {
|
||||
setIsGeneratingMeta(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 发布页集成
|
||||
|
||||
**文件**: `frontend/src/app/publish/page.tsx`
|
||||
|
||||
从 localStorage 恢复 AI 生成的标题和标签:
|
||||
|
||||
```typescript
|
||||
// 恢复标题和标签
|
||||
const savedTitle = localStorage.getItem(`vigent_${storageKey}_publish_title`);
|
||||
const savedTags = localStorage.getItem(`vigent_${storageKey}_publish_tags`);
|
||||
|
||||
if (savedTags) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedTags);
|
||||
if (Array.isArray(parsed)) {
|
||||
setTags(parsed.join(', ')); // 数组转逗号分隔字符串
|
||||
} else {
|
||||
setTags(savedTags);
|
||||
}
|
||||
} catch {
|
||||
setTags(savedTags);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 结果
|
||||
|
||||
- ✅ AI 生成标题和标签功能正常
|
||||
- ✅ 数据自动同步到发布页
|
||||
- ✅ 支持 JSON 数组和字符串格式兼容
|
||||
|
||||
---
|
||||
|
||||
## 🐛 前端文本保存问题修复
|
||||
|
||||
### 问题描述
|
||||
|
||||
**现象**:页面刷新后,用户输入的文案、标题等数据丢失
|
||||
|
||||
**原因**:
|
||||
1. 认证状态恢复失败时,`userId` 为 `null`
|
||||
2. 原代码判断 `!userId` 后用默认值覆盖 localStorage 数据
|
||||
3. 导致已保存的用户数据被清空
|
||||
|
||||
### 解决方案
|
||||
|
||||
**文件**: `frontend/src/app/page.tsx`
|
||||
|
||||
#### 1. 添加恢复完成标志
|
||||
|
||||
```typescript
|
||||
const [isRestored, setIsRestored] = useState(false);
|
||||
```
|
||||
|
||||
#### 2. 等待认证完成后恢复数据
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (isAuthLoading) return; // 等待认证完成
|
||||
|
||||
// 使用 userId 或 'guest' 作为 key
|
||||
const key = userId || 'guest';
|
||||
|
||||
// 从 localStorage 恢复数据
|
||||
const savedText = localStorage.getItem(`vigent_${key}_text`);
|
||||
if (savedText) setText(savedText);
|
||||
|
||||
// ... 恢复其他数据
|
||||
|
||||
setIsRestored(true); // 标记恢复完成
|
||||
}, [userId, isAuthLoading]);
|
||||
```
|
||||
|
||||
#### 3. 恢复完成后才保存
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
localStorage.setItem(`vigent_${storageKey}_text`, text);
|
||||
}
|
||||
}, [text, storageKey, isRestored]);
|
||||
```
|
||||
|
||||
### 用户隔离机制
|
||||
|
||||
```typescript
|
||||
const storageKey = userId || 'guest';
|
||||
```
|
||||
|
||||
| 用户状态 | storageKey | 说明 |
|
||||
|----------|------------|------|
|
||||
| 已登录 | `user_xxx` | 数据按用户隔离 |
|
||||
| 未登录/认证失败 | `guest` | 使用统一 key |
|
||||
|
||||
### 数据恢复流程
|
||||
|
||||
```
|
||||
1. 页面加载
|
||||
↓
|
||||
2. 检查 isAuthLoading
|
||||
├─ true: 等待认证完成
|
||||
└─ false: 继续
|
||||
↓
|
||||
3. 确定 storageKey (userId || 'guest')
|
||||
↓
|
||||
4. 从 localStorage 读取数据
|
||||
├─ 有保存数据: 恢复到状态
|
||||
└─ 无保存数据: 使用默认值
|
||||
↓
|
||||
5. 设置 isRestored = true
|
||||
↓
|
||||
6. 后续状态变化时保存到 localStorage
|
||||
```
|
||||
|
||||
### 保存的数据项
|
||||
|
||||
| Key | 说明 |
|
||||
|-----|------|
|
||||
| `vigent_${key}_text` | 口播文案 |
|
||||
| `vigent_${key}_title` | 视频标题 |
|
||||
| `vigent_${key}_subtitles` | 字幕开关 |
|
||||
| `vigent_${key}_ttsMode` | TTS 模式 |
|
||||
| `vigent_${key}_voice` | 选择的音色 |
|
||||
| `vigent_${key}_material` | 选择的素材 |
|
||||
| `vigent_${key}_publish_title` | 发布标题 |
|
||||
| `vigent_${key}_publish_tags` | 发布标签 |
|
||||
|
||||
### 结果
|
||||
|
||||
- ✅ 页面刷新后数据正常恢复
|
||||
- ✅ 认证失败时不会覆盖已保存数据
|
||||
- ✅ 多用户数据隔离正常
|
||||
|
||||
---
|
||||
|
||||
## 🐛 登录页刷新循环修复
|
||||
|
||||
### 问题描述
|
||||
|
||||
**现象**:登录页未登录时不断刷新,无法停留在表单页面。
|
||||
|
||||
**原因**:
|
||||
1. `AuthProvider` 初始化时调用 `/api/auth/me`
|
||||
2. 未登录返回 401
|
||||
3. `axios` 全局拦截器遇到 401/403 重定向 `/login`
|
||||
4. 登录页本身也在 Provider 中,导致循环刷新
|
||||
|
||||
### 解决方案
|
||||
|
||||
**文件**: `frontend/src/lib/axios.ts`
|
||||
|
||||
在拦截器中对公开路由跳过重定向,仅在受保护页面触发登录跳转:
|
||||
|
||||
```typescript
|
||||
const PUBLIC_PATHS = new Set(['/login', '/register']);
|
||||
const isPublicPath = typeof window !== 'undefined' && PUBLIC_PATHS.has(window.location.pathname);
|
||||
|
||||
if ((status === 401 || status === 403) && !isRedirecting && !isPublicPath) {
|
||||
// ... 保持原有重定向逻辑
|
||||
}
|
||||
```
|
||||
|
||||
### 结果
|
||||
|
||||
- ✅ 登录页不再刷新,表单可正常输入
|
||||
- ✅ 受保护页面仍会在 401/403 时跳转登录页
|
||||
|
||||
---
|
||||
|
||||
## 📁 今日修改文件清单
|
||||
|
||||
| 文件 | 变更类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `models/Qwen3-TTS/qwen_tts_server.py` | 修改 | 模型路径升级到 1.7B-Base |
|
||||
| `Docs/QWEN3_TTS_DEPLOY.md` | 修改 | 更新部署文档为 1.7B 版本 |
|
||||
| `remotion/src/components/Subtitles.tsx` | 修改 | 优化字幕显示效果 |
|
||||
| `remotion/src/components/Title.tsx` | 修改 | 优化标题动画效果 |
|
||||
| `backend/app/services/glm_service.py` | 新增 | GLM AI 服务 |
|
||||
| `backend/app/api/ai.py` | 新增 | AI 生成标题标签 API |
|
||||
| `backend/app/main.py` | 修改 | 注册 ai 路由 |
|
||||
| `frontend/src/app/page.tsx` | 修改 | AI 生成按钮 + localStorage 修复 |
|
||||
| `frontend/src/app/publish/page.tsx` | 修改 | 恢复 AI 生成的标签 |
|
||||
| `frontend/src/lib/axios.ts` | 修改 | 公开路由跳过 401/403 登录重定向 |
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- [task_complete.md](../task_complete.md) - 任务总览
|
||||
- [Day13.md](./Day13.md) - 声音克隆功能集成 + 字幕功能
|
||||
- [QWEN3_TTS_DEPLOY.md](../QWEN3_TTS_DEPLOY.md) - Qwen3-TTS 1.7B 部署指南
|
||||
347
Docs/DevLogs/Day15.md
Normal file
347
Docs/DevLogs/Day15.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# Day 15 - 手机号登录迁移 + 账户设置功能
|
||||
|
||||
**日期**:2026-02-02
|
||||
|
||||
---
|
||||
|
||||
## 🔐 认证系统迁移:邮箱 → 手机号
|
||||
|
||||
### 背景
|
||||
|
||||
根据业务需求,将用户认证从邮箱登录迁移到手机号登录(11位中国手机号)。
|
||||
|
||||
### 变更范围
|
||||
|
||||
| 组件 | 变更内容 |
|
||||
|------|----------|
|
||||
| 数据库 Schema | `email` 字段替换为 `phone` |
|
||||
| 后端 API | 注册/登录/获取用户信息接口使用 `phone` |
|
||||
| 前端页面 | 登录/注册页面改为手机号输入框 |
|
||||
| 管理员配置 | `ADMIN_EMAIL` 改为 `ADMIN_PHONE` |
|
||||
|
||||
---
|
||||
|
||||
## 📦 后端修改
|
||||
|
||||
### 1. 数据库 Schema (`schema.sql`)
|
||||
|
||||
**文件**: `backend/database/schema.sql`
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
phone TEXT UNIQUE NOT NULL, -- 原 email 改为 phone
|
||||
password_hash TEXT NOT NULL,
|
||||
username TEXT,
|
||||
role TEXT DEFAULT 'pending' CHECK (role IN ('pending', 'user', 'admin')),
|
||||
is_active BOOLEAN DEFAULT FALSE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_phone ON users(phone);
|
||||
```
|
||||
|
||||
### 2. 认证 API (`auth.py`)
|
||||
|
||||
**文件**: `backend/app/api/auth.py`
|
||||
|
||||
#### 请求模型更新
|
||||
|
||||
```python
|
||||
class RegisterRequest(BaseModel):
|
||||
phone: str
|
||||
password: str
|
||||
username: Optional[str] = None
|
||||
|
||||
@field_validator('phone')
|
||||
@classmethod
|
||||
def validate_phone(cls, v):
|
||||
if not re.match(r'^\d{11}$', v):
|
||||
raise ValueError('手机号必须是11位数字')
|
||||
return v
|
||||
```
|
||||
|
||||
#### 新增修改密码接口
|
||||
|
||||
```python
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
@field_validator('new_password')
|
||||
@classmethod
|
||||
def validate_new_password(cls, v):
|
||||
if len(v) < 6:
|
||||
raise ValueError('新密码长度至少6位')
|
||||
return v
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(request: ChangePasswordRequest, req: Request, response: Response):
|
||||
"""修改密码,验证当前密码后更新"""
|
||||
# 1. 验证当前密码
|
||||
# 2. 更新密码 hash
|
||||
# 3. 重新生成 session token
|
||||
# 4. 返回新的 JWT Cookie
|
||||
```
|
||||
|
||||
### 3. 配置更新
|
||||
|
||||
**文件**: `backend/app/core/config.py`
|
||||
|
||||
```python
|
||||
# 管理员配置
|
||||
ADMIN_PHONE: str = "" # 原 ADMIN_EMAIL
|
||||
ADMIN_PASSWORD: str = ""
|
||||
```
|
||||
|
||||
**文件**: `backend/.env`
|
||||
|
||||
```bash
|
||||
ADMIN_PHONE=15549380526
|
||||
ADMIN_PASSWORD=lam1988324
|
||||
```
|
||||
|
||||
### 4. 管理员初始化 (`main.py`)
|
||||
|
||||
**文件**: `backend/app/main.py`
|
||||
|
||||
```python
|
||||
@app.on_event("startup")
|
||||
async def init_admin():
|
||||
admin_phone = settings.ADMIN_PHONE # 原 ADMIN_EMAIL
|
||||
# ... 使用 phone 字段创建管理员
|
||||
```
|
||||
|
||||
### 5. 管理员 API (`admin.py`)
|
||||
|
||||
**文件**: `backend/app/api/admin.py`
|
||||
|
||||
```python
|
||||
class UserListItem(BaseModel):
|
||||
id: str
|
||||
phone: str # 原 email
|
||||
username: Optional[str]
|
||||
role: str
|
||||
is_active: bool
|
||||
expires_at: Optional[str]
|
||||
created_at: str
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ 前端修改
|
||||
|
||||
### 1. 登录页面 (`login/page.tsx`)
|
||||
|
||||
**文件**: `frontend/src/app/login/page.tsx`
|
||||
|
||||
```tsx
|
||||
const [phone, setPhone] = useState('');
|
||||
|
||||
// 验证手机号格式
|
||||
if (!/^\d{11}$/.test(phone)) {
|
||||
setError('请输入正确的11位手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
|
||||
maxLength={11}
|
||||
placeholder="请输入11位手机号"
|
||||
/>
|
||||
```
|
||||
|
||||
### 2. 注册页面 (`register/page.tsx`)
|
||||
|
||||
同样使用手机号输入,增加 11 位数字验证。
|
||||
|
||||
### 3. Auth 工具函数 (`auth.ts`)
|
||||
|
||||
**文件**: `frontend/src/lib/auth.ts`
|
||||
|
||||
```typescript
|
||||
export interface User {
|
||||
id: string;
|
||||
phone: string; // 原 email
|
||||
username: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export async function login(phone: string, password: string): Promise<AuthResponse> { ... }
|
||||
export async function register(phone: string, password: string, username?: string): Promise<AuthResponse> { ... }
|
||||
export async function changePassword(oldPassword: string, newPassword: string): Promise<AuthResponse> { ... }
|
||||
```
|
||||
|
||||
### 4. 首页账户设置下拉菜单 (`page.tsx`)
|
||||
|
||||
**文件**: `frontend/src/app/page.tsx`
|
||||
|
||||
将原来的"退出"按钮改为账户设置下拉菜单:
|
||||
|
||||
```tsx
|
||||
function AccountSettingsDropdown() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
// ...
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button onClick={() => setIsOpen(!isOpen)}>
|
||||
⚙️ 账户
|
||||
</button>
|
||||
|
||||
{/* 下拉菜单 */}
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-40 bg-gray-800 ...">
|
||||
<button onClick={() => setShowPasswordModal(true)}>
|
||||
🔐 修改密码
|
||||
</button>
|
||||
<button onClick={handleLogout} className="text-red-300">
|
||||
🚪 退出登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 修改密码弹窗 */}
|
||||
{showPasswordModal && (
|
||||
<div className="fixed inset-0 z-50 ...">
|
||||
<form onSubmit={handleChangePassword}>
|
||||
<input placeholder="当前密码" />
|
||||
<input placeholder="新密码" />
|
||||
<input placeholder="确认新密码" />
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 管理员页面 (`admin/page.tsx`)
|
||||
|
||||
**文件**: `frontend/src/app/admin/page.tsx`
|
||||
|
||||
```tsx
|
||||
interface UserListItem {
|
||||
id: string;
|
||||
phone: string; // 原 email
|
||||
// ...
|
||||
}
|
||||
|
||||
// 显示手机号而非邮箱
|
||||
<div className="text-gray-400 text-sm">{user.phone}</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ 数据库迁移
|
||||
|
||||
### 迁移脚本
|
||||
|
||||
**文件**: `backend/database/migrate_to_phone.sql`
|
||||
|
||||
```sql
|
||||
-- 删除旧表 (CASCADE 处理外键依赖)
|
||||
DROP TABLE IF EXISTS user_sessions CASCADE;
|
||||
DROP TABLE IF EXISTS social_accounts CASCADE;
|
||||
DROP TABLE IF EXISTS users CASCADE;
|
||||
|
||||
-- 重新创建使用 phone 字段的表
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
phone TEXT UNIQUE NOT NULL,
|
||||
-- ...
|
||||
);
|
||||
|
||||
-- 重新创建依赖表和索引
|
||||
CREATE TABLE user_sessions (...);
|
||||
CREATE TABLE social_accounts (...);
|
||||
CREATE INDEX idx_users_phone ON users(phone);
|
||||
```
|
||||
|
||||
### 执行方式
|
||||
|
||||
```bash
|
||||
# 方式一:Docker 命令
|
||||
docker exec -i supabase-db psql -U postgres < backend/database/migrate_to_phone.sql
|
||||
|
||||
# 方式二:Supabase Studio SQL Editor
|
||||
# 打开 https://supabase.hbyrkj.top -> SQL Editor -> 粘贴执行
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 部署步骤
|
||||
|
||||
```bash
|
||||
# 1. 执行数据库迁移
|
||||
docker exec -i supabase-db psql -U postgres < backend/database/migrate_to_phone.sql
|
||||
|
||||
# 2. 重新构建前端
|
||||
cd frontend && npm run build
|
||||
|
||||
# 3. 重启服务
|
||||
pm2 restart vigent2-backend vigent2-frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 今日修改文件清单
|
||||
|
||||
| 文件 | 变更类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `backend/database/schema.sql` | 修改 | email → phone |
|
||||
| `backend/database/migrate_to_phone.sql` | 新增 | 数据库迁移脚本 |
|
||||
| `backend/app/api/auth.py` | 修改 | 手机号验证 + 修改密码 API |
|
||||
| `backend/app/api/admin.py` | 修改 | UserListItem.email → phone |
|
||||
| `backend/app/core/config.py` | 修改 | ADMIN_EMAIL → ADMIN_PHONE |
|
||||
| `backend/app/main.py` | 修改 | 管理员初始化使用 phone |
|
||||
| `backend/.env` | 修改 | ADMIN_PHONE=15549380526 |
|
||||
| `frontend/src/app/login/page.tsx` | 修改 | 手机号登录 + 11位验证 |
|
||||
| `frontend/src/app/register/page.tsx` | 修改 | 手机号注册 + 11位验证 |
|
||||
| `frontend/src/lib/auth.ts` | 修改 | phone 参数 + changePassword 函数 |
|
||||
| `frontend/src/app/page.tsx` | 修改 | AccountSettingsDropdown 组件 |
|
||||
| `frontend/src/app/admin/page.tsx` | 修改 | 用户列表显示手机号 |
|
||||
| `frontend/src/contexts/AuthContext.tsx` | 修改 | 存储完整用户信息含 expires_at |
|
||||
|
||||
---
|
||||
|
||||
## 🆕 后续完善 (Day 15 下午)
|
||||
|
||||
### 账户有效期显示
|
||||
|
||||
在账户下拉菜单中显示用户的有效期:
|
||||
|
||||
| 显示情况 | 格式 |
|
||||
|----------|------|
|
||||
| 有设置 expires_at | `2026-03-15` |
|
||||
| NULL | `永久有效` |
|
||||
|
||||
**相关修改**:
|
||||
- `backend/app/api/auth.py`: UserResponse 新增 `expires_at` 字段
|
||||
- `frontend/src/contexts/AuthContext.tsx`: 存储完整用户对象
|
||||
- `frontend/src/app/page.tsx`: 格式化并显示有效期
|
||||
|
||||
### 点击外部关闭下拉菜单
|
||||
|
||||
使用 `useRef` + `useEffect` 监听全局点击事件,点击菜单外部自动关闭。
|
||||
|
||||
### 修改密码后强制重新登录
|
||||
|
||||
密码修改成功后:
|
||||
1. 显示"密码修改成功,正在跳转登录页..."
|
||||
2. 1.5秒后调用登出 API
|
||||
3. 跳转到登录页面
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- [task_complete.md](../task_complete.md) - 任务总览
|
||||
- [Day14.md](./Day14.md) - 模型升级 + AI 标题标签
|
||||
- [AUTH_DEPLOY.md](../AUTH_DEPLOY.md) - 认证系统部署指南
|
||||
@@ -1,13 +1,13 @@
|
||||
# Qwen3-TTS 0.6B 部署指南
|
||||
# Qwen3-TTS 1.7B 部署指南
|
||||
|
||||
> 本文档描述如何在 Ubuntu 服务器上部署 Qwen3-TTS 0.6B-Base 声音克隆模型。
|
||||
> 本文档描述如何在 Ubuntu 服务器上部署 Qwen3-TTS 1.7B-Base 声音克隆模型。
|
||||
|
||||
## 系统要求
|
||||
|
||||
| 要求 | 规格 |
|
||||
|------|------|
|
||||
| GPU | NVIDIA RTX 3090 24GB (或更高) |
|
||||
| VRAM | ≥ 4GB (推理), ≥ 8GB (带 flash-attn) |
|
||||
| VRAM | ≥ 8GB (推理), ≥ 12GB (带 flash-attn) |
|
||||
| CUDA | 12.1+ |
|
||||
| Python | 3.10.x |
|
||||
| 系统 | Ubuntu 20.04+ |
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
| GPU | 服务 | 模型 |
|
||||
|-----|------|------|
|
||||
| GPU0 | **Qwen3-TTS** | 0.6B-Base (声音克隆) |
|
||||
| GPU0 | **Qwen3-TTS** | 1.7B-Base (声音克隆,更高质量) |
|
||||
| GPU1 | LatentSync | 1.6 (唇形同步) |
|
||||
|
||||
---
|
||||
@@ -81,8 +81,8 @@ pip install modelscope
|
||||
# 下载 Tokenizer (651MB)
|
||||
modelscope download --model Qwen/Qwen3-TTS-Tokenizer-12Hz --local_dir ./checkpoints/Tokenizer
|
||||
|
||||
# 下载 0.6B-Base 模型 (2.4GB)
|
||||
modelscope download --model Qwen/Qwen3-TTS-12Hz-0.6B-Base --local_dir ./checkpoints/0.6B-Base
|
||||
# 下载 1.7B-Base 模型 (6.8GB)
|
||||
modelscope download --model Qwen/Qwen3-TTS-12Hz-1.7B-Base --local_dir ./checkpoints/1.7B-Base
|
||||
```
|
||||
|
||||
### 方式 B: HuggingFace
|
||||
@@ -91,7 +91,7 @@ modelscope download --model Qwen/Qwen3-TTS-12Hz-0.6B-Base --local_dir ./checkpoi
|
||||
pip install -U "huggingface_hub[cli]"
|
||||
|
||||
huggingface-cli download Qwen/Qwen3-TTS-Tokenizer-12Hz --local-dir ./checkpoints/Tokenizer
|
||||
huggingface-cli download Qwen/Qwen3-TTS-12Hz-0.6B-Base --local-dir ./checkpoints/0.6B-Base
|
||||
huggingface-cli download Qwen/Qwen3-TTS-12Hz-1.7B-Base --local-dir ./checkpoints/1.7B-Base
|
||||
```
|
||||
|
||||
下载完成后,目录结构应如下:
|
||||
@@ -102,7 +102,7 @@ checkpoints/
|
||||
│ ├── config.json
|
||||
│ ├── model.safetensors
|
||||
│ └── ...
|
||||
└── 0.6B-Base/ # ~2.4GB
|
||||
└── 1.7B-Base/ # ~6.8GB
|
||||
├── config.json
|
||||
├── model.safetensors
|
||||
└── ...
|
||||
@@ -136,7 +136,7 @@ from qwen_tts import Qwen3TTSModel
|
||||
|
||||
print("Loading Qwen3-TTS model on GPU:0...")
|
||||
model = Qwen3TTSModel.from_pretrained(
|
||||
"./checkpoints/0.6B-Base",
|
||||
"./checkpoints/1.7B-Base",
|
||||
device_map="cuda:0",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
@@ -223,7 +223,7 @@ pm2 restart vigent2-qwen-tts
|
||||
└── models/Qwen3-TTS/
|
||||
├── checkpoints/
|
||||
│ ├── Tokenizer/ # 语音编解码器
|
||||
│ └── 0.6B-Base/ # 声音克隆模型
|
||||
│ └── 1.7B-Base/ # 声音克隆模型 (更高质量)
|
||||
├── qwen_tts/ # 源码
|
||||
│ ├── inference/
|
||||
│ ├── models/
|
||||
@@ -250,7 +250,7 @@ GET http://localhost:8009/health
|
||||
```json
|
||||
{
|
||||
"service": "Qwen3-TTS Voice Clone",
|
||||
"model": "0.6B-Base",
|
||||
"model": "1.7B-Base",
|
||||
"ready": true,
|
||||
"gpu_id": 0
|
||||
}
|
||||
@@ -281,7 +281,7 @@ Response: audio/wav 文件
|
||||
|------|------|------|
|
||||
| 0.6B-Base | 3秒快速声音克隆 | 2.4GB |
|
||||
| 0.6B-CustomVoice | 9种预设音色 | 2.4GB |
|
||||
| 1.7B-Base | 声音克隆 (更高质量) | 6.8GB |
|
||||
| **1.7B-Base** | **声音克隆 (更高质量)** ✅ 当前使用 | 6.8GB |
|
||||
| 1.7B-VoiceDesign | 自然语言描述生成声音 | 6.8GB |
|
||||
|
||||
### 支持语言
|
||||
@@ -306,17 +306,18 @@ conda install -y -c conda-forge sox
|
||||
|
||||
### CUDA 内存不足
|
||||
|
||||
Qwen3-TTS 0.6B 通常只需要 4-6GB VRAM。如果遇到 OOM:
|
||||
Qwen3-TTS 1.7B 通常需要 8-10GB VRAM。如果遇到 OOM:
|
||||
|
||||
1. 确保 GPU0 没有运行其他程序
|
||||
2. 不使用 flash-attn (会增加显存占用)
|
||||
3. 使用更小的参考音频 (3-5秒)
|
||||
4. 如果显存仍不足,可降级使用 0.6B-Base 模型
|
||||
|
||||
### 模型加载失败
|
||||
|
||||
确保以下文件存在:
|
||||
- `checkpoints/0.6B-Base/config.json`
|
||||
- `checkpoints/0.6B-Base/model.safetensors`
|
||||
- `checkpoints/1.7B-Base/config.json`
|
||||
- `checkpoints/1.7B-Base/model.safetensors`
|
||||
|
||||
### 音频输出质量问题
|
||||
|
||||
@@ -366,6 +367,14 @@ FOR INSERT TO anon WITH CHECK (bucket_id = 'ref-audios');
|
||||
|
||||
---
|
||||
|
||||
## 更新日志
|
||||
|
||||
| 日期 | 版本 | 说明 |
|
||||
|------|------|------|
|
||||
| 2026-01-30 | 1.1.0 | 明确默认模型升级为 1.7B-Base,替换旧版 0.6B 路径 |
|
||||
|
||||
---
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Qwen3-TTS GitHub](https://github.com/QwenLM/Qwen3-TTS)
|
||||
@@ -373,4 +382,3 @@ FOR INSERT TO anon WITH CHECK (bucket_id = 'ref-audios');
|
||||
- [HuggingFace 模型](https://huggingface.co/collections/Qwen/qwen3-tts)
|
||||
- [技术报告](https://arxiv.org/abs/2601.15621)
|
||||
- [官方博客](https://qwen.ai/blog?id=qwen3tts-0115)
|
||||
|
||||
|
||||
@@ -279,3 +279,4 @@ WhisperService(device="cuda:0") # 或 "cuda:1"
|
||||
| 日期 | 版本 | 说明 |
|
||||
|------|------|------|
|
||||
| 2026-01-29 | 1.0.0 | 初始版本,使用 faster-whisper + Remotion 实现逐字高亮字幕和片头标题 |
|
||||
| 2026-01-30 | 1.0.1 | 字幕高亮样式与标题动画优化,视觉表现更清晰 |
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
- 上传静态人物视频 → 生成口播视频(唇形同步)
|
||||
- TTS 配音或声音克隆
|
||||
- 字幕自动生成与渲染
|
||||
- AI 自动生成标题与标签
|
||||
- 一键发布到多个社交平台
|
||||
|
||||
---
|
||||
@@ -47,7 +48,7 @@
|
||||
| **任务队列** | Celery + Redis | RQ / Dramatiq |
|
||||
| **唇形同步** | **LatentSync 1.6** | MuseTalk / Wav2Lip |
|
||||
| **TTS 配音** | EdgeTTS | CosyVoice |
|
||||
| **声音克隆** | **Qwen3-TTS 0.6B** ✅ | GPT-SoVITS |
|
||||
| **声音克隆** | **Qwen3-TTS 1.7B** ✅ | GPT-SoVITS |
|
||||
| **视频处理** | FFmpeg | MoviePy |
|
||||
| **自动发布** | social-auto-upload | 自行实现 |
|
||||
| **数据库** | SQLite → PostgreSQL | MySQL |
|
||||
@@ -219,6 +220,7 @@ cp -r SuperIPAgent/social-auto-upload backend/social_upload
|
||||
| 功能 | 实现方式 |
|
||||
|------|----------|
|
||||
| **声音克隆** | 集成 GPT-SoVITS,用自己的声音 |
|
||||
| **AI 标题/标签生成** | 调用大模型 API 自动生成标题与标签 ✅ |
|
||||
| **批量生成** | 上传 Excel/CSV,批量生成视频 |
|
||||
| **字幕编辑器** | 可视化调整字幕样式、位置 |
|
||||
| **Docker 部署** | 一键部署到云服务器 | ✅ |
|
||||
@@ -334,25 +336,22 @@ cp -r SuperIPAgent/social-auto-upload backend/social_upload
|
||||
- [x] Supabase ref-audios Bucket 配置
|
||||
- [x] 端到端测试验证
|
||||
|
||||
### 阶段十八:手机号登录迁移 (Day 15) ✅
|
||||
|
||||
> **目标**:将认证系统从邮箱迁移到手机号
|
||||
|
||||
- [x] 数据库 Schema 迁移 (email → phone)
|
||||
- [x] 后端 API 适配 (auth.py/admin.py)
|
||||
- [x] 11位手机号校验 (正则验证)
|
||||
- [x] 修改密码功能 (/api/auth/change-password)
|
||||
- [x] 账户设置下拉菜单 (修改密码 + 有效期显示 + 退出)
|
||||
- [x] 前端登录/注册页面更新
|
||||
- [x] 数据库迁移脚本 (migrate_to_phone.sql)
|
||||
|
||||
---
|
||||
|
||||
## 项目目录结构 (最终)
|
||||
|
||||
```
|
||||
TalkingHeadAgent/
|
||||
├── frontend/ # Next.js 前端
|
||||
│ ├── app/
|
||||
│ ├── components/
|
||||
│ └── package.json
|
||||
├── backend/ # FastAPI 后端
|
||||
│ ├── app/
|
||||
│ ├── MuseTalk/ # 唇形同步模型
|
||||
│ ├── social_upload/ # 社交发布模块
|
||||
│ └── requirements.txt
|
||||
├── docker-compose.yml # 一键部署
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发时间估算
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
**项目**:ViGent2 数字人口播视频生成系统
|
||||
**服务器**:Dell R730 (2× RTX 3090 24GB)
|
||||
**更新时间**:2026-01-29
|
||||
**整体进度**:100%(Day 13 声音克隆 + 字幕功能完成)
|
||||
**更新时间**:2026-02-02
|
||||
**整体进度**:100%(Day 15 手机号登录迁移 + 账户设置功能完成)
|
||||
|
||||
## 📖 快速导航
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
| [时间线](#-时间线) | 开发历程 |
|
||||
|
||||
**相关文档**:
|
||||
- [Day 日志](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DevLogs/) (Day1-Day13)
|
||||
- [Day 日志](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DevLogs/) (Day1-Day15)
|
||||
- [部署指南](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DEPLOY_MANUAL.md)
|
||||
- [Qwen3-TTS 部署](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/QWEN3_TTS_DEPLOY.md)
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
- [x] **iOS Safari 安全区域修复** (viewport-fit: cover, themeColor, 渐变背景统一)
|
||||
- [x] **移动端 Header 优化** (按钮紧凑布局,响应式间距)
|
||||
- [x] **发布页面 UI 重构** (立即发布/定时发布按钮分离,防误触设计)
|
||||
- [x] **Qwen3-TTS 0.6B 部署** (声音克隆模型,GPU0,3秒参考音频快速克隆)
|
||||
- [x] **Qwen3-TTS 1.7B 部署** (声音克隆模型,GPU0,更高质量)
|
||||
|
||||
### 阶段二十:声音克隆功能集成 (Day 13)
|
||||
- [x] **Qwen3-TTS HTTP 服务** (独立 FastAPI 服务,端口 8009)
|
||||
@@ -185,6 +185,24 @@
|
||||
- [x] **前端标题/字幕设置 UI**
|
||||
- [x] **降级机制** (Remotion 失败时回退 FFmpeg)
|
||||
|
||||
### 阶段二十二:AI 标题标签 + 前端稳定性修复 (Day 14)
|
||||
- [x] **Qwen3-TTS 1.7B 模型升级** (0.6B → 1.7B-Base)
|
||||
- [x] **字幕样式与标题动画优化** (Remotion 视觉增强)
|
||||
- [x] **AI 标题/标签生成** (GLM-4-Flash API)
|
||||
- [x] **生成结果同步到发布页** (localStorage 对齐)
|
||||
- [x] **文案/标题本地保存修复** (刷新后恢复)
|
||||
- [x] **登录页刷新循环修复** (公开路由跳转豁免)
|
||||
|
||||
### 阶段二十三:手机号登录迁移 (Day 15)
|
||||
- [x] **认证迁移** (邮箱 → 11位手机号)
|
||||
- [x] **后端 API 适配** (auth.py/admin.py 手机号验证)
|
||||
- [x] **修改密码功能** (/api/auth/change-password 接口)
|
||||
- [x] **账户设置菜单** (首页下拉菜单:修改密码 + 有效期显示 + 退出登录)
|
||||
- [x] **有效期显示** (expires_at 字段显示在账户菜单)
|
||||
- [x] **点击外部关闭菜单** (useRef + useEffect 监听)
|
||||
- [x] **前端页面更新** (登录/注册/管理员页面)
|
||||
- [x] **数据库迁移脚本** (migrate_to_phone.sql)
|
||||
|
||||
---
|
||||
|
||||
## 🛤️ 后续规划
|
||||
@@ -372,7 +390,7 @@ Day 12: iOS 兼容与移动端优化 ✅ 完成
|
||||
- 渐变背景统一 (body 全局渐变,消除分层)
|
||||
- 移动端 Header 响应式优化 (按钮紧凑布局)
|
||||
- 发布页面 UI 重构 (立即发布 3/4 + 定时 1/4)
|
||||
- **Qwen3-TTS 0.6B 部署** (声音克隆模型,GPU0)
|
||||
- **Qwen3-TTS 1.7B 部署** (声音克隆模型,GPU0)
|
||||
- **部署文档** (QWEN3_TTS_DEPLOY.md)
|
||||
|
||||
Day 13: 声音克隆 + 字幕功能 ✅ 完成
|
||||
@@ -387,3 +405,17 @@ Day 13: 声音克隆 + 字幕功能 ✅ 完成
|
||||
- **前端标题/字幕设置 UI**
|
||||
- **部署文档** (SUBTITLE_DEPLOY.md)
|
||||
|
||||
Day 14: 模型升级 + AI 标题标签 + 前端修复 ✅ 完成
|
||||
- Qwen3-TTS 1.7B 模型升级 (0.6B → 1.7B-Base)
|
||||
- 字幕样式与标题动画优化 (Remotion)
|
||||
- AI 标题/标签生成接口 + 前端同步
|
||||
- 文案/标题本地保存修复 (刷新后恢复)
|
||||
- 登录页刷新循环修复 (公开路由跳转豁免)
|
||||
|
||||
Day 15: 手机号登录迁移 + 账户设置 ✅ 完成
|
||||
- 认证系统迁移 (邮箱 → 11位手机号)
|
||||
- 修改密码 API (/api/auth/change-password)
|
||||
- 账户设置下拉菜单 (修改密码 + 退出登录)
|
||||
- 前端登录/注册页面更新
|
||||
- 数据库迁移脚本 (migrate_to_phone.sql)
|
||||
|
||||
|
||||
13
README.md
13
README.md
@@ -10,14 +10,17 @@
|
||||
|
||||
- 🎬 **唇形同步** - LatentSync 1.6 驱动,512×512 高分辨率 Diffusion 模型
|
||||
- 🎙️ **TTS 配音** - EdgeTTS 多音色支持(云溪、晓晓等)
|
||||
- 🔊 **声音克隆** - Qwen3-TTS 0.6B,3秒参考音频快速克隆
|
||||
- 🔊 **声音克隆** - Qwen3-TTS 1.7B,3秒参考音频快速克隆(更高质量)
|
||||
- 📝 **逐字高亮字幕** - faster-whisper + Remotion,卡拉OK效果 🆕
|
||||
- 🎬 **片头标题** - 淡入淡出动画,可自定义 🆕
|
||||
- 🤖 **AI 标题/标签生成** - GLM-4-Flash 自动生成标题与标签 🆕
|
||||
- 📱 **全自动发布** - 扫码登录 + Cookie持久化,支持多平台(B站/抖音/小红书)定时发布
|
||||
- 🖥️ **Web UI** - Next.js 现代化界面,iOS/Android 移动端适配
|
||||
- 🔐 **用户系统** - Supabase + JWT 认证,支持管理员后台、注册/登录
|
||||
- 🔐 **用户系统** - Supabase + JWT 认证,**手机号登录** + 管理员后台 🆕
|
||||
- ⚙️ **账户设置** - 修改密码 + 有效期显示 + 安全退出 🆕
|
||||
- 👥 **多用户隔离** - 素材/视频/Cookie 按用户独立存储,数据完全隔离
|
||||
- 🚀 **性能优化** - 视频预压缩、常驻模型服务 (0s加载)、本地文件直读
|
||||
- 🚀 **性能优化** - 视频预压缩、常驻模型服务 (0s加载)、本地文件直读、并发控制
|
||||
- 🌐 **全局任务管理** - 跨页面任务状态同步,实时进度显示
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
@@ -30,7 +33,7 @@
|
||||
| 认证 | **JWT** + HttpOnly Cookie |
|
||||
| 唇形同步 | **LatentSync 1.6** (Latent Diffusion, 512×512) |
|
||||
| TTS | EdgeTTS |
|
||||
| 声音克隆 | **Qwen3-TTS 0.6B** |
|
||||
| 声音克隆 | **Qwen3-TTS 1.7B** |
|
||||
| 字幕渲染 | **faster-whisper + Remotion** |
|
||||
| 视频处理 | FFmpeg |
|
||||
| 自动发布 | Playwright |
|
||||
@@ -155,9 +158,11 @@ nohup python -m scripts.server > server.log 2>&1 &
|
||||
|
||||
- [手动部署指南](Docs/DEPLOY_MANUAL.md)
|
||||
- [Supabase 部署指南](Docs/SUPABASE_DEPLOY.md)
|
||||
- [Qwen3-TTS 部署指南](Docs/QWEN3_TTS_DEPLOY.md)
|
||||
- [字幕功能部署指南](Docs/SUBTITLE_DEPLOY.md)
|
||||
- [LatentSync 部署指南](models/LatentSync/DEPLOY.md)
|
||||
- [开发日志](Docs/DevLogs/)
|
||||
- [Day 15 - 手机号登录 + 账户设置](Docs/DevLogs/Day15.md) 🆕
|
||||
- [任务进度](Docs/task_complete.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -20,16 +20,16 @@ LATENTSYNC_GPU_ID=1
|
||||
LATENTSYNC_LOCAL=true
|
||||
|
||||
# 使用常驻服务 (Persistent Server) 加速
|
||||
LATENTSYNC_USE_SERVER=false
|
||||
LATENTSYNC_USE_SERVER=true
|
||||
|
||||
# 远程 API 地址 (常驻服务默认端口 8007)
|
||||
# LATENTSYNC_API_URL=http://localhost:8007
|
||||
|
||||
# 推理步数 (20-50, 越高质量越好,速度越慢)
|
||||
LATENTSYNC_INFERENCE_STEPS=20
|
||||
LATENTSYNC_INFERENCE_STEPS=40
|
||||
|
||||
# 引导系数 (1.0-3.0, 越高唇同步越准,但可能抖动)
|
||||
LATENTSYNC_GUIDANCE_SCALE=1.5
|
||||
LATENTSYNC_GUIDANCE_SCALE=2.0
|
||||
|
||||
# 启用 DeepCache 加速 (推荐开启)
|
||||
LATENTSYNC_ENABLE_DEEPCACHE=true
|
||||
@@ -59,5 +59,5 @@ JWT_EXPIRE_HOURS=168
|
||||
|
||||
# =============== 管理员配置 ===============
|
||||
# 服务启动时自动创建的管理员账号
|
||||
ADMIN_EMAIL=lamnickdavid@gmail.com
|
||||
ADMIN_PHONE=15549380526
|
||||
ADMIN_PASSWORD=lam1988324
|
||||
|
||||
@@ -14,7 +14,7 @@ router = APIRouter(prefix="/api/admin", tags=["管理"])
|
||||
|
||||
class UserListItem(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
phone: str
|
||||
username: Optional[str]
|
||||
role: str
|
||||
is_active: bool
|
||||
@@ -36,7 +36,7 @@ async def list_users(admin: dict = Depends(get_current_admin)):
|
||||
return [
|
||||
UserListItem(
|
||||
id=u["id"],
|
||||
email=u["email"],
|
||||
phone=u["phone"],
|
||||
username=u.get("username"),
|
||||
role=u["role"],
|
||||
is_active=u["is_active"],
|
||||
@@ -87,7 +87,7 @@ async def activate_user(
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
logger.info(f"管理员 {admin['email']} 激活用户 {user_id}, 有效期: {request.expires_days or '永久'} 天")
|
||||
logger.info(f"管理员 {admin['phone']} 激活用户 {user_id}, 有效期: {request.expires_days or '永久'} 天")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -128,7 +128,7 @@ async def deactivate_user(
|
||||
# 清除用户 session
|
||||
supabase.table("user_sessions").delete().eq("user_id", user_id).execute()
|
||||
|
||||
logger.info(f"管理员 {admin['email']} 停用用户 {user_id}")
|
||||
logger.info(f"管理员 {admin['phone']} 停用用户 {user_id}")
|
||||
|
||||
return {"success": True, "message": "用户已停用"}
|
||||
except HTTPException:
|
||||
@@ -171,7 +171,7 @@ async def extend_user(
|
||||
"expires_at": expires_at
|
||||
}).eq("id", user_id).execute()
|
||||
|
||||
logger.info(f"管理员 {admin['email']} 延长用户 {user_id} 授权 {request.expires_days or '永久'} 天")
|
||||
logger.info(f"管理员 {admin['phone']} 延长用户 {user_id} 授权 {request.expires_days or '永久'} 天")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
45
backend/app/api/ai.py
Normal file
45
backend/app/api/ai.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
AI 相关 API 路由
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from loguru import logger
|
||||
|
||||
from app.services.glm_service import glm_service
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/ai", tags=["AI"])
|
||||
|
||||
|
||||
class GenerateMetaRequest(BaseModel):
|
||||
"""生成标题标签请求"""
|
||||
text: str
|
||||
|
||||
|
||||
class GenerateMetaResponse(BaseModel):
|
||||
"""生成标题标签响应"""
|
||||
title: str
|
||||
tags: list[str]
|
||||
|
||||
|
||||
@router.post("/generate-meta", response_model=GenerateMetaResponse)
|
||||
async def generate_meta(req: GenerateMetaRequest):
|
||||
"""
|
||||
AI 生成视频标题和标签
|
||||
|
||||
根据口播文案自动生成吸引人的标题和相关标签
|
||||
"""
|
||||
if not req.text or not req.text.strip():
|
||||
raise HTTPException(status_code=400, detail="口播文案不能为空")
|
||||
|
||||
try:
|
||||
logger.info(f"Generating meta for text: {req.text[:50]}...")
|
||||
result = await glm_service.generate_title_tags(req.text)
|
||||
return GenerateMetaResponse(
|
||||
title=result.get("title", ""),
|
||||
tags=result.get("tags", [])
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Generate meta failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
认证 API:注册、登录、登出
|
||||
认证 API:注册、登录、登出、修改密码
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Response, status, Request
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from pydantic import BaseModel, field_validator
|
||||
from app.core.supabase import get_supabase
|
||||
from app.core.security import (
|
||||
get_password_hash,
|
||||
@@ -15,27 +15,55 @@ from app.core.security import (
|
||||
)
|
||||
from loguru import logger
|
||||
from typing import Optional
|
||||
import re
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
phone: str
|
||||
password: str
|
||||
username: Optional[str] = None
|
||||
|
||||
@field_validator('phone')
|
||||
@classmethod
|
||||
def validate_phone(cls, v):
|
||||
if not re.match(r'^\d{11}$', v):
|
||||
raise ValueError('手机号必须是11位数字')
|
||||
return v
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
phone: str
|
||||
password: str
|
||||
|
||||
@field_validator('phone')
|
||||
@classmethod
|
||||
def validate_phone(cls, v):
|
||||
if not re.match(r'^\d{11}$', v):
|
||||
raise ValueError('手机号必须是11位数字')
|
||||
return v
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
@field_validator('new_password')
|
||||
@classmethod
|
||||
def validate_new_password(cls, v):
|
||||
if len(v) < 6:
|
||||
raise ValueError('新密码长度至少6位')
|
||||
return v
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
phone: str
|
||||
username: Optional[str]
|
||||
role: str
|
||||
is_active: bool
|
||||
expires_at: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
@@ -48,29 +76,29 @@ async def register(request: RegisterRequest):
|
||||
try:
|
||||
supabase = get_supabase()
|
||||
|
||||
# 检查邮箱是否已存在
|
||||
# 检查手机号是否已存在
|
||||
existing = supabase.table("users").select("id").eq(
|
||||
"email", request.email
|
||||
"phone", request.phone
|
||||
).execute()
|
||||
|
||||
if existing.data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该邮箱已注册"
|
||||
detail="该手机号已注册"
|
||||
)
|
||||
|
||||
# 创建用户
|
||||
password_hash = get_password_hash(request.password)
|
||||
|
||||
result = supabase.table("users").insert({
|
||||
"email": request.email,
|
||||
"phone": request.phone,
|
||||
"password_hash": password_hash,
|
||||
"username": request.username or request.email.split("@")[0],
|
||||
"username": request.username or f"用户{request.phone[-4:]}",
|
||||
"role": "pending",
|
||||
"is_active": False
|
||||
}).execute()
|
||||
|
||||
logger.info(f"新用户注册: {request.email}")
|
||||
logger.info(f"新用户注册: {request.phone}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -100,21 +128,21 @@ async def login(request: LoginRequest, response: Response):
|
||||
|
||||
# 查找用户
|
||||
user_result = supabase.table("users").select("*").eq(
|
||||
"email", request.email
|
||||
"phone", request.phone
|
||||
).single().execute()
|
||||
|
||||
user = user_result.data
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="邮箱或密码错误"
|
||||
detail="手机号或密码错误"
|
||||
)
|
||||
|
||||
# 验证密码
|
||||
if not verify_password(request.password, user["password_hash"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="邮箱或密码错误"
|
||||
detail="手机号或密码错误"
|
||||
)
|
||||
|
||||
# 检查是否激活
|
||||
@@ -154,17 +182,18 @@ async def login(request: LoginRequest, response: Response):
|
||||
# 设置 HttpOnly Cookie
|
||||
set_auth_cookie(response, token)
|
||||
|
||||
logger.info(f"用户登录: {request.email}")
|
||||
logger.info(f"用户登录: {request.phone}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "登录成功",
|
||||
"user": UserResponse(
|
||||
id=user["id"],
|
||||
email=user["email"],
|
||||
phone=user["phone"],
|
||||
username=user.get("username"),
|
||||
role=user["role"],
|
||||
is_active=user["is_active"]
|
||||
is_active=user["is_active"],
|
||||
expires_at=user.get("expires_at")
|
||||
)
|
||||
}
|
||||
except HTTPException:
|
||||
@@ -184,6 +213,91 @@ async def logout(response: Response):
|
||||
return {"success": True, "message": "已登出"}
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(request: ChangePasswordRequest, req: Request, response: Response):
|
||||
"""
|
||||
修改密码
|
||||
|
||||
- 验证当前密码
|
||||
- 设置新密码
|
||||
- 重新生成 session token
|
||||
"""
|
||||
# 从 Cookie 获取用户
|
||||
token = req.cookies.get("access_token")
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="未登录"
|
||||
)
|
||||
|
||||
token_data = decode_access_token(token)
|
||||
if not token_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token 无效"
|
||||
)
|
||||
|
||||
try:
|
||||
supabase = get_supabase()
|
||||
|
||||
# 获取用户信息
|
||||
user_result = supabase.table("users").select("*").eq(
|
||||
"id", token_data.user_id
|
||||
).single().execute()
|
||||
|
||||
user = user_result.data
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 验证当前密码
|
||||
if not verify_password(request.old_password, user["password_hash"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="当前密码错误"
|
||||
)
|
||||
|
||||
# 更新密码
|
||||
new_password_hash = get_password_hash(request.new_password)
|
||||
supabase.table("users").update({
|
||||
"password_hash": new_password_hash
|
||||
}).eq("id", user["id"]).execute()
|
||||
|
||||
# 生成新的 session token,使旧 token 失效
|
||||
new_session_token = generate_session_token()
|
||||
|
||||
supabase.table("user_sessions").delete().eq(
|
||||
"user_id", user["id"]
|
||||
).execute()
|
||||
|
||||
supabase.table("user_sessions").insert({
|
||||
"user_id": user["id"],
|
||||
"session_token": new_session_token,
|
||||
"device_info": None
|
||||
}).execute()
|
||||
|
||||
# 生成新的 JWT Token
|
||||
new_token = create_access_token(user["id"], new_session_token)
|
||||
set_auth_cookie(response, new_token)
|
||||
|
||||
logger.info(f"用户修改密码: {user['phone']}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "密码修改成功"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"修改密码失败: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="修改密码失败,请稍后重试"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(request: Request):
|
||||
"""获取当前用户信息"""
|
||||
@@ -216,8 +330,9 @@ async def get_me(request: Request):
|
||||
|
||||
return UserResponse(
|
||||
id=user["id"],
|
||||
email=user["email"],
|
||||
phone=user["phone"],
|
||||
username=user.get("username"),
|
||||
role=user["role"],
|
||||
is_active=user["is_active"]
|
||||
is_active=user["is_active"],
|
||||
expires_at=user.get("expires_at")
|
||||
)
|
||||
|
||||
@@ -22,9 +22,8 @@ class Settings(BaseSettings):
|
||||
LATENTSYNC_INFERENCE_STEPS: int = 20 # 推理步数 [20-50]
|
||||
LATENTSYNC_GUIDANCE_SCALE: float = 1.5 # 引导系数 [1.0-3.0]
|
||||
LATENTSYNC_ENABLE_DEEPCACHE: bool = True # 启用 DeepCache 加速
|
||||
LATENTSYNC_ENABLE_DEEPCACHE: bool = True # 启用 DeepCache 加速
|
||||
LATENTSYNC_SEED: int = 1247 # 随机种子 (-1 则随机)
|
||||
LATENTSYNC_USE_SERVER: bool = False # 使用常驻服务 (Persistent Server) 加速
|
||||
LATENTSYNC_USE_SERVER: bool = True # 使用常驻服务 (Persistent Server) 加速
|
||||
|
||||
# Supabase 配置
|
||||
SUPABASE_URL: str = ""
|
||||
@@ -37,7 +36,7 @@ class Settings(BaseSettings):
|
||||
JWT_EXPIRE_HOURS: int = 24
|
||||
|
||||
# 管理员配置
|
||||
ADMIN_EMAIL: str = ""
|
||||
ADMIN_PHONE: str = ""
|
||||
ADMIN_PASSWORD: str = ""
|
||||
|
||||
@property
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core import config
|
||||
from app.api import materials, videos, publish, login_helper, auth, admin, ref_audios
|
||||
from app.api import materials, videos, publish, login_helper, auth, admin, ref_audios, ai
|
||||
from loguru import logger
|
||||
import os
|
||||
|
||||
@@ -56,6 +56,7 @@ app.include_router(login_helper.router, prefix="/api", tags=["LoginHelper"])
|
||||
app.include_router(auth.router) # /api/auth
|
||||
app.include_router(admin.router) # /api/admin
|
||||
app.include_router(ref_audios.router, prefix="/api/ref-audios", tags=["RefAudios"])
|
||||
app.include_router(ai.router) # /api/ai
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
@@ -63,11 +64,11 @@ async def init_admin():
|
||||
"""
|
||||
服务启动时初始化管理员账号
|
||||
"""
|
||||
admin_email = settings.ADMIN_EMAIL
|
||||
admin_phone = settings.ADMIN_PHONE
|
||||
admin_password = settings.ADMIN_PASSWORD
|
||||
|
||||
if not admin_email or not admin_password:
|
||||
logger.warning("未配置 ADMIN_EMAIL 和 ADMIN_PASSWORD,跳过管理员初始化")
|
||||
if not admin_phone or not admin_password:
|
||||
logger.warning("未配置 ADMIN_PHONE 和 ADMIN_PASSWORD,跳过管理员初始化")
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -77,15 +78,15 @@ async def init_admin():
|
||||
supabase = get_supabase()
|
||||
|
||||
# 检查是否已存在
|
||||
existing = supabase.table("users").select("id").eq("email", admin_email).execute()
|
||||
existing = supabase.table("users").select("id").eq("phone", admin_phone).execute()
|
||||
|
||||
if existing.data:
|
||||
logger.info(f"管理员账号已存在: {admin_email}")
|
||||
logger.info(f"管理员账号已存在: {admin_phone}")
|
||||
return
|
||||
|
||||
# 创建管理员
|
||||
supabase.table("users").insert({
|
||||
"email": admin_email,
|
||||
"phone": admin_phone,
|
||||
"password_hash": get_password_hash(admin_password),
|
||||
"username": "Admin",
|
||||
"role": "admin",
|
||||
@@ -93,7 +94,7 @@ async def init_admin():
|
||||
"expires_at": None # 永不过期
|
||||
}).execute()
|
||||
|
||||
logger.success(f"管理员账号已创建: {admin_email}")
|
||||
logger.success(f"管理员账号已创建: {admin_phone}")
|
||||
except Exception as e:
|
||||
logger.error(f"初始化管理员失败: {e}")
|
||||
|
||||
|
||||
102
backend/app/services/glm_service.py
Normal file
102
backend/app/services/glm_service.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
GLM AI 服务
|
||||
使用智谱 GLM-4.7-Flash 生成标题和标签
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class GLMService:
|
||||
"""GLM AI 服务"""
|
||||
|
||||
API_URL = "https://open.bigmodel.cn/api/paas/v4/chat/completions"
|
||||
API_KEY = "5915240ea48d4e93b454bc2412d1cc54.e054ej4pPqi9G6rc"
|
||||
|
||||
async def generate_title_tags(self, text: str) -> dict:
|
||||
"""
|
||||
根据口播文案生成标题和标签
|
||||
|
||||
Args:
|
||||
text: 口播文案
|
||||
|
||||
Returns:
|
||||
{"title": "标题", "tags": ["标签1", "标签2", ...]}
|
||||
"""
|
||||
prompt = f"""根据以下口播文案,生成一个吸引人的短视频标题和3个相关标签。
|
||||
|
||||
口播文案:
|
||||
{text}
|
||||
|
||||
要求:
|
||||
1. 标题要简洁有力,能吸引观众点击,不超过10个字
|
||||
2. 标签要与内容相关,便于搜索和推荐,只要3个
|
||||
|
||||
请严格按以下JSON格式返回(不要包含其他内容):
|
||||
{{"title": "标题", "tags": ["标签1", "标签2", "标签3"]}}"""
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
self.API_URL,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.API_KEY}"
|
||||
},
|
||||
json={
|
||||
"model": "glm-4-flash",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 500,
|
||||
"temperature": 0.7
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# 提取生成的内容
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
logger.info(f"GLM response: {content}")
|
||||
|
||||
# 解析 JSON
|
||||
result = self._parse_json_response(content)
|
||||
return result
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"GLM API request failed: {e}")
|
||||
raise Exception(f"AI 服务请求失败: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"GLM service error: {e}")
|
||||
raise Exception(f"AI 生成失败: {str(e)}")
|
||||
|
||||
def _parse_json_response(self, content: str) -> dict:
|
||||
"""解析 GLM 返回的 JSON 内容"""
|
||||
# 尝试直接解析
|
||||
try:
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 尝试提取 JSON 块
|
||||
json_match = re.search(r'\{[^{}]*"title"[^{}]*"tags"[^{}]*\}', content, re.DOTALL)
|
||||
if json_match:
|
||||
try:
|
||||
return json.loads(json_match.group())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 尝试提取 ```json 代码块
|
||||
code_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', content, re.DOTALL)
|
||||
if code_match:
|
||||
try:
|
||||
return json.loads(code_match.group(1))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
logger.error(f"Failed to parse GLM response: {content}")
|
||||
raise Exception("AI 返回格式解析失败")
|
||||
|
||||
|
||||
# 全局服务实例
|
||||
glm_service = GLMService()
|
||||
@@ -74,6 +74,50 @@ class LipSyncService:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _get_media_duration(self, media_path: str) -> Optional[float]:
|
||||
"""获取音频或视频的时长(秒)"""
|
||||
try:
|
||||
cmd = [
|
||||
"ffprobe", "-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
media_path
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
if result.returncode == 0:
|
||||
return float(result.stdout.strip())
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 获取媒体时长失败: {e}")
|
||||
return None
|
||||
|
||||
def _loop_video_to_duration(self, video_path: str, output_path: str, target_duration: float) -> str:
|
||||
"""
|
||||
循环视频以匹配目标时长
|
||||
使用 FFmpeg stream_loop 实现无缝循环
|
||||
"""
|
||||
try:
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-stream_loop", "-1", # 无限循环
|
||||
"-i", video_path,
|
||||
"-t", str(target_duration), # 截取到目标时长
|
||||
"-c:v", "libx264",
|
||||
"-preset", "fast",
|
||||
"-crf", "18",
|
||||
"-an", # 去掉原音频
|
||||
output_path
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
if result.returncode == 0 and Path(output_path).exists():
|
||||
logger.info(f"✅ 视频循环完成: {target_duration:.1f}s")
|
||||
return output_path
|
||||
else:
|
||||
logger.warning(f"⚠️ 视频循环失败: {result.stderr[:200]}")
|
||||
return video_path
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 视频循环异常: {e}")
|
||||
return video_path
|
||||
|
||||
def _preprocess_video(self, video_path: str, output_path: str, target_height: int = 720) -> str:
|
||||
"""
|
||||
视频预处理:压缩视频以加速后续处理
|
||||
@@ -204,27 +248,34 @@ class LipSyncService:
|
||||
|
||||
logger.info("⏳ 等待 GPU 资源 (排队中)...")
|
||||
async with self._lock:
|
||||
# 使用临时目录存放中间文件
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
|
||||
# 获取音频和视频时长
|
||||
audio_duration = self._get_media_duration(audio_path)
|
||||
video_duration = self._get_media_duration(video_path)
|
||||
|
||||
# 如果音频比视频长,循环视频以匹配音频长度
|
||||
if audio_duration and video_duration and audio_duration > video_duration + 0.5:
|
||||
logger.info(f"🔄 音频({audio_duration:.1f}s) > 视频({video_duration:.1f}s),循环视频...")
|
||||
looped_video = tmpdir / "looped_input.mp4"
|
||||
actual_video_path = self._loop_video_to_duration(
|
||||
video_path,
|
||||
str(looped_video),
|
||||
audio_duration
|
||||
)
|
||||
else:
|
||||
actual_video_path = video_path
|
||||
|
||||
if self.use_server:
|
||||
# 模式 A: 调用常驻服务 (加速模式)
|
||||
return await self._call_persistent_server(video_path, audio_path, output_path)
|
||||
return await self._call_persistent_server(actual_video_path, audio_path, output_path)
|
||||
|
||||
logger.info("🔄 调用 LatentSync 推理 (subprocess)...")
|
||||
|
||||
# 使用临时目录存放输出
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
temp_output = tmpdir / "output.mp4"
|
||||
|
||||
# 视频预处理:压缩高分辨率视频以加速处理
|
||||
# preprocessed_video = tmpdir / "preprocessed_input.mp4"
|
||||
# actual_video_path = self._preprocess_video(
|
||||
# video_path,
|
||||
# str(preprocessed_video),
|
||||
# target_height=720
|
||||
# )
|
||||
# 暂时禁用预处理以保持原始分辨率
|
||||
actual_video_path = video_path
|
||||
|
||||
# 构建命令
|
||||
cmd = [
|
||||
str(self.conda_python),
|
||||
|
||||
@@ -82,8 +82,15 @@ class VideoService:
|
||||
# Previous state: subtitles disabled due to font issues
|
||||
# if subtitle_path: ...
|
||||
|
||||
# Audio map
|
||||
cmd.extend(["-c:v", "libx264", "-c:a", "aac", "-shortest"])
|
||||
# Audio map with high quality encoding
|
||||
cmd.extend([
|
||||
"-c:v", "libx264",
|
||||
"-preset", "slow", # 慢速预设,更好的压缩效率
|
||||
"-crf", "18", # 高质量(与 LatentSync 一致)
|
||||
"-c:a", "aac",
|
||||
"-b:a", "192k", # 音频比特率
|
||||
"-shortest"
|
||||
])
|
||||
# Use audio from input 1
|
||||
cmd.extend(["-map", "0:v", "-map", "1:a"])
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
通过 HTTP 调用 Qwen3-TTS 独立服务 (端口 8009)
|
||||
"""
|
||||
import httpx
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from loguru import logger
|
||||
@@ -21,6 +22,8 @@ class VoiceCloneService:
|
||||
# 健康状态缓存
|
||||
self._health_cache: Optional[dict] = None
|
||||
self._health_cache_time: float = 0
|
||||
# GPU 并发锁 (Serial Queue)
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def generate_audio(
|
||||
self,
|
||||
@@ -43,6 +46,8 @@ class VoiceCloneService:
|
||||
Returns:
|
||||
输出文件路径
|
||||
"""
|
||||
# 使用锁确保串行执行,避免 GPU 显存溢出
|
||||
async with self._lock:
|
||||
logger.info(f"🎤 Voice Clone: {text[:30]}...")
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@@ -6,12 +6,17 @@
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
from loguru import logger
|
||||
|
||||
# 模型缓存
|
||||
_whisper_model = None
|
||||
|
||||
# 断句标点
|
||||
SENTENCE_PUNCTUATION = set('。!?,、;:,.!?;:')
|
||||
# 每行最大字数
|
||||
MAX_CHARS_PER_LINE = 12
|
||||
|
||||
|
||||
def split_word_to_chars(word: str, start: float, end: float) -> list:
|
||||
"""
|
||||
@@ -50,6 +55,61 @@ def split_word_to_chars(word: str, start: float, end: float) -> list:
|
||||
return result
|
||||
|
||||
|
||||
def split_segment_to_lines(words: List[dict], max_chars: int = MAX_CHARS_PER_LINE) -> List[dict]:
|
||||
"""
|
||||
将长段落按标点和字数拆分成多行
|
||||
|
||||
Args:
|
||||
words: 字列表,每个包含 word/start/end
|
||||
max_chars: 每行最大字数
|
||||
|
||||
Returns:
|
||||
拆分后的 segment 列表
|
||||
"""
|
||||
if not words:
|
||||
return []
|
||||
|
||||
segments = []
|
||||
current_words = []
|
||||
current_text = ""
|
||||
|
||||
for word_info in words:
|
||||
char = word_info["word"]
|
||||
current_words.append(word_info)
|
||||
current_text += char
|
||||
|
||||
# 判断是否需要断句
|
||||
should_break = False
|
||||
|
||||
# 1. 遇到断句标点
|
||||
if char in SENTENCE_PUNCTUATION:
|
||||
should_break = True
|
||||
# 2. 达到最大字数
|
||||
elif len(current_text) >= max_chars:
|
||||
should_break = True
|
||||
|
||||
if should_break and current_words:
|
||||
segments.append({
|
||||
"text": current_text,
|
||||
"start": current_words[0]["start"],
|
||||
"end": current_words[-1]["end"],
|
||||
"words": current_words.copy()
|
||||
})
|
||||
current_words = []
|
||||
current_text = ""
|
||||
|
||||
# 处理剩余的字
|
||||
if current_words:
|
||||
segments.append({
|
||||
"text": current_text,
|
||||
"start": current_words[0]["start"],
|
||||
"end": current_words[-1]["end"],
|
||||
"words": current_words.copy()
|
||||
})
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
class WhisperService:
|
||||
"""字幕对齐服务(基于 faster-whisper)"""
|
||||
|
||||
@@ -114,16 +174,10 @@ class WhisperService:
|
||||
|
||||
logger.info(f"Detected language: {info.language} (prob: {info.language_probability:.2f})")
|
||||
|
||||
segments = []
|
||||
all_segments = []
|
||||
for segment in segments_iter:
|
||||
seg_data = {
|
||||
"text": segment.text.strip(),
|
||||
"start": segment.start,
|
||||
"end": segment.end,
|
||||
"words": []
|
||||
}
|
||||
|
||||
# 提取每个字的时间戳,并拆分成单字
|
||||
all_words = []
|
||||
if segment.words:
|
||||
for word_info in segment.words:
|
||||
word_text = word_info.word.strip()
|
||||
@@ -134,12 +188,15 @@ class WhisperService:
|
||||
word_info.start,
|
||||
word_info.end
|
||||
)
|
||||
seg_data["words"].extend(chars)
|
||||
all_words.extend(chars)
|
||||
|
||||
if seg_data["words"]: # 只添加有内容的段落
|
||||
segments.append(seg_data)
|
||||
# 将长段落按标点和字数拆分成多行
|
||||
if all_words:
|
||||
line_segments = split_segment_to_lines(all_words, MAX_CHARS_PER_LINE)
|
||||
all_segments.extend(line_segments)
|
||||
|
||||
return {"segments": segments}
|
||||
logger.info(f"Generated {len(all_segments)} subtitle segments")
|
||||
return {"segments": all_segments}
|
||||
|
||||
# 在线程池中执行
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
88
backend/database/migrate_to_phone.sql
Normal file
88
backend/database/migrate_to_phone.sql
Normal file
@@ -0,0 +1,88 @@
|
||||
-- ============================================================
|
||||
-- ViGent 手机号登录迁移脚本
|
||||
-- 用于将 email 字段改为 phone 字段
|
||||
--
|
||||
-- 执行方式(任选一种):
|
||||
-- 1. Supabase Studio: 打开 https://supabase.hbyrkj.top -> SQL Editor -> 粘贴执行
|
||||
-- 2. Docker 命令: docker exec -i supabase-db psql -U postgres < migrate_to_phone.sql
|
||||
-- ============================================================
|
||||
|
||||
-- 注意:此脚本会删除现有的用户数据!
|
||||
-- 如需保留数据,请先备份
|
||||
|
||||
-- 1. 删除依赖表(有外键约束)
|
||||
DROP TABLE IF EXISTS user_sessions CASCADE;
|
||||
DROP TABLE IF EXISTS social_accounts CASCADE;
|
||||
|
||||
-- 2. 删除用户表
|
||||
DROP TABLE IF EXISTS users CASCADE;
|
||||
|
||||
-- 3. 重新创建 users 表(使用 phone 字段)
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
phone TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
username TEXT,
|
||||
role TEXT DEFAULT 'pending' CHECK (role IN ('pending', 'user', 'admin')),
|
||||
is_active BOOLEAN DEFAULT FALSE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 4. 重新创建 user_sessions 表
|
||||
CREATE TABLE user_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
|
||||
session_token TEXT UNIQUE NOT NULL,
|
||||
device_info TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 5. 重新创建 social_accounts 表
|
||||
CREATE TABLE social_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
platform TEXT NOT NULL CHECK (platform IN ('bilibili', 'douyin', 'xiaohongshu')),
|
||||
logged_in BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(user_id, platform)
|
||||
);
|
||||
|
||||
-- 6. 创建索引
|
||||
CREATE INDEX idx_users_phone ON users(phone);
|
||||
CREATE INDEX idx_sessions_user_id ON user_sessions(user_id);
|
||||
CREATE INDEX idx_social_user_platform ON social_accounts(user_id, platform);
|
||||
|
||||
-- 7. 启用 RLS
|
||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE user_sessions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE social_accounts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 8. 创建 RLS 策略
|
||||
CREATE POLICY "Users can view own profile" ON users
|
||||
FOR SELECT USING (auth.uid()::text = id::text);
|
||||
|
||||
CREATE POLICY "Users can access own sessions" ON user_sessions
|
||||
FOR ALL USING (user_id::text = auth.uid()::text);
|
||||
|
||||
CREATE POLICY "Users can access own social accounts" ON social_accounts
|
||||
FOR ALL USING (user_id::text = auth.uid()::text);
|
||||
|
||||
-- 9. 更新时间触发器
|
||||
CREATE OR REPLACE FUNCTION update_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS users_updated_at ON users;
|
||||
CREATE TRIGGER users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- 完成!
|
||||
-- 管理员账号会在后端服务重启时自动创建 (15549380526)
|
||||
@@ -4,7 +4,7 @@
|
||||
-- 1. 创建 users 表
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
phone TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
username TEXT,
|
||||
role TEXT DEFAULT 'pending' CHECK (role IN ('pending', 'user', 'admin')),
|
||||
@@ -34,7 +34,7 @@ CREATE TABLE IF NOT EXISTS social_accounts (
|
||||
);
|
||||
|
||||
-- 4. 创建索引
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_phone ON users(phone);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON user_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_social_user_platform ON social_accounts(user_id, platform);
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
|
||||
### 1. 视频生成 (`/`)
|
||||
- **素材管理**: 拖拽上传人物视频,实时预览。
|
||||
- **文案配音**: 集成 EdgeTTS,支持多音色选择 (云溪 / 晓晓)。
|
||||
- **AI 标题/标签**: 一键生成视频标题与标签 (Day 14)。
|
||||
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
|
||||
- **结果预览**: 生成完成后直接播放下载。
|
||||
- **本地保存**: 文案/标题自动保存,刷新后恢复 (Day 14)。
|
||||
|
||||
### 2. 全自动发布 (`/publish`) [Day 7 新增]
|
||||
- **多平台管理**: 统一管理 B站、抖音、小红书账号状态。
|
||||
@@ -29,13 +31,18 @@ ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
|
||||
- **逐字高亮字幕**: 卡拉OK效果,默认开启,可关闭。
|
||||
- **自动对齐**: 基于 faster-whisper 生成字级别时间戳。
|
||||
|
||||
### 5. 账户设置 [Day 15 新增]
|
||||
- **手机号登录**: 11位中国手机号验证登录。
|
||||
- **账户下拉菜单**: 显示有效期 + 修改密码 + 安全退出。
|
||||
- **修改密码**: 弹窗输入当前密码与新密码,修改后强制重新登录。
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **框架**: Next.js 14 (App Router)
|
||||
- **样式**: TailwindCSS
|
||||
- **图标**: Lucide React
|
||||
- **组件**: 自定义现代化组件 (Glassmorphism 风格)
|
||||
- **API**: Fetch API (对接后端 FastAPI :8006)
|
||||
- **API**: Axios 实例 `@/lib/axios` (对接后端 FastAPI :8006)
|
||||
|
||||
## 🚀 开发指南
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import api from '@/lib/axios';
|
||||
|
||||
interface UserListItem {
|
||||
id: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
username: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
@@ -144,8 +144,8 @@ export default function AdminPage() {
|
||||
<tr key={user.id} className="hover:bg-white/5">
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<div className="text-white font-medium">{user.username || user.email.split('@')[0]}</div>
|
||||
<div className="text-gray-400 text-sm">{user.email}</div>
|
||||
<div className="text-white font-medium">{user.username || `用户${user.phone.slice(-4)}`}</div>
|
||||
<div className="text-gray-400 text-sm">{user.phone}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import { TaskProvider } from "@/contexts/TaskContext";
|
||||
import GlobalTaskIndicator from "@/components/GlobalTaskIndicator";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -13,7 +16,7 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "ViGent",
|
||||
title: "IPAgent",
|
||||
description: "ViGent Talking Head Agent",
|
||||
};
|
||||
|
||||
@@ -39,7 +42,11 @@ export default function RootLayout({
|
||||
background: 'linear-gradient(to bottom, #0f172a 0%, #0f172a 5%, #581c87 50%, #0f172a 95%, #0f172a 100%)',
|
||||
}}
|
||||
>
|
||||
<AuthProvider>
|
||||
<TaskProvider>
|
||||
{children}
|
||||
</TaskProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { login } from '@/lib/auth';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -14,10 +14,17 @@ export default function LoginPage() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// 验证手机号格式
|
||||
if (!/^\d{11}$/.test(phone)) {
|
||||
setError('请输入正确的11位手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await login(email, password);
|
||||
const result = await login(phone, password);
|
||||
if (result.success) {
|
||||
router.push('/');
|
||||
} else {
|
||||
@@ -41,15 +48,16 @@ export default function LoginPage() {
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||
邮箱
|
||||
手机号
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
|
||||
required
|
||||
maxLength={11}
|
||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="your@email.com"
|
||||
placeholder="请输入11位手机号"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import api from "@/lib/axios";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useTask } from "@/contexts/TaskContext";
|
||||
|
||||
const API_BASE = typeof window === 'undefined'
|
||||
? 'http://localhost:8006'
|
||||
@@ -54,15 +56,222 @@ const formatDate = (timestamp: number) => {
|
||||
return `${year}/${month}/${day} ${hour}:${minute}`;
|
||||
};
|
||||
|
||||
// 账户设置下拉菜单组件
|
||||
function AccountSettingsDropdown() {
|
||||
const { user } = useAuth();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 点击外部关闭菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// 格式化有效期
|
||||
const formatExpiry = (expiresAt: string | null) => {
|
||||
if (!expiresAt) return '永久有效';
|
||||
const date = new Date(expiresAt);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (confirm('确定要退出登录吗?')) {
|
||||
try {
|
||||
await api.post('/api/auth/logout');
|
||||
} catch (e) { }
|
||||
window.location.href = '/login';
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('两次输入的新密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError('新密码长度至少6位');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.post('/api/auth/change-password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword
|
||||
});
|
||||
if (res.data.success) {
|
||||
setSuccess('密码修改成功,正在跳转登录页...');
|
||||
// 清除登录状态并跳转
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await api.post('/api/auth/logout');
|
||||
} catch (e) { }
|
||||
window.location.href = '/login';
|
||||
}, 1500);
|
||||
} else {
|
||||
setError(res.data.message || '修改失败');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || '修改失败,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span>⚙️</span>
|
||||
<span className="hidden sm:inline">账户</span>
|
||||
<svg className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 下拉菜单 */}
|
||||
{isOpen && (
|
||||
<div ref={dropdownRef} className="absolute right-0 mt-2 bg-gray-800 border border-white/10 rounded-lg shadow-xl z-[160] overflow-hidden whitespace-nowrap">
|
||||
{/* 有效期显示 */}
|
||||
<div className="px-3 py-2 border-b border-white/10 text-center">
|
||||
<div className="text-xs text-gray-400">账户有效期</div>
|
||||
<div className="text-sm text-white font-medium">
|
||||
{user?.expires_at ? formatExpiry(user.expires_at) : '永久有效'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
setShowPasswordModal(true);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-white/10 flex items-center gap-2"
|
||||
>
|
||||
🔐 修改密码
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full px-3 py-2 text-left text-sm text-red-300 hover:bg-red-500/20 flex items-center gap-2"
|
||||
>
|
||||
🚪 退出登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 修改密码弹窗 */}
|
||||
{showPasswordModal && (
|
||||
<div className="fixed inset-0 z-[200] flex items-start justify-center pt-20 bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-md p-6 bg-gray-900 border border-white/10 rounded-2xl shadow-2xl mx-4">
|
||||
<h3 className="text-xl font-bold text-white mb-4">修改密码</h3>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-1">当前密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="输入当前密码"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-1">新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="至少6位"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-1">确认新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="再次输入新密码"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-2 bg-red-500/20 border border-red-500/50 rounded text-red-200 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="p-2 bg-green-500/20 border border-green-500/50 rounded text-green-200 text-sm">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowPasswordModal(false);
|
||||
setError('');
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
}}
|
||||
className="flex-1 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? '修改中...' : '确认修改'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [materials, setMaterials] = useState<Material[]>([]);
|
||||
const [selectedMaterial, setSelectedMaterial] = useState<string>("");
|
||||
const [text, setText] = useState<string>(
|
||||
"大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。"
|
||||
);
|
||||
|
||||
const [text, setText] = useState<string>("");
|
||||
const [voice, setVoice] = useState<string>("zh-CN-YunxiNeural");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [currentTask, setCurrentTask] = useState<Task | null>(null);
|
||||
|
||||
// 使用全局任务状态
|
||||
const { currentTask, isGenerating, startTask } = useTask();
|
||||
|
||||
const [generatedVideo, setGeneratedVideo] = useState<string | null>(null);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
const [debugData, setDebugData] = useState<string>("");
|
||||
@@ -86,6 +295,9 @@ export default function Home() {
|
||||
const [isUploadingRef, setIsUploadingRef] = useState(false);
|
||||
const [uploadRefError, setUploadRefError] = useState<string | null>(null);
|
||||
|
||||
// AI 生成标题标签
|
||||
const [isGeneratingMeta, setIsGeneratingMeta] = useState(false);
|
||||
|
||||
// 在线录音相关
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
|
||||
@@ -93,6 +305,11 @@ export default function Home() {
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const recordingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 使用全局认证状态
|
||||
const { userId, isLoading: isAuthLoading } = useAuth();
|
||||
// 是否已从 localStorage 恢复完成
|
||||
const [isRestored, setIsRestored] = useState(false);
|
||||
|
||||
// 可选音色
|
||||
const voices = [
|
||||
{ id: "zh-CN-YunxiNeural", name: "云溪 (男声-年轻)" },
|
||||
@@ -105,6 +322,9 @@ export default function Home() {
|
||||
// 声音克隆固定参考文字(用户录音/上传时需要读这段话)
|
||||
const FIXED_REF_TEXT = "其实生活中有许多美好的瞬间,比如清晨的阳光,或者一杯温热的清茶。希望这次生成的音色能够自然、流畅,完美还原出我最真实的声音状态。";
|
||||
|
||||
// 获取存储 key 的前缀(登录用户使用 userId,未登录使用 guest)
|
||||
const storageKey = userId || 'guest';
|
||||
|
||||
// 加载素材列表和历史视频
|
||||
useEffect(() => {
|
||||
fetchMaterials();
|
||||
@@ -112,6 +332,80 @@ export default function Home() {
|
||||
fetchRefAudios();
|
||||
}, []);
|
||||
|
||||
// 监听任务完成,自动显示视频
|
||||
useEffect(() => {
|
||||
if (currentTask?.status === 'completed' && currentTask.download_url) {
|
||||
const API_BASE = typeof window === 'undefined'
|
||||
? process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8006'
|
||||
: (process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8006');
|
||||
|
||||
setGeneratedVideo(`${API_BASE}${currentTask.download_url}`);
|
||||
fetchGeneratedVideos(); // 刷新历史视频列表
|
||||
}
|
||||
}, [currentTask?.status, currentTask?.download_url]);
|
||||
|
||||
// 从 localStorage 恢复用户输入(等待认证完成后)
|
||||
useEffect(() => {
|
||||
console.log("[Home] 恢复检查 - isAuthLoading:", isAuthLoading, "userId:", userId);
|
||||
if (isAuthLoading) return;
|
||||
|
||||
console.log("[Home] 开始从 localStorage 恢复数据,storageKey:", storageKey);
|
||||
// 从 localStorage 恢复用户输入(带用户隔离,未登录用户使用 guest)
|
||||
const savedText = localStorage.getItem(`vigent_${storageKey}_text`);
|
||||
const savedTitle = localStorage.getItem(`vigent_${storageKey}_title`);
|
||||
const savedSubtitles = localStorage.getItem(`vigent_${storageKey}_subtitles`);
|
||||
const savedTtsMode = localStorage.getItem(`vigent_${storageKey}_ttsMode`);
|
||||
const savedVoice = localStorage.getItem(`vigent_${storageKey}_voice`);
|
||||
const savedMaterial = localStorage.getItem(`vigent_${storageKey}_material`);
|
||||
|
||||
console.log("[Home] localStorage 数据:", { savedText, savedTitle, savedSubtitles, savedTtsMode, savedVoice, savedMaterial });
|
||||
|
||||
// 恢复数据,如果没有保存的数据则使用默认值
|
||||
setText(savedText || "大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。");
|
||||
setVideoTitle(savedTitle || "");
|
||||
setEnableSubtitles(savedSubtitles !== null ? savedSubtitles === 'true' : true);
|
||||
setTtsMode((savedTtsMode as 'edgetts' | 'voiceclone') || 'edgetts');
|
||||
setVoice(savedVoice || "zh-CN-YunxiNeural");
|
||||
if (savedMaterial) setSelectedMaterial(savedMaterial);
|
||||
|
||||
// 恢复完成后才允许保存
|
||||
setIsRestored(true);
|
||||
console.log("[Home] 恢复完成,isRestored = true");
|
||||
}, [storageKey, isAuthLoading]);
|
||||
|
||||
// 保存用户输入到 localStorage(恢复完成后才保存,未登录用户也可保存)
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
console.log("[Home] 保存 text:", text.substring(0, 50) + "...");
|
||||
localStorage.setItem(`vigent_${storageKey}_text`, text);
|
||||
}
|
||||
}, [text, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
console.log("[Home] 保存 title:", videoTitle);
|
||||
localStorage.setItem(`vigent_${storageKey}_title`, videoTitle);
|
||||
}
|
||||
}, [videoTitle, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) localStorage.setItem(`vigent_${storageKey}_subtitles`, String(enableSubtitles));
|
||||
}, [enableSubtitles, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) localStorage.setItem(`vigent_${storageKey}_ttsMode`, ttsMode);
|
||||
}, [ttsMode, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) localStorage.setItem(`vigent_${storageKey}_voice`, voice);
|
||||
}, [voice, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored && selectedMaterial) {
|
||||
localStorage.setItem(`vigent_${storageKey}_material`, selectedMaterial);
|
||||
}
|
||||
}, [selectedMaterial, storageKey, isRestored]);
|
||||
|
||||
const fetchMaterials = async () => {
|
||||
try {
|
||||
setFetchError(null);
|
||||
@@ -252,6 +546,38 @@ export default function Home() {
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// AI 生成标题和标签
|
||||
const handleGenerateMeta = async () => {
|
||||
if (!text.trim()) {
|
||||
alert("请先输入口播文案");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Home] AI生成标题 - userId:", userId, "isRestored:", isRestored);
|
||||
|
||||
setIsGeneratingMeta(true);
|
||||
try {
|
||||
const { data } = await api.post('/api/ai/generate-meta', { text: text.trim() });
|
||||
|
||||
console.log("[Home] AI生成结果:", data);
|
||||
|
||||
// 更新首页标题
|
||||
setVideoTitle(data.title || "");
|
||||
|
||||
// 同步到发布页 localStorage
|
||||
console.log("[Home] 保存到 publish localStorage - title:", data.title, "tags:", data.tags);
|
||||
localStorage.setItem(`vigent_${storageKey}_publish_title`, data.title || "");
|
||||
localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(data.tags || []));
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("AI generate meta failed:", err);
|
||||
const errorMsg = err.response?.data?.detail || err.message || String(err);
|
||||
alert(`AI 生成失败: ${errorMsg}`);
|
||||
} finally {
|
||||
setIsGeneratingMeta(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除素材
|
||||
const deleteMaterial = async (materialId: string) => {
|
||||
if (!confirm("确定要删除这个素材吗?")) return;
|
||||
@@ -329,6 +655,7 @@ export default function Home() {
|
||||
|
||||
|
||||
|
||||
|
||||
// 生成视频
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedMaterial || !text.trim()) {
|
||||
@@ -344,7 +671,6 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setGeneratedVideo(null);
|
||||
|
||||
try {
|
||||
@@ -376,32 +702,13 @@ export default function Home() {
|
||||
|
||||
const taskId = data.task_id;
|
||||
|
||||
// 轮询任务状态
|
||||
const pollTask = async () => {
|
||||
try {
|
||||
const { data: taskData } = await api.get(`/api/videos/tasks/${taskId}`);
|
||||
setCurrentTask(taskData);
|
||||
// 保存任务ID到 localStorage,以便页面切换后恢复
|
||||
localStorage.setItem(`vigent_${storageKey}_current_task`, taskId);
|
||||
|
||||
if (taskData.status === "completed") {
|
||||
setGeneratedVideo(`${API_BASE}${taskData.download_url}`);
|
||||
setIsGenerating(false);
|
||||
fetchGeneratedVideos(); // 刷新历史视频列表
|
||||
} else if (taskData.status === "failed") {
|
||||
alert("视频生成失败: " + taskData.message);
|
||||
setIsGenerating(false);
|
||||
} else {
|
||||
setTimeout(pollTask, 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("轮询任务失败:", error);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
pollTask();
|
||||
// 使用全局 TaskContext 开始任务
|
||||
startTask(taskId);
|
||||
} catch (error) {
|
||||
console.error("生成失败:", error);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -426,11 +733,11 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
</header> */}
|
||||
<header className="border-b border-white/10 bg-black/20 backdrop-blur-sm">
|
||||
<header className="border-b border-white/10 bg-black/20 backdrop-blur-sm relative z-[100]">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-between">
|
||||
<Link href="/" className="text-xl sm:text-2xl font-bold text-white flex items-center gap-2 sm:gap-3 hover:opacity-80 transition-opacity">
|
||||
<span className="text-3xl sm:text-4xl">🎬</span>
|
||||
ViGent
|
||||
IPAgent
|
||||
</Link>
|
||||
<div className="flex items-center gap-1 sm:gap-4">
|
||||
<span className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg font-semibold">
|
||||
@@ -442,19 +749,8 @@ export default function Home() {
|
||||
>
|
||||
发布管理
|
||||
</Link>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (confirm('确定要退出登录吗?')) {
|
||||
try {
|
||||
await api.post('/api/auth/logout');
|
||||
} catch (e) { }
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}}
|
||||
className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-red-500/10 hover:bg-red-500/20 text-red-200 rounded-lg transition-colors"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
{/* 账户设置下拉菜单 */}
|
||||
<AccountSettingsDropdown />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -578,9 +874,21 @@ export default function Home() {
|
||||
|
||||
{/* 文案输入 */}
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<div className="flex justify-between items-center gap-2 mb-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 whitespace-nowrap">
|
||||
✍️ 输入口播文案
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleGenerateMeta}
|
||||
disabled={isGeneratingMeta || !text.trim()}
|
||||
className={`px-2 py-1 text-xs rounded transition-all whitespace-nowrap ${isGeneratingMeta || !text.trim()
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||
: "bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white"
|
||||
}`}
|
||||
>
|
||||
{isGeneratingMeta ? "⏳ 生成中..." : "🤖 AI生成标题标签"}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
@@ -879,7 +1187,7 @@ export default function Home() {
|
||||
style={{ width: `${currentTask.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-gray-300">正在用AI生成中...</p>
|
||||
<p className="text-gray-300">正在AI生成中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import useSWR from 'swr';
|
||||
import Link from "next/link";
|
||||
import api from "@/lib/axios";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
// SWR fetcher 使用 axios(自动处理 401/403)
|
||||
const fetcher = (url: string) => api.get(url).then((res) => res.data);
|
||||
@@ -51,12 +52,61 @@ export default function PublishPage() {
|
||||
const [qrPlatform, setQrPlatform] = useState<string | null>(null);
|
||||
const [isLoadingQR, setIsLoadingQR] = useState(false);
|
||||
|
||||
// 使用全局认证状态
|
||||
const { userId, isLoading: isAuthLoading } = useAuth();
|
||||
// 是否已从 localStorage 恢复完成
|
||||
const [isRestored, setIsRestored] = useState(false);
|
||||
|
||||
// 加载账号和视频列表
|
||||
useEffect(() => {
|
||||
fetchAccounts();
|
||||
fetchVideos();
|
||||
}, []);
|
||||
|
||||
// 获取存储 key 的前缀(登录用户使用 userId,未登录使用 guest)
|
||||
const storageKey = userId || 'guest';
|
||||
|
||||
// 从 localStorage 恢复用户输入(等待认证完成后)
|
||||
useEffect(() => {
|
||||
console.log("[Publish] 恢复检查 - isAuthLoading:", isAuthLoading, "userId:", userId);
|
||||
if (isAuthLoading) return;
|
||||
|
||||
console.log("[Publish] 开始从 localStorage 恢复数据,storageKey:", storageKey);
|
||||
// 从 localStorage 恢复用户输入(带用户隔离,未登录用户使用 guest)
|
||||
const savedTitle = localStorage.getItem(`vigent_${storageKey}_publish_title`);
|
||||
const savedTags = localStorage.getItem(`vigent_${storageKey}_publish_tags`);
|
||||
|
||||
console.log("[Publish] localStorage 数据:", { savedTitle, savedTags });
|
||||
|
||||
if (savedTitle) setTitle(savedTitle);
|
||||
if (savedTags) {
|
||||
// 兼容 JSON 数组格式(AI 生成)和字符串格式(手动输入)
|
||||
try {
|
||||
const parsed = JSON.parse(savedTags);
|
||||
if (Array.isArray(parsed)) {
|
||||
setTags(parsed.join(', '));
|
||||
} else {
|
||||
setTags(savedTags);
|
||||
}
|
||||
} catch {
|
||||
setTags(savedTags);
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复完成后才允许保存
|
||||
setIsRestored(true);
|
||||
console.log("[Publish] 恢复完成,isRestored = true");
|
||||
}, [storageKey, isAuthLoading]);
|
||||
|
||||
// 保存用户输入到 localStorage(恢复完成后才保存,未登录用户也可保存)
|
||||
useEffect(() => {
|
||||
if (isRestored) localStorage.setItem(`vigent_${storageKey}_publish_title`, title);
|
||||
}, [title, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) localStorage.setItem(`vigent_${storageKey}_publish_tags`, tags);
|
||||
}, [tags, storageKey, isRestored]);
|
||||
|
||||
const fetchAccounts = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/publish/accounts');
|
||||
@@ -250,7 +300,7 @@ export default function PublishPage() {
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-between">
|
||||
<Link href="/" className="text-xl sm:text-2xl font-bold text-white flex items-center gap-2 sm:gap-3 hover:opacity-80 transition-opacity">
|
||||
<span className="text-3xl sm:text-4xl">🎬</span>
|
||||
ViGent
|
||||
IPAgent
|
||||
</Link>
|
||||
<div className="flex items-center gap-1 sm:gap-4">
|
||||
<Link
|
||||
|
||||
@@ -6,7 +6,7 @@ import { register } from '@/lib/auth';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -18,6 +18,12 @@ export default function RegisterPage() {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// 验证手机号格式
|
||||
if (!/^\d{11}$/.test(phone)) {
|
||||
setError('请输入正确的11位手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('两次输入的密码不一致');
|
||||
return;
|
||||
@@ -31,7 +37,7 @@ export default function RegisterPage() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await register(email, password, username || undefined);
|
||||
const result = await register(phone, password, username || undefined);
|
||||
if (result.success) {
|
||||
setSuccess(true);
|
||||
} else {
|
||||
@@ -79,16 +85,18 @@ export default function RegisterPage() {
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||
邮箱 <span className="text-red-400">*</span>
|
||||
手机号 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
|
||||
required
|
||||
maxLength={11}
|
||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="your@email.com"
|
||||
placeholder="请输入11位手机号"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">必须是11位数字</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
42
frontend/src/components/GlobalTaskIndicator.tsx
Normal file
42
frontend/src/components/GlobalTaskIndicator.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useTask } from "@/contexts/TaskContext";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function GlobalTaskIndicator() {
|
||||
const { currentTask, isGenerating } = useTask();
|
||||
|
||||
if (!isGenerating) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 z-50 bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg">
|
||||
<div className="max-w-6xl mx-auto px-6 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent"></div>
|
||||
<span className="font-medium">
|
||||
视频生成中... {currentTask?.progress || 0}%
|
||||
</span>
|
||||
{currentTask?.message && (
|
||||
<span className="text-white/80 text-sm">
|
||||
{currentTask.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="px-3 py-1 bg-white/20 hover:bg-white/30 rounded transition-colors text-sm"
|
||||
>
|
||||
查看详情
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-2 w-full bg-white/20 rounded-full h-1.5 overflow-hidden">
|
||||
<div
|
||||
className="bg-white h-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${currentTask?.progress || 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
frontend/src/contexts/AuthContext.tsx
Normal file
80
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import api from "@/lib/axios";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
phone: string;
|
||||
username: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
userId: string | null;
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
userId: null,
|
||||
user: null,
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let retryCount = 0;
|
||||
const maxRetries = 2;
|
||||
|
||||
const fetchUser = async () => {
|
||||
console.log("[AuthContext] 开始获取用户信息...");
|
||||
try {
|
||||
const { data } = await api.get('/api/auth/me');
|
||||
console.log("[AuthContext] 获取用户信息成功:", data);
|
||||
if (data && data.id) {
|
||||
setUser(data);
|
||||
console.log("[AuthContext] 设置 user:", data);
|
||||
} else {
|
||||
console.warn("[AuthContext] 响应中没有用户数据");
|
||||
}
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error("[AuthContext] 获取用户信息失败:", error);
|
||||
// 重试逻辑
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
console.log(`[AuthContext] 重试 ${retryCount}/${maxRetries}...`);
|
||||
setTimeout(fetchUser, 1000);
|
||||
} else {
|
||||
console.error("[AuthContext] 重试次数用尽,放弃获取用户信息");
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
userId: user?.id || null,
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated: !!user
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
119
frontend/src/contexts/TaskContext.tsx
Normal file
119
frontend/src/contexts/TaskContext.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import api from "@/lib/axios";
|
||||
|
||||
interface Task {
|
||||
task_id: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
message: string;
|
||||
download_url?: string;
|
||||
}
|
||||
|
||||
interface TaskContextType {
|
||||
currentTask: Task | null;
|
||||
isGenerating: boolean;
|
||||
startTask: (taskId: string) => void;
|
||||
clearTask: () => void;
|
||||
}
|
||||
|
||||
const TaskContext = createContext<TaskContextType | undefined>(undefined);
|
||||
|
||||
export function TaskProvider({ children }: { children: ReactNode }) {
|
||||
const [currentTask, setCurrentTask] = useState<Task | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [taskId, setTaskId] = useState<string | null>(null);
|
||||
|
||||
// 轮询任务状态
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
|
||||
const pollTask = async () => {
|
||||
try {
|
||||
const { data } = await api.get(`/api/videos/tasks/${taskId}`);
|
||||
setCurrentTask(data);
|
||||
|
||||
// 处理任务完成、失败或不存在的情况
|
||||
if (data.status === "completed" || data.status === "failed" || data.status === "not_found") {
|
||||
setIsGenerating(false);
|
||||
setTaskId(null);
|
||||
// 清除 localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
const keys = Object.keys(localStorage);
|
||||
keys.forEach(key => {
|
||||
if (key.includes('_current_task')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("轮询任务失败:", error);
|
||||
setIsGenerating(false);
|
||||
setTaskId(null);
|
||||
// 清除 localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
const keys = Object.keys(localStorage);
|
||||
keys.forEach(key => {
|
||||
if (key.includes('_current_task')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 立即执行一次
|
||||
pollTask();
|
||||
|
||||
// 每秒轮询
|
||||
const interval = setInterval(pollTask, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [taskId]);
|
||||
|
||||
// 页面加载时恢复任务
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// 查找所有可能的任务ID
|
||||
const keys = Object.keys(localStorage);
|
||||
const taskKey = keys.find(key => key.includes('_current_task'));
|
||||
|
||||
if (taskKey) {
|
||||
const savedTaskId = localStorage.getItem(taskKey);
|
||||
if (savedTaskId) {
|
||||
console.log("[TaskContext] 恢复任务:", savedTaskId);
|
||||
setTaskId(savedTaskId);
|
||||
setIsGenerating(true);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startTask = (newTaskId: string) => {
|
||||
setTaskId(newTaskId);
|
||||
setIsGenerating(true);
|
||||
setCurrentTask(null);
|
||||
};
|
||||
|
||||
const clearTask = () => {
|
||||
setTaskId(null);
|
||||
setIsGenerating(false);
|
||||
setCurrentTask(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<TaskContext.Provider value={{ currentTask, isGenerating, startTask, clearTask }}>
|
||||
{children}
|
||||
</TaskContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTask() {
|
||||
const context = useContext(TaskContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useTask must be used within a TaskProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -8,10 +8,11 @@ const API_BASE = typeof window === 'undefined'
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
username: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
@@ -23,12 +24,12 @@ export interface AuthResponse {
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
export async function register(email: string, password: string, username?: string): Promise<AuthResponse> {
|
||||
export async function register(phone: string, password: string, username?: string): Promise<AuthResponse> {
|
||||
const res = await fetch(`${API_BASE}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password, username })
|
||||
body: JSON.stringify({ phone, password, username })
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
@@ -36,12 +37,12 @@ export async function register(email: string, password: string, username?: strin
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
export async function login(email: string, password: string): Promise<AuthResponse> {
|
||||
export async function login(phone: string, password: string): Promise<AuthResponse> {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password })
|
||||
body: JSON.stringify({ phone, password })
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
@@ -57,6 +58,19 @@ export async function logout(): Promise<AuthResponse> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
export async function changePassword(oldPassword: string, newPassword: string): Promise<AuthResponse> {
|
||||
const res = await fetch(`${API_BASE}/api/auth/change-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword })
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,8 @@ const API_BASE = typeof window === 'undefined'
|
||||
// 防止重复跳转
|
||||
let isRedirecting = false;
|
||||
|
||||
const PUBLIC_PATHS = new Set(['/login', '/register']);
|
||||
|
||||
// 创建 axios 实例
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE,
|
||||
@@ -27,7 +29,9 @@ api.interceptors.response.use(
|
||||
async (error) => {
|
||||
const status = error.response?.status;
|
||||
|
||||
if ((status === 401 || status === 403) && !isRedirecting) {
|
||||
const isPublicPath = typeof window !== 'undefined' && PUBLIC_PATHS.has(window.location.pathname);
|
||||
|
||||
if ((status === 401 || status === 403) && !isRedirecting && !isPublicPath) {
|
||||
isRedirecting = true;
|
||||
|
||||
// 调用 logout API 清除 HttpOnly cookie
|
||||
|
||||
@@ -27,8 +27,8 @@ import uvicorn
|
||||
|
||||
app = FastAPI(title="Qwen3-TTS Voice Clone Service", version="1.0")
|
||||
|
||||
# 模型路径
|
||||
MODEL_PATH = Path(__file__).parent / "checkpoints" / "0.6B-Base"
|
||||
# 模型路径 (1.7B-Base 提供更高质量的声音克隆)
|
||||
MODEL_PATH = Path(__file__).parent / "checkpoints" / "1.7B-Base"
|
||||
|
||||
# 全局模型实例
|
||||
_model = None
|
||||
@@ -92,7 +92,7 @@ async def health():
|
||||
|
||||
return HealthResponse(
|
||||
service="Qwen3-TTS Voice Clone",
|
||||
model="0.6B-Base",
|
||||
model="1.7B-Base",
|
||||
ready=_model_loaded and gpu_ok,
|
||||
gpu_id=0
|
||||
)
|
||||
|
||||
@@ -19,6 +19,8 @@ interface RenderOptions {
|
||||
outputPath: string;
|
||||
fps?: number;
|
||||
enableSubtitles?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
async function parseArgs(): Promise<RenderOptions> {
|
||||
@@ -77,8 +79,10 @@ async function main() {
|
||||
console.log(`Loaded captions with ${captions.segments?.length || 0} segments`);
|
||||
}
|
||||
|
||||
// 获取视频时长
|
||||
// 获取视频时长和尺寸
|
||||
let durationInFrames = 300; // 默认 12 秒
|
||||
let videoWidth = 1280;
|
||||
let videoHeight = 720;
|
||||
try {
|
||||
// 使用 ffprobe 获取视频时长
|
||||
const { execSync } = require('child_process');
|
||||
@@ -89,6 +93,18 @@ async function main() {
|
||||
const durationInSeconds = parseFloat(ffprobeOutput.trim());
|
||||
durationInFrames = Math.ceil(durationInSeconds * fps);
|
||||
console.log(`Video duration: ${durationInSeconds}s (${durationInFrames} frames at ${fps}fps)`);
|
||||
|
||||
// 使用 ffprobe 获取视频尺寸
|
||||
const dimensionsOutput = execSync(
|
||||
`ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 "${options.videoPath}"`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
const [width, height] = dimensionsOutput.trim().split('x').map(Number);
|
||||
if (width && height) {
|
||||
videoWidth = width;
|
||||
videoHeight = height;
|
||||
console.log(`Video dimensions: ${videoWidth}x${videoHeight}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not get video duration, using default:', e);
|
||||
}
|
||||
@@ -119,9 +135,11 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
// Override duration
|
||||
// Override duration and dimensions
|
||||
composition.durationInFrames = durationInFrames;
|
||||
composition.fps = fps;
|
||||
composition.width = videoWidth;
|
||||
composition.height = videoHeight;
|
||||
|
||||
// Render the video
|
||||
console.log('Rendering video...');
|
||||
|
||||
@@ -15,13 +15,13 @@ interface SubtitlesProps {
|
||||
|
||||
/**
|
||||
* 逐字高亮字幕组件
|
||||
* 根据时间戳逐字高亮显示字幕
|
||||
* 根据时间戳逐字高亮显示字幕(无背景,纯文字描边)
|
||||
*/
|
||||
export const Subtitles: React.FC<SubtitlesProps> = ({
|
||||
captions,
|
||||
highlightColor = '#FFFFFF',
|
||||
normalColor = 'rgba(255, 255, 255, 0.5)',
|
||||
fontSize = 36,
|
||||
highlightColor = '#FFFF00',
|
||||
normalColor = '#FFFFFF',
|
||||
fontSize = 52,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
@@ -43,43 +43,45 @@ export const Subtitles: React.FC<SubtitlesProps> = ({
|
||||
style={{
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
paddingBottom: '60px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
padding: '12px 24px',
|
||||
borderRadius: '12px',
|
||||
maxWidth: '80%',
|
||||
textAlign: 'center',
|
||||
paddingBottom: '6%',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: `${fontSize}px`,
|
||||
fontFamily: '"Noto Sans SC", "Microsoft YaHei", sans-serif',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.5,
|
||||
fontFamily: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||||
fontWeight: 800,
|
||||
lineHeight: 1.4,
|
||||
textAlign: 'center',
|
||||
maxWidth: '90%',
|
||||
wordBreak: 'keep-all',
|
||||
letterSpacing: '2px',
|
||||
}}
|
||||
>
|
||||
{currentSegment.words.map((word, index) => (
|
||||
{currentSegment.words.map((word, index) => {
|
||||
const isHighlighted = index <= currentWordIndex;
|
||||
return (
|
||||
<span
|
||||
key={`${word.word}-${index}`}
|
||||
style={{
|
||||
color: index <= currentWordIndex ? highlightColor : normalColor,
|
||||
transition: 'color 0.1s ease',
|
||||
textShadow: index <= currentWordIndex
|
||||
? '0 2px 10px rgba(255,255,255,0.3)'
|
||||
: 'none',
|
||||
color: isHighlighted ? highlightColor : normalColor,
|
||||
textShadow: `
|
||||
-3px -3px 0 #000,
|
||||
3px -3px 0 #000,
|
||||
-3px 3px 0 #000,
|
||||
3px 3px 0 #000,
|
||||
0 0 12px rgba(0,0,0,0.9),
|
||||
0 4px 8px rgba(0,0,0,0.6)
|
||||
`,
|
||||
transition: 'color 0.05s ease',
|
||||
}}
|
||||
>
|
||||
{word.word}
|
||||
</span>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ interface TitleProps {
|
||||
|
||||
/**
|
||||
* 片头标题组件
|
||||
* 在视频开头显示标题,带淡入淡出效果
|
||||
* 在视频顶部显示标题,带淡入淡出效果
|
||||
*/
|
||||
export const Title: React.FC<TitleProps> = ({
|
||||
title,
|
||||
@@ -49,46 +49,45 @@ export const Title: React.FC<TitleProps> = ({
|
||||
|
||||
const opacity = Math.min(fadeInOpacity, fadeOutOpacity);
|
||||
|
||||
// 轻微的缩放动画
|
||||
const scale = interpolate(
|
||||
// 轻微的下滑动画
|
||||
const translateY = interpolate(
|
||||
currentTimeInSeconds,
|
||||
[0, 0.5],
|
||||
[0.95, 1],
|
||||
[-20, 0],
|
||||
{ extrapolateRight: 'clamp' }
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
paddingTop: '6%',
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
textAlign: 'center',
|
||||
padding: '40px 60px',
|
||||
background: 'linear-gradient(135deg, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.5) 100%)',
|
||||
borderRadius: '20px',
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
color: 'white',
|
||||
fontSize: '48px',
|
||||
fontWeight: 'bold',
|
||||
fontFamily: '"Noto Sans SC", "Microsoft YaHei", sans-serif',
|
||||
textShadow: '0 4px 20px rgba(0,0,0,0.5)',
|
||||
transform: `translateY(${translateY}px)`,
|
||||
textAlign: 'center',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '72px',
|
||||
fontWeight: 900,
|
||||
fontFamily: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||||
textShadow: `
|
||||
0 0 10px rgba(0,0,0,0.9),
|
||||
0 0 20px rgba(0,0,0,0.7),
|
||||
0 4px 8px rgba(0,0,0,0.8),
|
||||
0 8px 16px rgba(0,0,0,0.5)
|
||||
`,
|
||||
margin: 0,
|
||||
lineHeight: 1.4,
|
||||
padding: '0 5%',
|
||||
lineHeight: 1.3,
|
||||
letterSpacing: '4px',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ interface VideoLayerProps {
|
||||
|
||||
/**
|
||||
* 视频图层组件
|
||||
* 渲染底层视频和音频
|
||||
* 渲染底层视频和音频,视频自动循环以匹配音频长度
|
||||
*/
|
||||
export const VideoLayer: React.FC<VideoLayerProps> = ({
|
||||
videoSrc,
|
||||
@@ -21,10 +21,11 @@ export const VideoLayer: React.FC<VideoLayerProps> = ({
|
||||
<AbsoluteFill>
|
||||
<OffthreadVideo
|
||||
src={videoUrl}
|
||||
loop
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
{audioSrc && <Audio src={staticFile(audioSrc)} />}
|
||||
|
||||
Reference in New Issue
Block a user