diff --git a/Docs/DEPLOY_MANUAL.md b/Docs/DEPLOY_MANUAL.md index 3c2858c..08b5ccc 100644 --- a/Docs/DEPLOY_MANUAL.md +++ b/Docs/DEPLOY_MANUAL.md @@ -77,7 +77,7 @@ python -m scripts.server # 测试能否启动,Ctrl+C 退出 --- -## 步骤 4: 安装后端依赖 +## 步骤 4: 安装后端依赖 ```bash cd /home/rongye/ProgramFiles/ViGent2/backend @@ -92,13 +92,22 @@ pip install torch torchvision torchaudio --index-url https://download.pytorch.or # 安装 Python 依赖 pip install -r requirements.txt -# 安装 Playwright 浏览器(社交发布需要) -playwright install chromium -``` - ---- - -## 步骤 5: 部署用户认证系统 (Supabase + Auth) +# 安装 Playwright 浏览器(社交发布需要) +playwright install chromium +``` + +--- + +### 可选:AI 标题/标签生成 + +> ✅ 如需启用“AI 标题/标签生成”功能,请确保后端可访问外网 API。 + +- 需要可访问 `https://open.bigmodel.cn` +- API Key 配置在 `backend/app/services/glm_service.py`(建议替换为自己的密钥) + +--- + +## 步骤 5: 部署用户认证系统 (Supabase + Auth) > 🔐 **包含**: 登录/注册、Supabase 数据库配置、JWT 认证、管理员后台 @@ -426,15 +435,16 @@ pm2 logs vigent2-qwen-tts ## 依赖清单 -### 后端关键依赖 +### 后端关键依赖 | 依赖 | 用途 | |------|------| | `fastapi` | Web API 框架 | | `uvicorn` | ASGI 服务器 | -| `edge-tts` | 微软 TTS 配音 | -| `playwright` | 社交媒体自动发布 | -| `biliup` | B站视频上传 | +| `edge-tts` | 微软 TTS 配音 | +| `httpx` | GLM API HTTP 客户端 | +| `playwright` | 社交媒体自动发布 | +| `biliup` | B站视频上传 | | `loguru` | 日志管理 | ### 前端关键依赖 diff --git a/Docs/DevLogs/Day14.md b/Docs/DevLogs/Day14.md new file mode 100644 index 0000000..8778c0a --- /dev/null +++ b/Docs/DevLogs/Day14.md @@ -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 = ({ + 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 + +``` + +#### 处理逻辑 + +```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 部署指南 diff --git a/Docs/DevLogs/Day15.md b/Docs/DevLogs/Day15.md new file mode 100644 index 0000000..286beb7 --- /dev/null +++ b/Docs/DevLogs/Day15.md @@ -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; +} + + 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 { ... } +export async function register(phone: string, password: string, username?: string): Promise { ... } +export async function changePassword(oldPassword: string, newPassword: string): Promise { ... } +``` + +### 4. 首页账户设置下拉菜单 (`page.tsx`) + +**文件**: `frontend/src/app/page.tsx` + +将原来的"退出"按钮改为账户设置下拉菜单: + +```tsx +function AccountSettingsDropdown() { + const [isOpen, setIsOpen] = useState(false); + const [showPasswordModal, setShowPasswordModal] = useState(false); + // ... + + return ( +
+ + + {/* 下拉菜单 */} + {isOpen && ( +
+ + +
+ )} + + {/* 修改密码弹窗 */} + {showPasswordModal && ( +
+
+ + + +
+
+ )} +
+ ); +} +``` + +### 5. 管理员页面 (`admin/page.tsx`) + +**文件**: `frontend/src/app/admin/page.tsx` + +```tsx +interface UserListItem { + id: string; + phone: string; // 原 email + // ... +} + +// 显示手机号而非邮箱 +
{user.phone}
+``` + +--- + +## 🗄️ 数据库迁移 + +### 迁移脚本 + +**文件**: `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) - 认证系统部署指南 diff --git a/Docs/QWEN3_TTS_DEPLOY.md b/Docs/QWEN3_TTS_DEPLOY.md index 14bda2e..2a7b5ab 100644 --- a/Docs/QWEN3_TTS_DEPLOY.md +++ b/Docs/QWEN3_TTS_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) - diff --git a/Docs/SUBTITLE_DEPLOY.md b/Docs/SUBTITLE_DEPLOY.md index df8d5e0..106b794 100644 --- a/Docs/SUBTITLE_DEPLOY.md +++ b/Docs/SUBTITLE_DEPLOY.md @@ -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 | 字幕高亮样式与标题动画优化,视觉表现更清晰 | diff --git a/Docs/implementation_plan.md b/Docs/implementation_plan.md index de16596..c03048c 100644 --- a/Docs/implementation_plan.md +++ b/Docs/implementation_plan.md @@ -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 -``` - --- ## 开发时间估算 diff --git a/Docs/task_complete.md b/Docs/task_complete.md index fd5129f..c48f6f6 100644 --- a/Docs/task_complete.md +++ b/Docs/task_complete.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) + diff --git a/README.md b/README.md index 05851b6..77ff414 100644 --- a/README.md +++ b/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) --- diff --git a/backend/.env.example b/backend/.env.example index c0fd2fe..de2fec5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 8d48eba..7a05b8a 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -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, diff --git a/backend/app/api/ai.py b/backend/app/api/ai.py new file mode 100644 index 0000000..efbf83a --- /dev/null +++ b/backend/app/api/ai.py @@ -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)) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 35f3513..6b66b6a 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -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") ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2a6e267..9be622c 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 93c2af0..10f6eaa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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}") diff --git a/backend/app/services/glm_service.py b/backend/app/services/glm_service.py new file mode 100644 index 0000000..56e2427 --- /dev/null +++ b/backend/app/services/glm_service.py @@ -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() diff --git a/backend/app/services/lipsync_service.py b/backend/app/services/lipsync_service.py index 32cb6fb..68ad2d4 100644 --- a/backend/app/services/lipsync_service.py +++ b/backend/app/services/lipsync_service.py @@ -73,7 +73,51 @@ class LipSyncService: logger.warning(f"⚠️ Conda Python 不存在: {self.conda_python}") 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: - if self.use_server: - # 模式 A: 调用常驻服务 (加速模式) - return await self._call_persistent_server(video_path, audio_path, output_path) - - logger.info("🔄 调用 LatentSync 推理 (subprocess)...") - - # 使用临时目录存放输出 + # 使用临时目录存放中间文件 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(actual_video_path, audio_path, output_path) + + logger.info("🔄 调用 LatentSync 推理 (subprocess)...") + 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), @@ -285,7 +336,7 @@ class LipSyncService: return output_path logger.info(f"LatentSync 输出:\n{stdout_text[-500:] if stdout_text else 'N/A'}") - + # 检查输出文件 if temp_output.exists(): shutil.copy(temp_output, output_path) diff --git a/backend/app/services/video_service.py b/backend/app/services/video_service.py index b22f6b8..8a6981f 100644 --- a/backend/app/services/video_service.py +++ b/backend/app/services/video_service.py @@ -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"]) diff --git a/backend/app/services/voice_clone_service.py b/backend/app/services/voice_clone_service.py index 871850c..37e5def 100644 --- a/backend/app/services/voice_clone_service.py +++ b/backend/app/services/voice_clone_service.py @@ -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,41 +46,43 @@ class VoiceCloneService: Returns: 输出文件路径 """ - logger.info(f"🎤 Voice Clone: {text[:30]}...") - Path(output_path).parent.mkdir(parents=True, exist_ok=True) + # 使用锁确保串行执行,避免 GPU 显存溢出 + async with self._lock: + logger.info(f"🎤 Voice Clone: {text[:30]}...") + Path(output_path).parent.mkdir(parents=True, exist_ok=True) - # 读取参考音频 - with open(ref_audio_path, "rb") as f: - ref_audio_data = f.read() + # 读取参考音频 + with open(ref_audio_path, "rb") as f: + ref_audio_data = f.read() - # 调用 Qwen3-TTS 服务 - timeout = httpx.Timeout(300.0) # 5分钟超时 - async with httpx.AsyncClient(timeout=timeout) as client: - try: - response = await client.post( - f"{self.base_url}/generate", - files={"ref_audio": ("ref.wav", ref_audio_data, "audio/wav")}, - data={ - "text": text, - "ref_text": ref_text, - "language": language - } - ) - response.raise_for_status() + # 调用 Qwen3-TTS 服务 + timeout = httpx.Timeout(300.0) # 5分钟超时 + async with httpx.AsyncClient(timeout=timeout) as client: + try: + response = await client.post( + f"{self.base_url}/generate", + files={"ref_audio": ("ref.wav", ref_audio_data, "audio/wav")}, + data={ + "text": text, + "ref_text": ref_text, + "language": language + } + ) + response.raise_for_status() - # 保存返回的音频 - with open(output_path, "wb") as f: - f.write(response.content) + # 保存返回的音频 + with open(output_path, "wb") as f: + f.write(response.content) - logger.info(f"✅ Voice clone saved: {output_path}") - return output_path + logger.info(f"✅ Voice clone saved: {output_path}") + return output_path - except httpx.HTTPStatusError as e: - logger.error(f"Qwen3-TTS API error: {e.response.status_code} - {e.response.text}") - raise RuntimeError(f"声音克隆服务错误: {e.response.text}") - except httpx.RequestError as e: - logger.error(f"Qwen3-TTS connection error: {e}") - raise RuntimeError("无法连接声音克隆服务,请检查服务是否启动") + except httpx.HTTPStatusError as e: + logger.error(f"Qwen3-TTS API error: {e.response.status_code} - {e.response.text}") + raise RuntimeError(f"声音克隆服务错误: {e.response.text}") + except httpx.RequestError as e: + logger.error(f"Qwen3-TTS connection error: {e}") + raise RuntimeError("无法连接声音克隆服务,请检查服务是否启动") async def check_health(self) -> dict: """健康检查""" diff --git a/backend/app/services/whisper_service.py b/backend/app/services/whisper_service.py index bbba0c3..260602c 100644 --- a/backend/app/services/whisper_service.py +++ b/backend/app/services/whisper_service.py @@ -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() diff --git a/backend/database/migrate_to_phone.sql b/backend/database/migrate_to_phone.sql new file mode 100644 index 0000000..45521be --- /dev/null +++ b/backend/database/migrate_to_phone.sql @@ -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) diff --git a/backend/database/schema.sql b/backend/database/schema.sql index ef12fc6..c0116ca 100644 --- a/backend/database/schema.sql +++ b/backend/database/schema.sql @@ -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); diff --git a/frontend/README.md b/frontend/README.md index 8932157..2e0fd0a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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) ## 🚀 开发指南 diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index cf4bbbe..1790188 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -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() {
-
{user.username || user.email.split('@')[0]}
-
{user.email}
+
{user.username || `用户${user.phone.slice(-4)}`}
+
{user.phone}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 1b4791c..3a75261 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -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%)', }} > - {children} + + + {children} + + ); diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 3cd3e70..df17565 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -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() {
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位手机号" />
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 1294ff0..acc164a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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(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 ( +
+ + + {/* 下拉菜单 */} + {isOpen && ( +
+ {/* 有效期显示 */} +
+
账户有效期
+
+ {user?.expires_at ? formatExpiry(user.expires_at) : '永久有效'} +
+
+ + +
+ )} + + {/* 修改密码弹窗 */} + {showPasswordModal && ( +
+
+

修改密码

+ +
+ + 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="输入当前密码" + /> +
+
+ + 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位" + /> +
+
+ + 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="再次输入新密码" + /> +
+ + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + +
+ + +
+ +
+
+ )} +
+ ); +} + export default function Home() { const [materials, setMaterials] = useState([]); const [selectedMaterial, setSelectedMaterial] = useState(""); - const [text, setText] = useState( - "大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。" - ); + + const [text, setText] = useState(""); const [voice, setVoice] = useState("zh-CN-YunxiNeural"); - const [isGenerating, setIsGenerating] = useState(false); - const [currentTask, setCurrentTask] = useState(null); + + // 使用全局任务状态 + const { currentTask, isGenerating, startTask } = useTask(); + const [generatedVideo, setGeneratedVideo] = useState(null); const [fetchError, setFetchError] = useState(null); const [debugData, setDebugData] = useState(""); @@ -86,6 +295,9 @@ export default function Home() { const [isUploadingRef, setIsUploadingRef] = useState(false); const [uploadRefError, setUploadRefError] = useState(null); + // AI 生成标题标签 + const [isGeneratingMeta, setIsGeneratingMeta] = useState(false); + // 在线录音相关 const [isRecording, setIsRecording] = useState(false); const [recordedBlob, setRecordedBlob] = useState(null); @@ -93,6 +305,11 @@ export default function Home() { const mediaRecorderRef = useRef(null); const recordingIntervalRef = useRef(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() { */} -
+
🎬 - ViGent + IPAgent
@@ -442,19 +749,8 @@ export default function Home() { > 发布管理 - + {/* 账户设置下拉菜单 */} +
@@ -578,9 +874,21 @@ export default function Home() { {/* 文案输入 */}
-

- ✍️ 输入口播文案 -

+
+

+ ✍️ 输入口播文案 +

+ +