更新代码

This commit is contained in:
Kevin Wong
2026-02-02 10:51:27 +08:00
parent cf679b34bf
commit 6801d3e8aa
38 changed files with 2234 additions and 293 deletions

View File

@@ -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
View 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
View 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) - 认证系统部署指南

View File

@@ -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)

View File

@@ -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 | 字幕高亮样式与标题动画优化,视觉表现更清晰 |

View File

@@ -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
```
---
## 开发时间估算

View File

@@ -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 部署** (声音克隆模型GPU03秒参考音频快速克隆)
- [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)

View File

@@ -10,14 +10,17 @@
- 🎬 **唇形同步** - LatentSync 1.6 驱动512×512 高分辨率 Diffusion 模型
- 🎙️ **TTS 配音** - EdgeTTS 多音色支持(云溪、晓晓等)
- 🔊 **声音克隆** - Qwen3-TTS 0.6B3秒参考音频快速克隆
- 🔊 **声音克隆** - Qwen3-TTS 1.7B3秒参考音频快速克隆(更高质量)
- 📝 **逐字高亮字幕** - 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)
---

View File

@@ -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

View File

@@ -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
View 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))

View File

@@ -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")
)

View File

@@ -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

View File

@@ -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}")

View 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()

View File

@@ -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),

View File

@@ -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"])

View File

@@ -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)

View File

@@ -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()

View 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)

View File

@@ -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);

View File

@@ -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)
## 🚀 开发指南

View File

@@ -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">

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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

View File

@@ -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>

View 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>
);
}

View 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);
}

View 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;
}

View File

@@ -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();
}
/**
* 获取当前用户
*/

View File

@@ -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

View File

@@ -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
)

View File

@@ -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...');

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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)} />}