Compare commits

..

5 Commits

Author SHA1 Message Date
Kevin Wong
6e58f4bbe7 更新 2026-02-02 17:16:07 +08:00
Kevin Wong
7bfd6bf862 更新 2026-02-02 14:28:48 +08:00
Kevin Wong
569736d05b 更新代码 2026-02-02 11:49:22 +08:00
Kevin Wong
ec16e08bdb 更新代码 2026-02-02 10:58:21 +08:00
Kevin Wong
6801d3e8aa 更新代码 2026-02-02 10:51:27 +08:00
45 changed files with 3334 additions and 337 deletions

View File

@@ -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` | 日志管理 |
### 前端关键依赖

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 部署指南

410
Docs/DevLogs/Day15.md Normal file
View File

@@ -0,0 +1,410 @@
# 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) - 认证系统部署指南
---
## 🤖 模型与功能增强 (Day 15 晚)
### 1. GLM-4.7-Flash 升级
**文件**: `backend/app/services/glm_service.py`
将文案洗稿模型从 `glm-4-flash` 升级为 `glm-4.7-flash`
```python
response = client.chat.completions.create(
model="glm-4.7-flash", # Upgrade from glm-4-flash
messages=[...],
# ...
)
```
**改进**:
- 响应速度提升
- 洗稿文案的流畅度和逻辑性增强
### 2. 独立文案提取助手
实现了独立的文案提取工具,支持从视频/音频文件或 URL 提取文字。
#### 后端实现 (`backend/app/api/tools.py`)
- **多源支持**: 文件上传 (MP4/MP3/WAV) 或 URL 下载
- **智能下载**:
- `yt-dlp`: 通用下载 (Douyin/TikTok/Bilibili)
- `Playwright`: 智能回退机制 (Bilibili Dashboard API, Douyin Cookie Bypass)
- **URL 自动清洗**: 正则提取分享文本中的 HTTP 链接
- **流程**: 下载 -> FFmpeg 转 WAV (16k) -> Whisper 识别 -> GLM-4.7 洗稿
#### 前端实现 (`frontend/src/components/ScriptExtractionModal.tsx`)
- **独立模态框**: 通过顶部导航栏打开
- **功能**:
- 链接粘贴 / 文件拖拽
- 实时进度显示 (下载 -> 识别 -> 洗稿)
- **一键填入**: 将提取结果直接填充到主输入框
- **自动识别**: 自动区分平台与链接
- **交互优化**:
- 防止误触背景关闭
- 复制功能兼容 HTTP 环境 (Fallback textArea)
### 3. 上传视频预览功能
在素材列表 (`frontend/src/app/page.tsx`) 中为上传的视频添加预览功能:
- 点击缩略图弹出视频播放模态框
- 支持下载与发布快捷跳转
---
## 📝 任务清单更新
- [x] 认证系统迁移 (手机号)
- [x] 账户管理 (密码修改/有效期)
- [x] GLM-4.7 模型升级
- [x] 独立文案提取助手 (B站/抖音支持)
- [x] 视频预览功能

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,18 @@ Day 13: 声音克隆 + 字幕功能 ✅ 完成
- **前端标题/字幕设置 UI**
- **部署文档** (SUBTITLE_DEPLOY.md)
Day 14: 模型升级 + AI 标题标签 + 前端修复 ✅ 完成
- Qwen3-TTS 1.7B 模型升级 (0.6B → 1.7B-Base)
- 字幕样式与标题动画优化 (Remotion)
- AI 标题/标签生成接口 + 前端同步
- 文案/标题本地保存修复 (刷新后恢复)
- 登录页刷新循环修复 (公开路由跳转豁免)
Day 15: 手机号登录迁移 + 账户设置 ✅ 完成
- **认证系统迁移** (邮箱 → 11位手机号)
- **账户设置** (修改密码 + 退出登录 + 有效期显示)
- **GLM-4.7 模型升级** (文案洗稿效果提升)
- **文案提取助手** (支持 B站/抖音/URL 提取 + 自动洗稿)
- **视频预览功能** (素材列表预览 + 交互优化)
- **前端交互优化** (滚动条美化、弹窗误触修复)

View File

@@ -10,14 +10,19 @@
- 🎬 **唇形同步** - LatentSync 1.6 驱动512×512 高分辨率 Diffusion 模型
- 🎙️ **TTS 配音** - EdgeTTS 多音色支持(云溪、晓晓等)
- 🔊 **声音克隆** - Qwen3-TTS 0.6B3秒参考音频快速克隆
- 🔊 **声音克隆** - Qwen3-TTS 1.7B3秒参考音频快速克隆(更高质量)
- 📝 **逐字高亮字幕** - faster-whisper + Remotion卡拉OK效果 🆕
- 🎬 **片头标题** - 淡入淡出动画,可自定义 🆕
- 🤖 **AI 标题/标签生成** - GLM-4.7-Flash 自动生成标题与标签 (升级版) 🆕
- 📜 **文案提取助手** - 支持 B站/抖音/TikTok 视频链接提取与 AI 洗稿 🆕
- 📽️ **上传视频预览** - 素材列表支持直接预览播放 🆕
- 📱 **全自动发布** - 扫码登录 + Cookie持久化支持多平台(B站/抖音/小红书)定时发布
- 🖥️ **Web UI** - Next.js 现代化界面iOS/Android 移动端适配
- 🔐 **用户系统** - Supabase + JWT 认证,支持管理员后台、注册/登录
- 🔐 **用户系统** - Supabase + JWT 认证,**手机号登录** + 管理员后台 🆕
- ⚙️ **账户设置** - 修改密码 + 有效期显示 + 安全退出 🆕
- 👥 **多用户隔离** - 素材/视频/Cookie 按用户独立存储,数据完全隔离
- 🚀 **性能优化** - 视频预压缩、常驻模型服务 (0s加载)、本地文件直读
- 🚀 **性能优化** - 视频预压缩、常驻模型服务 (0s加载)、本地文件直读、并发控制
- 🌐 **全局任务管理** - 跨页面任务状态同步,实时进度显示
## 🛠️ 技术栈
@@ -30,7 +35,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 +160,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,10 @@ JWT_EXPIRE_HOURS=168
# =============== 管理员配置 ===============
# 服务启动时自动创建的管理员账号
ADMIN_EMAIL=lamnickdavid@gmail.com
ADMIN_PHONE=15549380526
ADMIN_PASSWORD=lam1988324
# =============== GLM AI 配置 ===============
# 智谱 GLM API 配置 (用于生成标题和标签)
GLM_API_KEY=32440cd3f3444d1f8fe721304acea8bd.YXNLrk7eIJMKcg4t
GLM_MODEL=glm-4.7-flash

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

@@ -9,6 +9,10 @@ import os
import aiofiles
from pathlib import Path
from loguru import logger
from pydantic import BaseModel
from typing import Optional
import httpx
router = APIRouter()
@@ -329,3 +333,6 @@ async def delete_material(material_id: str, current_user: dict = Depends(get_cur
return {"success": True, "message": "素材已删除"}
except Exception as e:
raise HTTPException(500, f"删除失败: {str(e)}")

390
backend/app/api/tools.py Normal file
View File

@@ -0,0 +1,390 @@
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from typing import Optional
import shutil
import os
import time
from pathlib import Path
from loguru import logger
import traceback
import re
import json
import requests
from urllib.parse import unquote
from app.services.whisper_service import whisper_service
from app.services.glm_service import glm_service
router = APIRouter()
@router.post("/extract-script")
async def extract_script_tool(
file: Optional[UploadFile] = File(None),
url: Optional[str] = Form(None),
rewrite: bool = Form(True)
):
"""
独立文案提取工具
支持上传视频/音频 OR 输入视频链接 -> 提取文字 -> (可选) AI洗稿
"""
if not file and not url:
raise HTTPException(400, "必须提供文件或视频链接")
temp_path = None
try:
timestamp = int(time.time())
temp_dir = Path("/tmp")
if os.name == 'nt':
temp_dir = Path("d:/tmp")
temp_dir.mkdir(parents=True, exist_ok=True)
# 1. 获取/保存文件
if file:
safe_filename = Path(file.filename).name.replace(" ", "_")
temp_path = temp_dir / f"tool_extract_{timestamp}_{safe_filename}"
with open(temp_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
logger.info(f"Tool processing upload file: {temp_path}")
else:
# URL 下载逻辑
# 自动提取文案中的链接 (支持 Douyin/Bilibili 等分享文案)
url_match = re.search(r'https?://[^\s]+', url)
if url_match:
extracted_url = url_match.group(0)
logger.info(f"Extracted URL from text: {extracted_url}")
url = extracted_url
logger.info(f"Tool downloading URL: {url}")
# 先尝试 yt-dlp
try:
import yt_dlp
logger.info("Attempting download with yt-dlp...")
ydl_opts = {
'format': 'bestaudio/best',
'outtmpl': str(temp_dir / f"tool_download_{timestamp}_%(id)s.%(ext)s"),
'quiet': True,
'no_warnings': True,
'http_headers': {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Referer': 'https://www.douyin.com/',
}
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
if 'requested_downloads' in info:
downloaded_file = info['requested_downloads'][0]['filepath']
else:
ext = info.get('ext', 'mp4')
id = info.get('id')
downloaded_file = str(temp_dir / f"tool_download_{timestamp}_{id}.{ext}")
temp_path = Path(downloaded_file)
logger.info(f"yt-dlp downloaded to: {temp_path}")
except Exception as e:
logger.warning(f"yt-dlp download failed: {e}. Trying manual Douyin fallback...")
# 失败则尝试手动解析 (Douyin Fallback)
if "douyin" in url:
manual_path = await download_douyin_manual(url, temp_dir, timestamp)
if manual_path:
temp_path = manual_path
logger.info(f"Manual Douyin fallback successful: {temp_path}")
else:
raise HTTPException(400, f"视频下载失败。yt-dlp 报错: {str(e)}")
elif "bilibili" in url:
manual_path = await download_bilibili_manual(url, temp_dir, timestamp)
if manual_path:
temp_path = manual_path
logger.info(f"Manual Bilibili fallback successful: {temp_path}")
else:
raise HTTPException(400, f"视频下载失败。yt-dlp 报错: {str(e)}")
else:
raise HTTPException(400, f"视频下载失败: {str(e)}")
if not temp_path or not temp_path.exists():
raise HTTPException(400, "文件获取失败")
# 1.5 安全转换: 强制转为 WAV (16k) 传给 Whisper
# 这一步既能验证文件有效性ffmpeg会报错又能避免 PyAV 音频解码 bug
import subprocess
audio_path = temp_dir / f"extract_audio_{timestamp}.wav"
try:
# ffmpeg -i input -vn -acodec pcm_s16le -ar 16000 -ac 1 output.wav -y
convert_cmd = [
'ffmpeg',
'-i', str(temp_path),
'-vn', # 忽略视频
'-acodec', 'pcm_s16le',
'-ar', '16000', # Whisper 推荐采样率
'-ac', '1', # 单声道
'-y', # 覆盖
str(audio_path)
]
# 捕获 stderr 以便出错时打印
subprocess.run(convert_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
logger.info(f"Converted to WAV: {audio_path}")
# 使用转换后的文件
target_path = audio_path
except subprocess.CalledProcessError as e:
error_log = e.stderr.decode('utf-8', errors='ignore') if e.stderr else str(e)
logger.error(f"FFmpeg check/convert failed: {error_log}")
# 尝试判断是不是 HTML
head = b""
try:
with open(temp_path, 'rb') as f:
head = f.read(100)
except:
pass
if b'<!DOCTYPE html' in head or b'<html' in head:
raise HTTPException(400, "下载的文件是网页而非视频,请重试或手动上传。")
raise HTTPException(400, "下载的文件已损坏或格式无法识别。")
# 2. 提取文案 (Whisper)
script = await whisper_service.transcribe(str(target_path))
# 3. AI 洗稿 (GLM)
rewritten = None
if rewrite:
if script and len(script.strip()) > 0:
logger.info("Rewriting script...")
rewritten = await glm_service.rewrite_script(script)
else:
logger.warning("No script extracted, skipping rewrite")
return {
"success": True,
"original_script": script,
"rewritten_script": rewritten
}
except HTTPException as he:
raise he
except Exception as e:
logger.error(f"Tool extract failed: {e}")
logger.error(traceback.format_exc())
# Friendly error message
msg = str(e)
if "Fresh cookies" in msg:
msg = "下载失败:目标平台开启了反爬验证,请过段时间重试或直接上传视频文件。"
raise HTTPException(500, f"提取失败: {msg}")
finally:
# 清理临时文件
if temp_path and temp_path.exists():
try:
os.remove(temp_path)
logger.info(f"Cleaned up temp file: {temp_path}")
except Exception as e:
logger.warning(f"Failed to cleanup temp file {temp_path}: {e}")
async def download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Optional[Path]:
"""
手动下载抖音视频 (Fallback logic - Ported from SuperIPAgent/douyinDownloader)
使用特定的 User Profile URL 和硬编码 Cookie 绕过反爬
"""
logger.info(f"[SuperIPAgent] Starting download for: {url}")
try:
# 1. 提取 Modal ID (支持短链跳转)
headers = {
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
}
# 如果是短链或重定向
resp = requests.get(url, headers=headers, allow_redirects=True, timeout=10)
final_url = resp.url
logger.info(f"[SuperIPAgent] Final URL: {final_url}")
modal_id = None
match = re.search(r'/video/(\d+)', final_url)
if match:
modal_id = match.group(1)
if not modal_id:
logger.error("[SuperIPAgent] Could not extract modal_id")
return None
logger.info(f"[SuperIPAgent] Extracted modal_id: {modal_id}")
# 2. 构造特定请求 URL (Copy from SuperIPAgent)
# 使用特定用户的 Profile 页 + modal_id 参数,配合特定 Cookie
target_url = f"https://www.douyin.com/user/MS4wLjABAAAAN_s_hups7LD0N4qnrM3o2gI0vuG3pozNaEolz2_py3cHTTrpVr1Z4dukFD9SOlwY?from_tab_name=main&modal_id={modal_id}"
# 3. 使用硬编码 Cookie (Copy from SuperIPAgent)
headers_with_cookie = {
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"cookie": "douyin.com; device_web_cpu_core=10; device_web_memory_size=8; __ac_nonce=06760391f00b9b51264ae; __ac_signature=_02B4Z6wo00f019a5ceAAAIDAhEZR-X3jjWfWmXVAAJLXd4; ttwid=1%7C7MTKBSMsP4eOv9h5NAh8p0E-NYIud09ftNmB0mjLpWc%7C1734359327%7C8794abeabbd47447e1f56e5abc726be089f2a0344d6343b5f75f23e7b0f0028f; UIFID_TEMP=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff1396912bcb2af71efee56a14a2a9f37b74010d0a0413795262f6d4afe02a032ac7ab; s_v_web_id=verify_m4r4ribr_c7krmY1z_WoeI_43po_ATpO_I4o8U1bex2D7; hevc_supported=true; home_can_add_dy_2_desktop=%220%22; dy_swidth=2560; dy_sheight=1440; stream_recommend_feed_params=%22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A2560%2C%5C%22screen_height%5C%22%3A1440%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A10%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A10%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A50%7D%22; strategyABtestKey=%221734359328.577%22; csrf_session_id=2f53aed9aa6974e83aa9a1014180c3a4; fpk1=U2FsdGVkX1/IpBh0qdmlKAVhGyYHgur4/VtL9AReZoeSxadXn4juKvsakahRGqjxOPytHWspYoBogyhS/V6QSw==; fpk2=0845b309c7b9b957afd9ecf775a4c21f; passport_csrf_token=d80e0c5b2fa2328219856be5ba7e671e; passport_csrf_token_default=d80e0c5b2fa2328219856be5ba7e671e; odin_tt=3c891091d2eb0f4718c1d5645bc4a0017032d4d5aa989decb729e9da2ad570918cbe5e9133dc6b145fa8c758de98efe32ff1f81aa0d611e838cc73ab08ef7d3f6adf66ab4d10e8372ddd628f94f16b8e; volume_info=%7B%22isUserMute%22%3Afalse%2C%22isMute%22%3Afalse%2C%22volume%22%3A0.5%7D; bd_ticket_guard_client_web_domain=2; FORCE_LOGIN=%7B%22videoConsumedRemainSeconds%22%3A180%7D; UIFID=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff139655a3c2b735923234f371c699560c657923fd3d6c5b63ab7bb9b83423b6cb4787e2ce66a7fbc4ecb24c8570f520fe6de068bbb95115023c0c6c1b6ee31b49fb7e3996fb8349f43a3fd8b7a61cd9e18e8fe65eb6a7c13de4c0960d84e344b644725db3eb2fa6b7caf821de1b50527979f2; is_dash_user=1; biz_trace_id=b57a241f; bd_ticket_guard_client_data=eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCTEo2R0lDalVoWW1XcHpGOFdrN0Vrc0dXcCtaUzNKY1g4NGNGY2k0TTl1TEowNjdUb21mbFU5aDdvWVBGamhNRWNRQWtKdnN1MnM3RmpTWnlJQXpHMjA9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D; download_guide=%221%2F20241216%2F0%22; sdk_source_info=7e276470716a68645a606960273f276364697660272927676c715a6d6069756077273f276364697660272927666d776a68605a607d71606b766c6a6b5a7666776c7571273f275e58272927666a6b766a69605a696c6061273f27636469766027292762696a6764695a7364776c6467696076273f275e5827292771273f273d33323131333c3036313632342778; bit_env=RiOY4jzzpxZoVCl6zdVSVhVRjdwHRTxqcqWdqMBZLPGjMdB4Tax1kAELHNTVAAh72KuhumewE4Lq6f0-VJ2UpJrkrhSxoPw9LUb3zQrq1OSwbeSPHkRlRgRQvO89sItdGUyq1oFr0XyRCnMYG87KSeWyc4x0czGR0o50hTDoDLG5rJVoRcdQOLvjiAegsqyytKF59sPX_QM9qffK2SqYsg0hCggURc_AI6kguDDE5DvG0bnyz1utw4z1eEnIoLrkGDqzqBZj4dOAr0BVU6ofbsS-pOQ2u2PM1dLP9FlBVBlVaqYVgHJeSLsR5k76BRTddUjTb4zEilVIEwAMJWGN4I1BxVt6fC9B5tBQpuT0lj3n3eKXCKXZsd8FrEs5_pbfDsxV-e_WMiXI2ff4qxiTC0U73sfo9OpicKICtZjdq8qsHxJuu6wVR36zvXeL2Wch5C6MzprNvkivv0l8nbh2mSgy1nabZr3dmU6NcR-Bg3Q3xTWUlR9aAUmpopC-cNuXjgLpT-Lw1AYGilSUnCvosth1Gfypq-b0MpgmdSDgTrQ%3D; gulu_source_res=eyJwX2luIjoiMDhjOGQ3ZTJiODQyNjZkZWI5Y2VkMGJiODNlNmY1ZWY0ZjMyNTE2ZmYyZjAzNDMzZjI0OWU1Y2Q1NTczNTk5NyJ9; passport_auth_mix_state=hp9bc3dgb1tm5wd8p82zawus27g0e3ue; IsDouyinActive=false",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
}
logger.info(f"[SuperIPAgent] Requesting page with Cookie...")
# 必须 verify=False 否则有些环境会报错
response = requests.get(target_url, headers=headers_with_cookie, timeout=10)
# 4. 解析 RENDER_DATA
content_match = re.findall(r'<script id="RENDER_DATA" type="application/json">(.*?)</script>', response.text)
if not content_match:
# 尝试解码后再查找?或者结构变了
# 再尝试找 SSR_HYDRATED_DATA
if "SSR_HYDRATED_DATA" in response.text:
content_match = re.findall(r'<script id="SSR_HYDRATED_DATA" type="application/json">(.*?)</script>', response.text)
if not content_match:
logger.error(f"[SuperIPAgent] Could not find RENDER_DATA in page (len={len(response.text)})")
return None
content = unquote(content_match[0])
try:
data = json.loads(content)
except:
logger.error("[SuperIPAgent] JSON decode failed")
return None
# 5. 提取视频流
video_url = None
try:
# 路径通常是: app -> videoDetail -> video -> bitRateList -> playAddr -> src
if "app" in data and "videoDetail" in data["app"]:
info = data["app"]["videoDetail"]["video"]
if "bitRateList" in info and info["bitRateList"]:
video_url = info["bitRateList"][0]["playAddr"][0]["src"]
elif "playAddr" in info and info["playAddr"]:
video_url = info["playAddr"][0]["src"]
except Exception as e:
logger.error(f"[SuperIPAgent] Path extraction failed: {e}")
if not video_url:
logger.error("[SuperIPAgent] No video_url found")
return None
if video_url.startswith("//"):
video_url = "https:" + video_url
logger.info(f"[SuperIPAgent] Found video URL: {video_url[:50]}...")
# 6. 下载 (带 Header)
temp_path = temp_dir / f"douyin_manual_{timestamp}.mp4"
download_headers = {
'Referer': 'https://www.douyin.com/',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
}
dl_resp = requests.get(video_url, headers=download_headers, stream=True, timeout=60)
if dl_resp.status_code == 200:
with open(temp_path, 'wb') as f:
for chunk in dl_resp.iter_content(chunk_size=1024):
f.write(chunk)
logger.info(f"[SuperIPAgent] Downloaded successfully: {temp_path}")
return temp_path
else:
logger.error(f"[SuperIPAgent] Download failed: {dl_resp.status_code}")
return None
except Exception as e:
logger.error(f"[SuperIPAgent] Logic failed: {e}")
return None
async def download_bilibili_manual(url: str, temp_dir: Path, timestamp: int) -> Optional[Path]:
"""
手动下载 Bilibili 视频 (Fallback logic - Playwright Version)
B站通常音视频分离这里只提取音频即可因为只需要文案
"""
from playwright.async_api import async_playwright
logger.info(f"[Playwright] Starting Bilibili download for: {url}")
playwright = None
browser = None
try:
playwright = await async_playwright().start()
# Launch browser (ensure chromium is installed: playwright install chromium)
browser = await playwright.chromium.launch(headless=True, args=['--no-sandbox', '--disable-setuid-sandbox'])
# Mobile User Agent often gives single stream?
# But Bilibili mobile web is tricky. Desktop is fine.
context = await browser.new_context(
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
page = await context.new_page()
# Intercept audio responses?
# Bilibili streams are usually .m4s
# But finding the initial state is easier.
logger.info("[Playwright] Navigating to Bilibili...")
await page.goto(url, timeout=45000)
# Wait for video element (triggers loading)
try:
await page.wait_for_selector('video', timeout=15000)
except:
logger.warning("[Playwright] Video selector timeout")
# 1. Try extracting from __playinfo__
# window.__playinfo__ contains dash streams
playinfo = await page.evaluate("window.__playinfo__")
audio_url = None
if playinfo and "data" in playinfo and "dash" in playinfo["data"]:
dash = playinfo["data"]["dash"]
if "audio" in dash and dash["audio"]:
audio_url = dash["audio"][0]["baseUrl"]
logger.info(f"[Playwright] Found audio stream in __playinfo__: {audio_url[:50]}...")
# 2. If playinfo fails, try extracting video src (sometimes it's a blob, which we can't fetch easily without interception)
# But interception is complex. Let's try requests with Referer if we have URL.
if not audio_url:
logger.warning("[Playwright] Could not find audio in __playinfo__")
return None
# Download the audio stream
temp_path = temp_dir / f"bilibili_audio_{timestamp}.m4s" # usually m4s
try:
api_request = context.request
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": "https://www.bilibili.com/"
}
logger.info(f"[Playwright] Downloading audio stream...")
response = await api_request.get(audio_url, headers=headers)
if response.status == 200:
body = await response.body()
with open(temp_path, 'wb') as f:
f.write(body)
logger.info(f"[Playwright] Downloaded successfully: {temp_path}")
return temp_path
else:
logger.error(f"[Playwright] API Request failed: {response.status}")
return None
except Exception as e:
logger.error(f"[Playwright] Download logic error: {e}")
return None
except Exception as e:
logger.error(f"[Playwright] Bilibili download failed: {e}")
return None
finally:
if browser:
await browser.close()
if playwright:
await playwright.stop()

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,9 +36,13 @@ class Settings(BaseSettings):
JWT_EXPIRE_HOURS: int = 24
# 管理员配置
ADMIN_EMAIL: str = ""
ADMIN_PHONE: str = ""
ADMIN_PASSWORD: str = ""
# GLM AI 配置
GLM_API_KEY: str = ""
GLM_MODEL: str = "glm-4.7-flash"
@property
def LATENTSYNC_DIR(self) -> Path:
"""LatentSync 目录路径 (动态计算)"""

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, tools
from loguru import logger
import os
@@ -56,6 +56,8 @@ 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.include_router(tools.router, prefix="/api/tools", tags=["Tools"])
@app.on_event("startup")
@@ -63,11 +65,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 +79,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 +95,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,146 @@
"""
GLM AI 服务
使用智谱 GLM 生成标题和标签
"""
import json
import re
from loguru import logger
from zai import ZhipuAiClient
from app.core.config import settings
class GLMService:
"""GLM AI 服务"""
def __init__(self):
self.client = None
def _get_client(self):
"""获取或创建 ZhipuAI 客户端"""
if self.client is None:
if not settings.GLM_API_KEY:
raise Exception("GLM_API_KEY 未配置")
self.client = ZhipuAiClient(api_key=settings.GLM_API_KEY)
return self.client
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:
client = self._get_client()
logger.info(f"Calling GLM API with model: {settings.GLM_MODEL}")
response = client.chat.completions.create(
model=settings.GLM_MODEL,
messages=[{"role": "user", "content": prompt}],
thinking={"type": "disabled"}, # 禁用思考模式,加快响应
max_tokens=500,
temperature=0.7
)
# 提取生成的内容
content = response.choices[0].message.content
logger.info(f"GLM response (model: {settings.GLM_MODEL}): {content}")
# 解析 JSON
result = self._parse_json_response(content)
return result
except Exception as e:
logger.error(f"GLM service error: {e}")
raise Exception(f"AI 生成失败: {str(e)}")
async def rewrite_script(self, text: str) -> str:
"""
AI 洗稿(文案改写)
Args:
text: 原始文案
Returns:
改写后的文案
"""
prompt = f"""请将以下视频文案进行改写。
原始文案:
{text}
要求:
1. 保持原意,但语气更加自然流畅
2. 适合口播,读起来朗朗上口
3. 字数与原文相当或略微精简
4. 不要返回多余的解释,只返回改写后的正文"""
try:
client = self._get_client()
logger.info(f"Using GLM to rewrite script")
response = client.chat.completions.create(
model=settings.GLM_MODEL,
messages=[{"role": "user", "content": prompt}],
thinking={"type": "disabled"},
max_tokens=2000,
temperature=0.8
)
content = response.choices[0].message.content
logger.info("GLM rewrite completed")
return content.strip()
except Exception as e:
logger.error(f"GLM rewrite 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

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

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,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:
"""健康检查"""

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()
@@ -155,6 +212,43 @@ class WhisperService:
return result
async def transcribe(self, audio_path: str) -> str:
"""
仅转录文本(用于提取文案)
Args:
audio_path: 音频/视频文件路径
Returns:
纯文本内容
"""
import asyncio
def _do_transcribe_text():
model = self._load_model()
logger.info(f"Extracting script from: {audio_path}")
# 转录 (无需字级时间戳)
segments_iter, _ = model.transcribe(
audio_path,
language="zh",
word_timestamps=False,
vad_filter=True,
)
text_parts = []
for segment in segments_iter:
text_parts.append(segment.text.strip())
full_text = " ".join(text_parts)
logger.info(f"Extracted text length: {len(full_text)}")
return full_text
# 在线程池中执行
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, _do_transcribe_text)
return result
async def check_health(self) -> dict:
"""检查服务健康状态"""
try:

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,24 @@ ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
- **逐字高亮字幕**: 卡拉OK效果默认开启可关闭。
- **自动对齐**: 基于 faster-whisper 生成字级别时间戳。
### 5. 账户设置 [Day 15 新增]
- **手机号登录**: 11位中国手机号验证登录。
- **账户下拉菜单**: 显示有效期 + 修改密码 + 安全退出。
- **修改密码**: 弹窗输入当前密码与新密码,修改后强制重新登录。
### 6. 文案提取助手 (`ScriptExtractionModal`) [Day 15 新增]
- **多源提取**: 支持文件拖拽上传与 URL 粘贴 (B站/抖音/TikTok)。
- **AI 洗稿**: 集成 GLM-4.7-Flash自动改写为口播文案。
- **一键填入**: 提取结果直接填充至视频生成输入框。
- **智能交互**: 实时进度展示,防误触设计。
## 🛠️ 技术栈
- **框架**: 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

@@ -38,6 +38,7 @@ body {
font-family: Arial, Helvetica, sans-serif;
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
background: linear-gradient(to bottom, #0f172a 0%, #0f172a 5%, #581c87 50%, #0f172a 95%, #0f172a 100%);
}
/* 自定义滚动条样式 - 深色主题 */

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,8 +16,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "ViGent",
description: "ViGent Talking Head Agent",
title: "IPAgent",
description: "IPAgent Talking Head Agent",
};
export const viewport: Viewport = {
@@ -30,16 +33,15 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" style={{ backgroundColor: '#0f172a' }}>
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
style={{
margin: 0,
minHeight: '100dvh',
background: 'linear-gradient(to bottom, #0f172a 0%, #0f172a 5%, #581c87 50%, #0f172a 95%, #0f172a 100%)',
}}
>
{children}
<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 {
@@ -34,22 +41,23 @@ export default function LoginPage() {
<div className="min-h-dvh flex items-center justify-center">
<div className="w-full max-w-md p-8 bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl border border-white/20">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-2">ViGent</h1>
<h1 className="text-3xl font-bold text-white mb-2">IPAgent</h1>
<p className="text-gray-300">AI </p>
</div>
<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,11 @@
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";
import AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
import VideoPreviewModal from "@/components/VideoPreviewModal";
import ScriptExtractionModal from "@/components/ScriptExtractionModal";
const API_BASE = typeof window === 'undefined'
? 'http://localhost:8006'
@@ -54,15 +59,19 @@ const formatDate = (timestamp: number) => {
return `${year}/${month}/${day} ${hour}:${minute}`;
};
export default function Home() {
const [materials, setMaterials] = useState<Material[]>([]);
const [selectedMaterial, setSelectedMaterial] = useState<string>("");
const [text, setText] = useState<string>(
"大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。"
);
const [previewMaterial, setPreviewMaterial] = useState<string | null>(null);
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 +95,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 +105,15 @@ 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 [extractModalOpen, setExtractModalOpen] = useState(false);
// 可选音色
const voices = [
{ id: "zh-CN-YunxiNeural", name: "云溪 (男声-年轻)" },
@@ -105,6 +126,9 @@ export default function Home() {
// 声音克隆固定参考文字(用户录音/上传时需要读这段话)
const FIXED_REF_TEXT = "其实生活中有许多美好的瞬间,比如清晨的阳光,或者一杯温热的清茶。希望这次生成的音色能够自然、流畅,完美还原出我最真实的声音状态。";
// 获取存储 key 的前缀(登录用户使用 userId未登录使用 guest
const storageKey = userId || 'guest';
// 加载素材列表和历史视频
useEffect(() => {
fetchMaterials();
@@ -112,6 +136,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 +350,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 +459,7 @@ export default function Home() {
// 生成视频
const handleGenerate = async () => {
if (!selectedMaterial || !text.trim()) {
@@ -344,7 +475,6 @@ export default function Home() {
}
}
setIsGenerating(true);
setGeneratedVideo(null);
try {
@@ -376,32 +506,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);
}
};
@@ -411,7 +522,7 @@ export default function Home() {
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<span className="text-4xl">🎬</span>
ViGent
IPAgent
</h1>
<div className="flex items-center gap-4">
<span className="px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg font-semibold">
@@ -426,11 +537,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 +553,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>
@@ -560,6 +660,18 @@ export default function Home() {
{m.size_mb.toFixed(1)} MB
</div>
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (m.path) {
setPreviewMaterial(`${API_BASE}${m.path}`);
}
}}
className="absolute top-2 right-10 p-1 text-gray-500 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity"
title="预览视频"
>
👁
</button>
<button
onClick={(e) => {
e.stopPropagation();
@@ -578,14 +690,34 @@ 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">
</h2>
<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>
<div className="flex gap-2">
<button
onClick={() => setExtractModalOpen(true)}
className="px-2 py-1 text-xs rounded transition-all whitespace-nowrap bg-purple-600 hover:bg-purple-700 text-white flex items-center gap-1"
>
<span>📜</span>
</button>
<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>
</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="请输入你想说的话..."
className="w-full h-40 bg-black/30 border border-white/10 rounded-xl p-4 text-white placeholder-gray-500 resize-none focus:outline-none focus:border-purple-500 transition-colors"
className="w-full h-40 bg-black/30 border border-white/10 rounded-xl p-4 text-white placeholder-gray-500 resize-none focus:outline-none focus:border-purple-500 transition-colors hide-scrollbar"
/>
<div className="flex justify-between mt-2 text-sm text-gray-400">
<span>{text.length} </span>
@@ -879,7 +1011,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>
)}
@@ -982,6 +1114,16 @@ export default function Home() {
</div>
</div>
</main >
<VideoPreviewModal
onClose={() => setPreviewMaterial(null)}
videoUrl={previewMaterial}
/>
<ScriptExtractionModal
isOpen={extractModalOpen}
onClose={() => setExtractModalOpen(false)}
onApply={(text) => setText(text)}
/>
</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 {
@@ -73,22 +79,24 @@ export default function RegisterPage() {
<div className="w-full max-w-md p-8 bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl border border-white/20">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-2"></h1>
<p className="text-gray-300"> ViGent </p>
<p className="text-gray-300"> IPAgent </p>
</div>
<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,211 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useAuth } from "@/contexts/AuthContext";
import api from "@/lib/axios";
// 账户设置下拉菜单组件
export default 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" ref={dropdownRef}>
<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 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>
);
}

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,424 @@
"use client";
import { useState, useRef, useEffect } from "react";
import api from "@/lib/axios";
interface ScriptExtractionModalProps {
isOpen: boolean;
onClose: () => void;
onApply?: (text: string) => void;
}
export default function ScriptExtractionModal({
isOpen,
onClose,
onApply
}: ScriptExtractionModalProps) {
const [isLoading, setIsLoading] = useState(false);
const [script, setScript] = useState("");
const [rewrittenScript, setRewrittenScript] = useState("");
const [error, setError] = useState<string | null>(null);
const [doRewrite, setDoRewrite] = useState(true);
const [step, setStep] = useState<'config' | 'processing' | 'result'>('config');
const [dragActive, setDragActive] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
// New state for URL mode
const [activeTab, setActiveTab] = useState<'file' | 'url'>('url');
const [inputUrl, setInputUrl] = useState("");
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setStep('config');
setScript("");
setRewrittenScript("");
setError(null);
setIsLoading(false);
setSelectedFile(null);
setInputUrl("");
setActiveTab('url');
}
}, [isOpen]);
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFile(e.dataTransfer.files[0]);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
handleFile(e.target.files[0]);
}
};
const handleFile = (file: File) => {
const validTypes = ['.mp4', '.mov', '.avi', '.mp3', '.wav', '.m4a'];
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
if (!validTypes.includes(ext)) {
setError(`不支持的文件格式 ${ext},请上传视频或音频文件`);
return;
}
setSelectedFile(file);
setError(null);
};
const handleExtract = async () => {
if (activeTab === 'file' && !selectedFile) {
setError("请先上传文件");
return;
}
if (activeTab === 'url' && !inputUrl.trim()) {
setError("请先输入视频链接");
return;
}
setIsLoading(true);
setStep('processing');
setError(null);
try {
const formData = new FormData();
if (activeTab === 'file' && selectedFile) {
formData.append('file', selectedFile);
} else if (activeTab === 'url') {
formData.append('url', inputUrl.trim());
}
formData.append('rewrite', doRewrite ? 'true' : 'false');
const { data } = await api.post('/api/tools/extract-script', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 180000 // 3 minutes timeout
});
if (data.success) {
setScript(data.original_script);
setRewrittenScript(data.rewritten_script || "");
setStep('result');
} else {
setError("提取失败:未知错误");
setStep('config');
}
} catch (err: any) {
console.error(err);
const msg = err.response?.data?.detail || err.message || "请求失败";
setError(msg);
setStep('config');
} finally {
setIsLoading(false);
}
};
const copyToClipboard = (text: string) => {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
alert("已复制到剪贴板");
}).catch(err => {
console.error('Async: Could not copy text: ', err);
fallbackCopyTextToClipboard(text);
});
} else {
fallbackCopyTextToClipboard(text);
}
};
const fallbackCopyTextToClipboard = (text: string) => {
var textArea = document.createElement("textarea");
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
var successful = document.execCommand('copy');
var msg = successful ? 'successful' : 'unsuccessful';
if (successful) {
alert("已复制到剪贴板");
} else {
alert("复制失败,请手动复制");
}
} catch (err) {
console.error('Fallback: Oops, unable to copy', err);
alert("复制失败,请手动复制");
}
document.body.removeChild(textArea);
};
// Close when clicking outside - DISABLED as per user request
// const modalRef = useRef<HTMLDivElement>(null);
// const handleBackdropClick = (e: React.MouseEvent) => {
// if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
// onClose();
// }
// };
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-in fade-in duration-200"
>
<div
// ref={modalRef}
className="bg-[#1a1a1a] border border-white/10 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col shadow-2xl"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-white/5">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
📜
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors text-2xl leading-none"
>
&times;
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{step === 'config' && (
<div className="space-y-6">
{/* Tabs */}
<div className="flex p-1 bg-white/5 rounded-xl border border-white/10">
<button
onClick={() => setActiveTab('url')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === 'url'
? 'bg-purple-600 text-white shadow-lg'
: 'text-gray-400 hover:text-white hover:bg-white/5'
}`}
>
🔗
</button>
<button
onClick={() => setActiveTab('file')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === 'file'
? 'bg-purple-600 text-white shadow-lg'
: 'text-gray-400 hover:text-white hover:bg-white/5'
}`}
>
📂
</button>
</div>
{/* URL Input Area */}
{activeTab === 'url' && (
<div className="space-y-2 py-4">
<div className="relative">
<input
type="text"
value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)}
placeholder="请粘贴抖音、B站等主流平台视频链接..."
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-4 text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors"
/>
{inputUrl && (
<button
onClick={() => setInputUrl("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white p-1"
>
</button>
)}
</div>
<p className="text-xs text-gray-400 px-1">
B站等主流平台分享链接
</p>
</div>
)}
{/* File Upload Area */}
{activeTab === 'file' && (
<div
className={`
relative border-2 border-dashed rounded-xl p-8 text-center transition-all cursor-pointer
${dragActive ? 'border-purple-500 bg-purple-500/10' : 'border-white/20 hover:border-white/40 hover:bg-white/5'}
${selectedFile ? 'bg-purple-900/10 border-purple-500/50' : ''}
`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input
type="file"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={handleFileChange}
accept=".mp4,.mov,.avi,.mp3,.wav,.m4a"
/>
{selectedFile ? (
<div className="flex flex-col items-center">
<div className="text-4xl mb-2">📄</div>
<div className="font-medium text-white break-all max-w-xs">{selectedFile.name}</div>
<div className="text-sm text-gray-400 mt-1">{(selectedFile.size / (1024 * 1024)).toFixed(1)} MB</div>
<div className="mt-4 text-xs text-purple-400"></div>
</div>
) : (
<div className="flex flex-col items-center">
<div className="text-4xl mb-2">📤</div>
<div className="font-medium text-white"></div>
<div className="text-sm text-gray-400 mt-2"> MP4, MOV, MP3, WAV </div>
</div>
)}
</div>
)}
{/* Options */}
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={doRewrite}
onChange={e => setDoRewrite(e.target.checked)}
className="w-5 h-5 accent-purple-600 rounded"
/>
<div>
<div className="text-white font-medium"> AI 稿</div>
<div className="text-xs text-gray-400">稿</div>
</div>
</label>
</div>
{error && (
<div className="p-3 bg-red-500/20 text-red-200 rounded-lg text-sm text-center">
{error}
</div>
)}
<div className="flex justify-center pt-2">
<button
onClick={handleExtract}
className="w-full sm:w-auto px-10 py-3 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-bold hover:shadow-lg hover:from-purple-500 hover:to-pink-500 transition-all transform hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={activeTab === 'file' ? !selectedFile : !inputUrl.trim()}
>
{activeTab === 'url' ? '🔗 解析并提取' : '🚀 开始提取'}
</button>
</div>
</div>
)}
{step === 'processing' && (
<div className="flex flex-col items-center justify-center py-20">
<div className="relative w-20 h-20 mb-6">
<div className="absolute inset-0 border-4 border-purple-500/30 rounded-full"></div>
<div className="absolute inset-0 border-4 border-t-purple-500 rounded-full animate-spin"></div>
</div>
<h4 className="text-xl font-medium text-white mb-2">...</h4>
<p className="text-sm text-gray-400 text-center max-w-sm px-4">
{activeTab === 'url' && "正在下载视频..."}<br />
{doRewrite ? "正在进行语音识别和 AI 智能改写..." : "正在进行语音识别..."}<br />
<span className="opacity-75"></span>
</p>
</div>
)}
{step === 'result' && (
<div className="space-y-6">
{rewrittenScript && (
<div className="space-y-2">
<div className="flex justify-between items-center">
<h4 className="font-semibold text-purple-300 flex items-center gap-2">
AI 稿 <span className="text-xs font-normal text-purple-400/70">()</span>
</h4>
{onApply && (
<button
onClick={() => {
onApply(rewrittenScript);
onClose();
}}
className="text-xs bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1 shadow-sm"
>
📥
</button>
)}
<button
onClick={() => copyToClipboard(rewrittenScript)}
className="text-xs bg-purple-600 hover:bg-purple-500 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1"
>
📋
</button>
</div>
<div className="bg-purple-900/10 border border-purple-500/20 rounded-xl p-4 max-h-60 overflow-y-auto custom-scrollbar">
<p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap">
{rewrittenScript}
</p>
</div>
</div>
)}
<div className="space-y-2">
<div className="flex justify-between items-center">
<h4 className="font-semibold text-gray-400 flex items-center gap-2">
🎙
</h4>
{onApply && (
<button
onClick={() => {
onApply(script);
onClose();
}}
className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1"
>
📥
</button>
)}
<button
onClick={() => copyToClipboard(script)}
className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors"
>
</button>
</div>
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-40 overflow-y-auto custom-scrollbar">
<p className="text-gray-400 text-sm leading-relaxed whitespace-pre-wrap">
{script}
</p>
</div>
</div>
<div className="flex justify-center pt-4">
<button
onClick={() => {
setStep('config');
setScript("");
setRewrittenScript("");
setSelectedFile(null);
setInputUrl("");
// Keep current tab active
}}
className="px-6 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
>
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useEffect } from "react";
interface VideoPreviewModalProps {
videoUrl: string | null;
onClose: () => void;
}
export default function VideoPreviewModal({ videoUrl, onClose }: VideoPreviewModalProps) {
useEffect(() => {
// 按 ESC 关闭
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (videoUrl) {
document.addEventListener('keydown', handleEsc);
// 禁止背景滚动
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEsc);
document.body.style.overflow = 'unset';
};
}, [videoUrl, onClose]);
if (!videoUrl) return null;
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="relative w-full max-w-4xl bg-gray-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-2 border-b border-white/10 bg-white/5">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
🎥
</h3>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Video Player */}
<div className="bg-black flex items-center justify-center min-h-[50vh] max-h-[80vh]">
<video
src={videoUrl}
controls
autoPlay
className="w-full h-full max-h-[80vh] object-contain"
/>
</div>
</div>
{/* Click outside to close */}
<div className="absolute inset-0 -z-10" onClick={onClose}></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

@@ -1,33 +0,0 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// 需要登录才能访问的路径
const protectedPaths = ['/', '/publish', '/admin'];
// 公开路径 (无需登录)
const publicPaths = ['/login', '/register'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 检查是否有 access_token cookie
const token = request.cookies.get('access_token');
// 访问受保护页面但未登录 → 重定向到登录页
if (protectedPaths.some(path => pathname === path || pathname.startsWith(path + '/')) && !token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', pathname);
return NextResponse.redirect(loginUrl);
}
// 已登录用户访问登录/注册页 → 重定向到首页
if (publicPaths.includes(pathname) && token) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/', '/publish/:path*', '/admin/:path*', '/login', '/register']
};

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',
paddingBottom: '6%',
}}
>
<div
<p
style={{
background: 'rgba(0, 0, 0, 0.6)',
padding: '12px 24px',
borderRadius: '12px',
maxWidth: '80%',
margin: 0,
fontSize: `${fontSize}px`,
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',
}}
>
<p
style={{
margin: 0,
fontSize: `${fontSize}px`,
fontFamily: '"Noto Sans SC", "Microsoft YaHei", sans-serif',
fontWeight: 500,
lineHeight: 1.5,
}}
>
{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>
);
})}
</p>
</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
<h1
style={{
transform: `scale(${scale})`,
transform: `translateY(${translateY}px)`,
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)',
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,
padding: '0 5%',
lineHeight: 1.3,
letterSpacing: '4px',
}}
>
<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)',
margin: 0,
lineHeight: 1.4,
}}
>
{title}
</h1>
</div>
{title}
</h1>
</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)} />}