Compare commits

...

2 Commits

Author SHA1 Message Date
Kevin Wong
ee342cc40f 更新 2026-02-08 16:23:39 +08:00
Kevin Wong
1a291a03b8 更新 2026-02-08 10:46:08 +08:00
64 changed files with 2439 additions and 1811 deletions

View File

@@ -19,7 +19,6 @@
- **repositories/**数据读写Supabase不包含业务逻辑。
- **services/**外部依赖与基础能力TTS、Storage、Remotion 等)。
- **core/**:配置、安全、依赖注入、统一响应。
- **api/**:仅做 router 透传,保持 `/api/*` 路由稳定。
---
@@ -28,18 +27,29 @@
```
backend/
├── app/
│ ├── api/ # 兼容路由入口,透传到 modules
│ ├── core/ # config、deps、security、response
│ ├── modules/ # 业务模块
│ │ ├── videos/
│ │ ├── materials/
│ │ ├── publish/
│ │ ├── auth/
│ │ ── ...
│ ├── modules/ # 业务模块(路由 + 逻辑)
│ │ ├── videos/ # 视频生成任务
│ │ ├── materials/ # 素材管理
│ │ ├── publish/ # 多平台发布
│ │ ├── auth/ # 认证与会话
│ │ ── ai/ # AI 功能(标题标签生成等)
│ │ ├── assets/ # 静态资源(字体/样式/BGM
│ │ ├── ref_audios/ # 声音克隆参考音频
│ │ ├── login_helper/ # 扫码登录辅助
│ │ ├── tools/ # 工具接口
│ │ └── admin/ # 管理员功能
│ ├── repositories/ # Supabase 数据访问
│ ├── services/ # 外部服务集成
│ │ ├── uploader/ # 平台发布器douyin/weixin
│ │ ├── qr_login_service.py
│ │ ├── publish_service.py
│ │ ├── remotion_service.py
│ │ ├── storage.py
│ │ └── ...
│ └── tests/
├── assets/ # 字体 / 样式 / bgm
├── user_data/ # 用户隔离数据Cookie 等)
├── scripts/
└── requirements.txt
```
@@ -86,6 +96,21 @@ backend/
- 所有文件上传/下载/删除/移动通过 `services/storage.py`
- 需要重命名时使用 `move_file`,避免直接读写 Storage。
### Cookie 存储(用户隔离)
多平台扫码登录产生的 Cookie 按用户隔离存储:
```
backend/user_data/{user_uuid}/cookies/
├── douyin_cookies.json
├── weixin_cookies.json
└── ...
```
- `publish_service.py` 中通过 `_get_cookies_dir(user_id)` / `_get_cookie_path(user_id, platform)` 定位
- 会话 key 格式:`"{user_id}_{platform}"`,确保多用户并发登录互不干扰
- 登录成功后 Cookie 自动保存到对应路径,发布时自动加载
---
## 7. 代码约定
@@ -112,13 +137,22 @@ backend/
- `REDIS_URL`
- `GLM_API_KEY`
- `LATENTSYNC_*`
- `CORS_ORIGINS` (CORS 白名单,默认 *)
### 微信视频号
- `WEIXIN_HEADLESS_MODE` (headful/headless-new)
- `WEIXIN_CHROME_PATH` / `WEIXIN_BROWSER_CHANNEL`
- `WEIXIN_USER_AGENT` / `WEIXIN_LOCALE` / `WEIXIN_TIMEZONE_ID`
- `WEIXIN_FORCE_SWIFTSHADER`
- `WEIXIN_TRANSCODE_MODE` (reencode/faststart/off)
- `CORS_ORIGINS` (CORS 白名单,默认 *)
- `SUPABASE_STORAGE_LOCAL_PATH` (本地存储路径)
### 抖音
- `DOUYIN_HEADLESS_MODE` (headful/headless-new默认 headless-new)
- `DOUYIN_CHROME_PATH` / `DOUYIN_BROWSER_CHANNEL`
- `DOUYIN_USER_AGENT` (默认 Chrome/144)
- `DOUYIN_LOCALE` / `DOUYIN_TIMEZONE_ID`
- `DOUYIN_FORCE_SWIFTSHADER`
- `DOUYIN_DEBUG_ARTIFACTS` / `DOUYIN_RECORD_VIDEO` / `DOUYIN_KEEP_SUCCESS_VIDEO`
- `DOUYIN_COOKIE` (抖音视频下载 Cookie)
---

View File

@@ -13,14 +13,24 @@
```
backend/
├── app/
│ ├── api/ # 兼容路由入口 (透传到 modules)
│ ├── core/ # 核心配置 (config.py, security.py, response.py)
│ ├── modules/ # 业务模块 (router/service/workflow/schemas)
│ │ ├── videos/ # 视频生成任务
│ │ ├── materials/ # 素材管理
│ │ ├── publish/ # 多平台发布
│ │ ├── auth/ # 认证与会话
│ │ ├── ai/ # AI 功能(标题标签生成)
│ │ ├── assets/ # 静态资源(字体/样式/BGM
│ │ ├── ref_audios/ # 声音克隆参考音频
│ │ ├── login_helper/ # 扫码登录辅助
│ │ ├── tools/ # 工具接口(文案提取等)
│ │ └── admin/ # 管理员功能
│ ├── repositories/ # Supabase 数据访问
│ ├── services/ # 外部服务集成 (TTS/Remotion/Storage 等)
│ ├── services/ # 外部服务集成 (TTS/Remotion/Storage/Uploader 等)
│ └── tests/ # 单元测试与集成测试
├── scripts/ # 运维脚本 (watchdog.py, init_db.py)
├── assets/ # 资源库 (fonts, bgm, styles)
├── user_data/ # 用户隔离数据 (Cookie 等)
└── requirements.txt # 依赖清单
```
@@ -42,12 +52,11 @@ backend/
2. **视频生成 (Videos)**
* `POST /api/videos/generate`: 提交生成任务
* `GET /api/videos/tasks/{task_id}`: 查询任务状态
* `GET /api/videos/tasks/{task_id}`: 查询单个任务状态
* `GET /api/videos/tasks`: 获取用户所有任务列表
* `GET /api/videos/generated`: 获取历史视频列表
* `DELETE /api/videos/generated/{video_id}`: 删除历史视频
> **修正 (16:20)**:任务查询与历史列表接口已更新为 `/api/videos/tasks/{task_id}` 与 `/api/videos/generated`。
3. **素材管理 (Materials)**
* `POST /api/materials`: 上传素材
* `GET /api/materials`: 获取素材列表
@@ -55,14 +64,33 @@ backend/
4. **社交发布 (Publish)**
* `POST /api/publish`: 发布视频到 抖音/微信视频号/B站/小红书
* `POST /api/publish/login`: 扫码登录平台
* `GET /api/publish/login/status`: 查询登录状态(含刷脸验证二维码)
* `GET /api/publish/accounts`: 获取已登录账号列表
> 提示:视频号发布建议使用 headful + xvfb-run 运行后端,避免 headless 解码失败
> 提示:视频号/抖音发布建议使用 headful + xvfb-run 运行后端。
5. **资源库 (Assets)**
* `GET /api/assets/subtitle-styles`: 字幕样式列表
* `GET /api/assets/title-styles`: 标题样式列表
* `GET /api/assets/bgm`: 背景音乐列表
6. **声音克隆 (Ref Audios)**
* `POST /api/ref-audios`: 上传参考音频 (multipart/form-data)
* `GET /api/ref-audios`: 获取参考音频列表
* `PUT /api/ref-audios/{id}`: 重命名参考音频
* `DELETE /api/ref-audios/{id}`: 删除参考音频
7. **AI 功能 (AI)**
* `POST /api/ai/generate-meta`: AI 生成标题和标签
8. **工具 (Tools)**
* `POST /api/tools/extract-script`: 从视频链接提取文案
9. **健康检查**
* `GET /api/lipsync/health`: LatentSync 服务健康状态
* `GET /api/voiceclone/health`: Qwen3-TTS 服务健康状态
### 统一响应结构
```json
@@ -80,10 +108,17 @@ backend/
`POST /api/videos/generate` 支持以下可选字段:
- `tts_mode`: TTS 模式 (`edgetts` / `voiceclone`)
- `voice`: EdgeTTS 音色 IDedgetts 模式)
- `ref_audio_id` / `ref_text`: 参考音频 ID 与文本voiceclone 模式)
- `title`: 片头标题文字
- `subtitle_style_id`: 字幕样式 ID
- `title_style_id`: 标题样式 ID
- `subtitle_font_size`: 字幕字号(覆盖样式默认值)
- `title_font_size`: 标题字号(覆盖样式默认值)
- `title_top_margin`: 标题距顶部像素
- `subtitle_bottom_margin`: 字幕距底部像素
- `enable_subtitles`: 是否启用字幕
- `bgm_id`: 背景音乐 ID
- `bgm_volume`: 背景音乐音量0-1默认 0.2
@@ -148,7 +183,7 @@ uvicorn app.main:app --host 0.0.0.0 --port 8006 --reload
1.`app/services/` 下创建新的 Service 类 (如 `NewTTSService`)。
2. 实现 `generate` 方法,可以使用 subprocess 调用,也可以是 HTTP 请求。
3. **重要**: 如果模型占用 GPU请务必使用 `asyncio.Lock` 进行并发控制,防止 OOM。
4.`app/api/` 中添加对应的路由调用
4.`app/modules/` 下创建对应模块,添加 router/service/schemas并在 `main.py` 注册路由
### 添加定时任务

View File

@@ -115,6 +115,16 @@ playwright install chromium
```
> 提示:视频号发布建议使用系统 Chrome + xvfb-run避免 headless 解码失败)。
> 抖音发布同样建议 headful 模式 (`DOUYIN_HEADLESS_MODE=headful`)。
### 扫码登录注意事项
- **Cookie 按用户隔离**:每个用户的 Cookie 存储在 `backend/user_data/{uuid}/cookies/` 目录下,多用户并发登录互不干扰。
- **抖音 QR 登录关键教训**
- 扫码后绝对**不能重新加载 QR 页面**,否则会销毁会话 token
- 使用**新标签页**检测登录完成状态(检查 URL 包含 `creator-micro` + session cookies 存在)
- 抖音可能弹出**刷脸验证**,后端会自动提取验证二维码返回给前端展示
- **微信视频号发布**:标题、描述、标签统一写入"视频描述"字段
---
@@ -189,8 +199,17 @@ cp .env.example .env
| `WEIXIN_TIMEZONE_ID` | Asia/Shanghai | 视频号时区 |
| `WEIXIN_FORCE_SWIFTSHADER` | true | 强制软件 WebGL避免 context lost |
| `WEIXIN_TRANSCODE_MODE` | reencode | 上传前转码 (reencode/faststart/off) |
| `DOUYIN_HEADLESS_MODE` | headless-new | 抖音 Playwright 模式 (headful/headless-new) |
| `DOUYIN_CHROME_PATH` | `/usr/bin/google-chrome` | 抖音 Chrome 路径 |
| `DOUYIN_BROWSER_CHANNEL` | | 抖音 Chromium 通道 (可选) |
| `DOUYIN_USER_AGENT` | Chrome/144 UA | 抖音浏览器指纹 UA |
| `DOUYIN_LOCALE` | zh-CN | 抖音语言环境 |
| `DOUYIN_TIMEZONE_ID` | Asia/Shanghai | 抖音时区 |
| `DOUYIN_FORCE_SWIFTSHADER` | true | 强制软件 WebGL |
| `DOUYIN_DEBUG_ARTIFACTS` | false | 保留调试截图 |
| `DOUYIN_RECORD_VIDEO` | false | 录制浏览器操作视频 |
| `DOUYIN_KEEP_SUCCESS_VIDEO` | false | 成功后保留录屏 |
| `CORS_ORIGINS` | `*` | CORS 允许源 (生产环境建议白名单) |
| `SUPABASE_STORAGE_LOCAL_PATH` | 默认路径 | Supabase 本地存储路径 |
| `DOUYIN_COOKIE` | 空 | 抖音视频下载 Cookie (文案提取功能) |
---
@@ -265,6 +284,7 @@ cat > run_backend.sh << 'EOF'
set -e
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
export WEIXIN_HEADLESS_MODE=headful
export DOUYIN_HEADLESS_MODE=headful
export WEIXIN_DEBUG_ARTIFACTS=false
export WEIXIN_RECORD_VIDEO=false
export DOUYIN_DEBUG_ARTIFACTS=false

View File

@@ -64,3 +64,40 @@ pm2 restart vigent2-backend
pm2 restart vigent2-latentsync
# Remotion 已自动编译
```
### 🎨 交互与体验优化 (17:00)
- [x] **UX-1**: PublishPage 图片加载优化 (`<img>``next/image`)
- [x] **UX-2**: 按钮 Loading 状态统一 (提取脚本弹窗 + 发布页)
- [x] **UX-3**: 骨架屏加载优化 (发布页加载中状态)
- [x] **UX-4**: 全局快捷键支持 (ESC 关闭弹窗, Enter 确认)
- [x] **UX-5**: 移除全局 GlobalTaskIndicator (视觉降噪)
- [x] **UX-6**: 视频生成完成自动刷新列表并选中最新
### 🐛 缺陷修复与回归治理 (17:30)
#### 严重缺陷修复
- [x] **BUG-1**: Remotion 渲染脚本路径解析错误 (导致标题字幕丢失)
- *原因*: `render.js` 预编译后使用了 `__dirname`,在 `dist` 目录下寻找源码失败。
- *修复*: 修改 `render.ts` 使用 `process.cwd()` 动态解析路径,并重新编译。
- [x] **BUG-2**: 发布页视频选择持久化失效 (Auth 异步竞态)
- *原因*: 页面加载时 `useAuth` 尚未返回用户 ID导致使用 `guest` Key 读取不到记录,随后被默认值覆盖。
- *修复*: 引入 `isVideoRestored` 状态机,强制等待 Auth 完成且 Video 列表加载完毕后,才执行恢复逻辑。
#### 回归问题治理
- [x] **REG-1**: 首页历史作品 ID 恢复后内容不显示
- *原因*: 持久化模块恢复了 ID`useGeneratedVideos` 未监听 ID 变化同步 URL。
- *修复*: 新增 `useEffect` 监听 `selectedVideoId` 变化并同步 `generatedVideo` URL。
- [x] **REG-2**: 首页/发布页“默认选中第一个”逻辑丢失
- *原因*: 重构移除旧逻辑后,新用户或无缓存用户进入页面无默认选中。
- *修复*: 在 `isRestored` 且无选中时,增加兜底逻辑自动选中列表第一项。
- [x] **REG-3**: 素材选择持久化失效 (闭包陷阱)
- *原因*: `useMaterials` 加载回调中捕获了旧的 `selectedMaterial` 状态,覆盖了已恢复的值。
- *修复*: 改为函数式状态更新 (`setState(prev => ...)`),确保基于最新状态判断。
- [x] **REF-1**: 持久化逻辑全站收敛与排查
- *优化*: 清理 `useBgm`, `useGeneratedVideos`, `useTitleSubtitleStyles` 中的冗余 `localStorage` 读取,统一由 `useHomePersistence` 管理。
- *排查*: 深度排查 `useRefAudios`, `useTitleSubtitleStyles` 等模块,确认逻辑健壮,无类似回归风险。

248
Docs/DevLogs/Day21.md Normal file
View File

@@ -0,0 +1,248 @@
## 🐛 缺陷修复:视频生成与持久化回归 (Day 21)
### 概述
本日修复 Day 20 优化后引入的 3 个回归缺陷Remotion 渲染崩溃容错、首页作品选择持久化、发布页作品选择持久化。
---
### 已完成修复
#### BUG-1: Remotion 渲染进程崩溃导致标题/字幕丢失
- **现象**: 视频生成后没有标题和字幕,回退到纯 FFmpeg 合成。
- **根因**: Remotion Node.js 进程在渲染完成100%)后以 SIGABRT (code -6) 退出Python 端将其视为失败。
- **修复**: `remotion_service.py` 在进程非零退出时,先检查输出文件是否存在且大小合理(>1KB若存在则视为成功。
- **文件**: `backend/app/services/remotion_service.py`
```python
if process.returncode != 0:
output_file = Path(output_path)
if output_file.exists() and output_file.stat().st_size > 1024:
logger.warning(
f"Remotion process exited with code {process.returncode}, "
f"but output file exists ({output_file.stat().st_size} bytes). Treating as success."
)
return output_path
raise RuntimeError(...)
```
#### BUG-2: 首页历史作品选择刷新后不保持
- **现象**: 用户选择某个历史作品后刷新页面,总是回到第一个视频。
- **根因**: `fetchGeneratedVideos()` 在初始加载时无条件自动选中第一个视频,覆盖了 `useHomePersistence` 的恢复值。
- **修复**: `fetchGeneratedVideos` 增加 `preferVideoId` 参数,仅在明确指定时才自动选中;新增 `"__latest__"` 哨兵值用于生成完成后选中最新。
- **文件**: `frontend/src/features/home/model/useGeneratedVideos.ts`, `frontend/src/features/home/model/useHomeController.ts`
```typescript
// 任务完成 → 自动选中最新
useEffect(() => {
if (prevIsGenerating.current && !isGenerating) {
if (currentTask?.status === "completed") {
void fetchGeneratedVideos("__latest__");
} else {
void fetchGeneratedVideos();
}
}
prevIsGenerating.current = isGenerating;
}, [isGenerating, currentTask, fetchGeneratedVideos]);
```
#### BUG-3: 发布页作品选择刷新后不保持(根因:签名 URL 不稳定)
- **现象**: 发布管理页选择视频后刷新,选择丢失(无任何视频被选中)。
- **根因**: 后端 `/api/videos/generated` 返回的 `path` 是 Supabase 签名 URL每次请求都会变化。发布页用 `path` 作为选择标识存入 localStorage刷新后新的 `path` 与保存值永远不匹配。首页不受影响是因为使用稳定的 `video.id`
- **修复**: 发布页全面改用 `id`(稳定标识)替代 `path`(签名 URL进行选择、持久化和比较。
- **文件**:
- `frontend/src/shared/types/publish.ts``PublishVideo` 新增 `id` 字段
- `frontend/src/features/publish/model/usePublishController.ts``selectedVideo` 存储 `id`,发布时根据 `id` 查找 `path`
- `frontend/src/features/publish/ui/PublishPage.tsx``key`/`onClick`/选中比较改用 `v.id`
- `frontend/src/features/home/model/useHomeController.ts` — 预取缓存加入 `id` 字段
```typescript
// 类型定义新增 id
export interface PublishVideo {
id: string; // 稳定标识符
name: string;
path: string; // 签名 URL仅用于播放/发布)
}
// 发布时根据 id 查找 path
const video = videos.find(v => v.id === selectedVideo);
await api.post('/api/publish', { video_path: video.path, ... });
```
---
### 涉及文件汇总
| 文件 | 变更 |
|------|------|
| `backend/app/services/remotion_service.py` | Remotion 崩溃容错 |
| `frontend/src/features/home/model/useGeneratedVideos.ts` | 首页视频选择不自动覆盖 |
| `frontend/src/features/home/model/useHomeController.ts` | 任务完成监听 + 预取缓存加 id |
| `frontend/src/shared/types/publish.ts` | PublishVideo 新增 id 字段 |
| `frontend/src/features/publish/model/usePublishController.ts` | 选择/持久化/发布改用 id |
| `frontend/src/features/publish/ui/PublishPage.tsx` | UI 选择比较改用 id |
### 关键教训
> **签名 URL 不可作为持久化标识**。Supabase Storage 的签名 URL 包含时间戳和签名参数,每次请求都不同。任何需要跨请求/跨刷新保持的标识,必须使用后端返回的稳定 `id` 字段。
### 重启要求
```bash
pm2 restart vigent2-backend # Remotion 容错
npm run build && pm2 restart vigent2-frontend # 前端持久化修复
```
---
## 🎨 浮动样式预览窗口优化 (Day 21)
### 概述
标题与字幕面板中的预览区域原本是内联折叠的,展开后调节下方滑块时看不到预览效果。改为 `position: fixed` 浮动窗口,固定在视口左上角,滚动页面时预览始终可见,边调边看。
### 已完成优化
#### 1. 新建浮动预览组件 `FloatingStylePreview.tsx`
- `createPortal(jsx, document.body)` 渲染到 body 层级,脱离面板 DOM 树
- `position: fixed` + 左上角固定定位,滚动时不移动
- `z-index: 150`(低于 VideoPreviewModal 的 200
- 顶部标题栏 + X 关闭按钮ESC 键关闭
- 桌面端固定宽度 280px移动端自适应最大 360px
- `previewScale = windowWidth / previewBaseWidth` 自行计算缩放
- `maxHeight: calc(100dvh - 32px)` 防止超出视口
#### 2. 修改 `TitleSubtitlePanel.tsx`
- 删除内联预览区域(`ref={previewContainerRef}` 整块 JSX
- 条件渲染 `<FloatingStylePreview />`,按钮文本保持"预览样式"/"收起预览"
- 移除 `previewScale``previewAspectRatio``previewContainerRef` props
- 保留 `previewBaseWidth/Height`(浮动窗口需要原始尺寸计算 scale
#### 3. 清理 `useHomeController.ts`
- 移除 `previewContainerWidth` 状态
- 移除 `titlePreviewContainerRef` ref
- 移除 ResizeObserver useEffect浮动窗口自管尺寸不再需要
#### 4. 简化 `HomePage.tsx` 传参
- 移除 `previewContainerWidth``titlePreviewContainerRef` 解构
- 移除 `previewScale``previewAspectRatio``previewContainerRef` prop 传递
#### 5. 移动端适配
- `ScriptEditor.tsx`:标题行改为 `flex-wrap`"AI生成标题标签"按钮不再溢出
- 预览默认比例从 1280×720 (16:9) 改为 1080×1920 (9:16),符合抖音竖屏视频
### 涉及文件汇总
| 文件 | 变更 |
|------|------|
| `frontend/src/features/home/ui/FloatingStylePreview.tsx` | **新建** 浮动预览组件 |
| `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | 移除内联预览,渲染浮动组件 |
| `frontend/src/features/home/model/useHomeController.ts` | 移除 preview 容器相关状态和 ResizeObserver |
| `frontend/src/features/home/ui/HomePage.tsx` | 简化 props 传递,默认比例改 9:16 |
| `frontend/src/features/home/ui/ScriptEditor.tsx` | 移动端按钮换行适配 |
### 重启要求
```bash
npm run build && pm2 restart vigent2-frontend
```
---
## 🔧 多平台发布体系重构:用户隔离与抖音刷脸验证 (Day 21)
### 概述
重构发布系统的两大核心问题:① 多用户场景下 Cookie/会话缺乏隔离,② 抖音登录新增刷脸验证步骤无法处理。同时修复了平台配置混用和微信视频号发布流程问题。
---
### 一、平台配置独立化
#### 问题
所有平台抖音、微信、B站、小红书共用 WEIXIN_* 配置,导致 User-Agent、Headless 模式等设置不匹配。
#### 修复 — `config.py`
- 新增 `DOUYIN_*` 独立配置项:`DOUYIN_HEADLESS_MODE``DOUYIN_USER_AGENT`Chrome/144`DOUYIN_LOCALE``DOUYIN_TIMEZONE_ID``DOUYIN_CHROME_PATH``DOUYIN_FORCE_SWIFTSHADER`、调试开关等
- 微信保持已有 `WEIXIN_*` 配置
- B站/小红书使用通用默认值
#### 修复 — `qr_login_service.py` 平台配置映射
```python
# 之前:所有平台都用 WEIXIN 设置
# 之后:每个平台独立配置
PLATFORM_CONFIGS = {
"douyin": { headless, user_agent, locale, timezone... },
"weixin": { headless, user_agent, locale, timezone... },
"bilibili": { 通用配置 },
"xiaohongshu": { 通用配置 },
}
```
---
### 二、用户隔离的 Cookie 管理
#### 问题
多用户共享同一套 Cookie 文件,用户 A 的登录态可能被用户 B 覆盖。
#### 修复 — `publish_service.py`
- `_get_cookies_dir(user_id)``backend/user_data/{uuid}/cookies/`
- `_get_cookie_path(user_id, platform)` → 按用户+平台返回独立 Cookie 文件路径
- `_get_session_key(user_id, platform)``"{user_id}_{platform}"` 格式的会话 key
- 登录/发布流程全链路传入 `user_id`,清理残留会话避免干扰
---
### 三、抖音刷脸验证二维码
#### 问题
抖音扫码登录后可能弹出刷脸验证窗口,内含新的二维码需要用户再次扫描,前端无法感知和展示。
#### 修复 — 后端 `qr_login_service.py`
- 扩展 QR 选择器:支持跨 iframe 搜索二维码元素
- 抖音 API 拦截:监听 `check_qrconnect` 响应,检测 `redirect_url`
- 检测 "完成验证" / "请前往APP完成验证" 文案
- 在验证弹窗内找到正方形二维码(排除头像),截图返回给前端
- API 确认后直接导航到 redirect_url不重新加载 QR 页,避免销毁会话)
#### 修复 — 后端 `publish_service.py`
- `get_login_session_status()` 新增 `face_verify_qr` 字段返回
- 登录成功且 Cookie 保存后自动清理会话
#### 修复 — 前端
- `usePublishController.ts`:新增 `faceVerifyQr` 状态,轮询时获取 `face_verify_qr` 字段
- `PublishPage.tsx`QR 弹窗优先展示刷脸验证二维码,附提示文案
```tsx
{faceVerifyQr ? (
<>
<Image src={`data:image/png;base64,${faceVerifyQr}`} />
<p>APP扫描上方二维码完成刷脸验证</p>
</>
) : /* 普通登录二维码 */ }
```
---
### 四、微信视频号发布流程优化
#### 修复 — `weixin_uploader.py`
- 添加 `user_id` 参数支持,发布截图目录隔离
- 新增 `post_create` API 响应监听,精准判断发布成功
- 发布结果判定URL 离开创建页 或 API 确认提交 → 视为成功
- 标题/标签处理改为统一写入"视频描述"字段(不再单独填写 title/tags
---
### 涉及文件汇总
| 文件 | 变更 |
|------|------|
| `backend/app/core/config.py` | 新增 DOUYIN_* 独立配置项 |
| `backend/app/services/qr_login_service.py` | 平台配置拆分、刷脸验证二维码、跨 iframe 选择器 |
| `backend/app/services/publish_service.py` | 用户隔离 Cookie 管理、刷脸验证状态返回 |
| `backend/app/services/uploader/weixin_uploader.py` | user_id 支持、post_create API 监听、描述字段合并 |
| `frontend/src/features/publish/model/usePublishController.ts` | faceVerifyQr 状态 |
| `frontend/src/features/publish/ui/PublishPage.tsx` | 刷脸验证二维码展示 |
### 重启要求
```bash
pm2 restart vigent2-backend # 发布服务 + QR登录
npm run build && pm2 restart vigent2-frontend # 刷脸验证UI
```

View File

@@ -31,8 +31,6 @@
| ⚡ **Med** | `Docs/FRONTEND_DEV.md` | **(前端规范)** API封装、日期格式化、新页面规范 |
| ⚡ **Med** | `Docs/FRONTEND_README.md` | **(前端文档)** 功能说明、页面变更 |
| 🧊 **Low** | `Docs/*_DEPLOY.md` | **(子系统部署)** LatentSync/Qwen3/字幕等独立部署文档 |
| 🧊 **Low** | `Docs/implementation_plan.md` | **(实施计划)** 核对计划与实际实现的差异 |
| 🧊 **Low** | `Docs/architecture_plan.md` | **(前端架构)** 拆分计划与阶段目标 |
---
@@ -97,7 +95,7 @@
### 必须执行的检查步骤
**1. 快速浏览全文**(使用 `view_file``grep_search`
**1. 快速浏览全文**(使用 `Read``Grep`
```markdown
# 检查是否存在:
- 同主题的旧章节?
@@ -144,66 +142,41 @@
> **核心原则**:使用正确的工具,避免字符编码问题
### ✅ 推荐工具:apply_patch
### ✅ 推荐工具:Edit / Read / Grep
**使用场景**
- 追加新章节到文件末尾
- 修改/替换现有章节内容
- 更新状态标记(🔄 → ✅)
- 修正错误内容
**优势**
- ✅ 自动处理字符编码Windows CRLF
- ✅ 精确替换,不会误删其他内容
- ✅ 有错误提示,方便调试
- `Read`:更新前先查看文件当前内容
- `Edit`:精确替换现有内容、追加新章节
- `Grep`:搜索文件中是否已有相关章节
- `Write`:创建新文件(如 Day{N+1}.md
**注意事项**
```markdown
1. **必须精确匹配**TargetContent 必须与文件完全一致
2. **处理换行符**文件使用 \r\n不要漏掉 \r
3. **合理范围**StartLine/EndLine 应覆盖目标内容
4. **先读后写**:编辑前先 view_file 确认内容
1. **先读后写**:编辑前先用 Read 确认内容
2. **精确匹配**Edit 的 old_string 必须与文件内容完全一致
3. **避免重复**编辑前用 Grep 检查是否已存在同主题章节
```
### ❌ 禁止使用:命令行工具
### ❌ 禁止使用:命令行工具修改文档
**禁止场景**
- ❌ 使用 `echo >>` 追加内容(编码问题)
- ❌ 使用 PowerShell 直接修改文档(破坏格式)
- ❌ 使用 sed/awk 等命令行工具
- ❌ 使用 `echo >>` 追加内容
- ❌ 使用 `sed` / `awk` 修改文档
- ❌ 使用 `cat <<EOF` 写入内容
**原因**
- 容易破坏 UTF-8 编码
- Windows CRLF vs Unix LF 混乱
- 容易破坏 UTF-8 编码和中文字符
- 难以追踪修改,容易出错
**唯一例外**:简单的全局文本替换(如批量更新日期),且必须使用 `-NoNewline` 参数
- 无法精确匹配替换位置
### 📝 最佳实践示例
**追加新章节**
```diff
*** Begin Patch
*** Update File: Docs/DevLogs/DayN.md
@@
## 🔗 相关文档
...
---
**追加新章节**使用 `Edit` 工具,`old_string` 匹配文件末尾内容,`new_string` 包含原内容 + 新章节。
## 🆕 新章节
内容...
*** End Patch
```
**修改现有内容**
```diff
*** Begin Patch
*** Update File: Docs/DevLogs/DayN.md
@@
-**状态**:🔄 待修复
+**状态**:✅ 已修复
*** End Patch
**修改现有内容**:使用 `Edit` 工具精确替换。
```markdown
old_string: "**状态**:🔄 待修复"
new_string: "**状态**:✅ 已修复"
```
@@ -219,8 +192,6 @@ ViGent2/Docs/
├── BACKEND_README.md # 后端功能文档
├── FRONTEND_DEV.md # 前端开发规范
├── FRONTEND_README.md # 前端功能文档
├── architecture_plan.md # 前端拆分计划
├── implementation_plan.md # 实施计划
├── DEPLOY_MANUAL.md # 部署手册
├── SUPABASE_DEPLOY.md # Supabase 部署文档
├── LatentSync_DEPLOY.md # LatentSync 部署文档
@@ -325,4 +296,4 @@ ViGent2/Docs/
---
**最后更新**2026-02-07
**最后更新**2026-02-08

View File

@@ -2,22 +2,65 @@
## 目录结构
采用轻量 FSDFeature-Sliced Design结构
```
frontend/src/
├── app/ # Next.js App Router 页面
│ ├── page.tsx # 首页(视频生成)
│ ├── publish/ # 发布页
│ ├── admin/ # 管理员页面
│ ├── login/ # 登录页面
│ └── register/ # 注册页面
├── components/ # 可复用组件
│ ├── home/ # 首页拆分组件
└── ...
├── lib/ # 公共工具函数
├── axios.ts # Axios 实例(含 401/403 拦截器)
├── auth.ts # 认证相关函数
└── media.ts # API Base / URL / 日期等通用工具
└── proxy.ts # 路由代理(原 middleware
├── app/ # Next.js App Router 页面入口
│ ├── page.tsx # 首页(视频生成)
│ ├── publish/ # 发布管理
│ ├── admin/ # 管理员页面
│ ├── login/ # 登录
│ └── register/ # 注册
├── features/ # 功能模块(按业务拆分)
│ ├── home/
│ ├── model/ # 业务逻辑 hooks
├── useHomeController.ts # 主控制器
│ │ ├── useHomePersistence.ts # 持久化管理
│ │ ├── useBgm.ts
│ │ ├── useGeneratedVideos.ts
│ ├── useMaterials.ts
│ │ │ ├── useMediaPlayers.ts
│ │ │ ├── useRefAudios.ts
│ │ │ └── useTitleSubtitleStyles.ts
│ │ └── ui/ # UI 组件(纯 props + 回调)
│ │ ├── HomePage.tsx
│ │ ├── HomeHeader.tsx
│ │ ├── MaterialSelector.tsx
│ │ ├── ScriptEditor.tsx
│ │ ├── TitleSubtitlePanel.tsx
│ │ ├── FloatingStylePreview.tsx
│ │ ├── VoiceSelector.tsx
│ │ ├── RefAudioPanel.tsx
│ │ ├── BgmPanel.tsx
│ │ ├── GenerateActionBar.tsx
│ │ ├── PreviewPanel.tsx
│ │ └── HistoryList.tsx
│ └── publish/
│ ├── model/
│ │ └── usePublishController.ts
│ └── ui/
│ └── PublishPage.tsx
├── shared/ # 跨功能共享
│ ├── api/
│ │ ├── axios.ts # Axios 实例(含 401/403 拦截器)
│ │ └── types.ts # 统一响应类型
│ ├── lib/
│ │ ├── media.ts # API Base / URL / 日期等通用工具
│ │ ├── auth.ts # 认证相关函数
│ │ └── title.ts # 标题输入处理
│ ├── hooks/
│ │ ├── useTitleInput.ts
│ │ └── usePublishPrefetch.ts
│ ├── types/
│ │ ├── user.ts # User 类型定义
│ │ └── publish.ts # 发布相关类型
│ └── contexts/ # 已迁移的 Context
├── contexts/ # 全局 ContextAuth、Task
├── components/ # 遗留通用组件
│ ├── VideoPreviewModal.tsx
│ └── ScriptExtractionModal.tsx
└── proxy.ts # Next.js middleware路由保护
```
---
@@ -228,10 +271,15 @@ import { formatDate } from '@/shared/lib/media';
## 轻量 FSD 结构
- `app/`:页面入口,保持轻量
- `features/*/model`:业务逻辑与状态 (hooks)
- `features/*/ui`:功能 UI 组件
- `shared/`:通用工具、通用 hooks、API 实例
- `app/`:页面入口,保持轻量,只做组合与布局
- `features/*/model`:业务逻辑与状态Controller Hook + 子 Hook
- `features/*/ui`:功能 UI 组件(纯 props + 回调,不直接发 API
- `shared/api`Axios 实例与统一响应类型
- `shared/lib`通用工具函数media.ts / auth.ts / title.ts
- `shared/hooks`:跨功能通用 hooks
- `shared/types`跨功能实体类型User / PublishVideo 等)
- `contexts/`:全局 ContextAuthContext / TaskContext
- `components/`遗留通用组件VideoPreviewModal 等)
## 类型定义规范
@@ -256,6 +304,7 @@ import { formatDate } from '@/shared/lib/media';
- **恢复先于保存**:恢复完成前禁止写入(`isRestored` 保护)。
- 避免默认值覆盖用户选择(优先读取已保存值)。
- 优先使用 `useHomePersistence` 集中管理恢复/保存,页面内避免分散的 localStorage 读写。
- **禁止使用签名 URL 作为持久化标识**Supabase Storage 签名 URL 每次请求都变化,必须使用后端返回的稳定 `id` 字段。
- 如需新增持久化字段,必须加入恢复与保存逻辑,并更新本节。
---
@@ -273,7 +322,8 @@ import { formatDate } from '@/shared/lib/media';
## 发布页交互规则
- 发布按钮在未选择任何平台时禁用
- 仅保留立即发布,不再提供定时发布 UI/参数
- 仅保留"立即发布",不再提供定时发布 UI/参数
- **作品选择持久化**:使用 `video.id`(稳定标识)而非 `video.path`(签名 URL进行选择、比较和 localStorage 存储。发布时根据 `id` 查找对应 `path` 发送请求。
---

View File

@@ -17,6 +17,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
- **预览优化**: 预览视频 `metadata` 预取,首帧加载更快。
- **本地保存**: 文案/标题/偏好由 `useHomePersistence` 统一持久化,刷新后恢复 (Day 14/17)。
- **选择持久化**: 首页/发布页作品选择均使用稳定 `id` 持久化,刷新保持用户选择;新视频生成后自动选中最新 (Day 21)。
### 2. 全自动发布 (`/publish`) [Day 7 新增]
- **多平台管理**: 统一管理抖音、微信视频号、B站、小红书账号状态。
@@ -26,6 +27,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- Cookie 自动保存与状态同步。
- **发布配置**: 设置视频标题、标签、简介。
- **作品选择**: 卡片列表 + 搜索 + 预览弹窗。
- **选择持久化**: 使用稳定 `video.id` 持久化选择,刷新保持;新视频生成自动选中最新 (Day 21)。
- **预览兼容**: 签名 URL / 相对路径均可直接预览。
- **发布方式**: 仅支持 "立即发布"。

View File

@@ -1,427 +0,0 @@
# 数字人口播视频生成系统 - 实现计划
## 项目目标
构建一个开源的数字人口播视频生成系统,功能包括:
- 上传静态人物视频 → 生成口播视频(唇形同步)
- TTS 配音或声音克隆
- 字幕自动生成与渲染
- AI 自动生成标题与标签
- 一键发布到多个社交平台
---
## 技术架构
```
┌─────────────────────────────────────────────────────────┐
│ 前端 (Next.js) │
│ 素材管理 | 视频生成 | 发布管理 | 任务状态 │
└─────────────────────────────────────────────────────────┘
│ REST API
┌─────────────────────────────────────────────────────────┐
│ 后端 (FastAPI) │
├─────────────────────────────────────────────────────────┤
│ 异步任务队列 (asyncio) │
│ ├── 视频生成任务 │
│ ├── TTS 配音任务 │
│ └── 自动发布任务 │
└─────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│LatentSync│ │ FFmpeg │ │Playwright│
│ 唇形同步 │ │ 视频合成 │ │ 自动发布 │
└──────────┘ └──────────┘ └──────────┘
```
---
## 技术选型
| 模块 | 技术选择 | 备选方案 |
|------|----------|----------|
| **前端框架** | Next.js 16 | Vue 3 + Vite |
| **UI 组件库** | TailwindCSS (自定义组件) | Ant Design |
| **后端框架** | FastAPI | Flask |
| **任务队列** | FastAPI BackgroundTasks (asyncio) | Celery + Redis |
| **唇形同步** | **LatentSync 1.6** | MuseTalk / Wav2Lip |
| **TTS 配音** | EdgeTTS | CosyVoice |
| **声音克隆** | **Qwen3-TTS 1.7B** ✅ | GPT-SoVITS |
| **视频处理** | FFmpeg | MoviePy |
| **自动发布** | Playwright | 自行实现 |
| **数据库** | Supabase (PostgreSQL) | MySQL |
| **文件存储** | Supabase Storage | 阿里云 OSS |
> **修正 (18:10)**:当前实现采用 Next.js 16、FastAPI BackgroundTasks 与 Supabase Storage/Auth自动发布基于 Playwright。
---
## ✅ 现状补充 (Day 17)
- 前端已拆分为组件化结构(`features/home/ui/`),主页面逻辑集中。
- 通用工具 `media.ts` 统一处理 API Base / 资源 URL / 日期格式化。
- 作品预览弹窗统一样式,并支持素材/发布预览复用。
- 标题/字幕预览按素材分辨率缩放,效果更接近成片。
---
## 分阶段实施计划
### 阶段一:核心功能验证 (MVP)
> **目标**:验证 LatentSync + EdgeTTS 效果,跑通端到端流程
#### 1.1 环境搭建
参考 `models/LatentSync/DEPLOY.md` 完成 LatentSync 环境与权重部署。
#### 1.2 集成 EdgeTTS
```python
# tts_engine.py
import edge_tts
import asyncio
async def text_to_speech(text: str, voice: str = "zh-CN-YunxiNeural", output_path: str = "output.mp3"):
communicate = edge_tts.Communicate(text, voice)
await communicate.save(output_path)
return output_path
```
#### 1.3 端到端测试脚本
```python
# test_pipeline.py
"""
1. 文案 → EdgeTTS → 音频
2. 静态视频 + 音频 → LatentSync → 口播视频
3. 添加字幕 → FFmpeg → 最终视频
"""
```
#### 1.4 验证标准
- [ ] LatentSync 能正常推理
- [ ] 唇形与音频同步率 > 90%
- [ ] 单个视频生成时间 < 2 分钟
---
### 阶段二:后端 API 开发
> **目标**:将核心功能封装为 API支持异步任务
#### 2.1 项目结构
```
backend/
├── app/
│ ├── main.py # FastAPI 入口
│ ├── api/
│ │ ├── videos.py # 视频生成 API
│ │ ├── materials.py # 素材管理 API
│ │ └── publish.py # 发布管理 API
│ ├── services/
│ │ ├── tts_service.py # TTS 服务
│ │ ├── lipsync_service.py # 唇形同步服务
│ │ └── video_service.py # 视频合成服务
│ ├── tasks/
│ │ └── celery_tasks.py # Celery 异步任务
│ ├── models/
│ │ └── schemas.py # Pydantic 模型
│ └── core/
│ └── config.py # 配置管理
├── requirements.txt
└── docker-compose.yml # Redis + API
```
#### 2.2 核心 API 设计
| 端点 | 方法 | 功能 |
|------|------|------|
| `/api/materials` | POST | 上传视频素材 | ✅ |
| `/api/materials` | GET | 获取素材列表 | ✅ |
| `/api/videos/generate` | POST | 创建视频生成任务 | ✅ |
| `/api/videos/tasks/{id}` | GET | 查询任务状态 | ✅ |
| `/api/videos/generated` | GET | 获取历史作品列表 | ✅ |
| `/api/publish` | POST | 发布到社交平台 | ✅ |
#### 2.3 BackgroundTasks 任务定义
```python
# app/api/videos.py
background_tasks.add_task(_process_video_generation, task_id, req, user_id)
```
---
### 阶段三:前端 Web UI
> **目标**:提供用户友好的操作界面
#### 3.1 页面设计
| 页面 | 功能 |
|------|------|
| **素材库** | 上传/管理多场景视频素材 |
| **生成视频** | 输入文案、选择素材、生成预览 |
| **任务中心** | 查看生成进度、下载视频 |
| **发布管理** | 绑定平台、一键发布、定时发布 |
#### 3.2 技术实现
```bash
# 创建 Next.js 项目
npx create-next-app@latest frontend --typescript --tailwind --app
# 安装依赖
cd frontend
npm install axios swr
```
---
### 阶段四:社交媒体发布
> **目标**:集成 social-auto-upload支持多平台发布
#### 4.1 复用 social-auto-upload
```bash
# 复制模块
cp -r SuperIPAgent/social-auto-upload backend/social_upload
```
#### 4.2 Cookie 管理
```python
# 用户通过浏览器登录 → 保存 Cookie → 后续自动发布
```
#### 4.3 支持平台
- 抖音
- 小红书
- 微信视频号
- 快手
---
### 阶段五:优化与扩展
| 功能 | 实现方式 |
|------|----------|
| **声音克隆** | 集成 GPT-SoVITS用自己的声音 |
| **AI 标题/标签生成** | 调用大模型 API 自动生成标题与标签 ✅ |
| **批量生成** | 上传 Excel/CSV批量生成视频 |
| **字幕编辑器** | 可视化调整字幕样式、位置 |
| **Docker 部署** | 一键部署到云服务器 | ✅ |
---
### 阶段六MuseTalk 服务器部署 (Day 2-3) ✅
> **目标**:在双显卡服务器上部署 MuseTalk 环境
- [x] Conda 环境配置 (musetalk)
- [x] 模型权重下载 (~7GB)
- [x] Subprocess 调用方式实现
- [x] 健康检查功能
### 阶段七MuseTalk 完整修复 (Day 4) ✅
> **目标**:解决推理脚本的各种兼容性问题
- [x] 权重检测路径修复 (软链接)
- [x] 音视频长度不匹配修复
- [x] 推理脚本错误日志增强
- [x] 视频合成 MP4 生成验证
### 阶段八:前端功能增强 (Day 5) ✅
> **目标**:提升用户体验
- [x] Web 视频上传功能
- [x] 上传进度显示
- [x] 自动刷新素材列表
### 阶段九:唇形同步模型升级 (Day 6) ✅
> **目标**:从 MuseTalk 迁移到 LatentSync 1.6
- [x] MuseTalk → LatentSync 1.6 迁移
- [x] 后端代码适配 (config.py, lipsync_service.py)
- [x] Latent Diffusion 架构 (512x512 高清)
- [x] 服务器端到端验证
### 阶段十:性能优化 (Day 6) ✅
> **目标**:提升系统响应速度和稳定性
- [x] 视频预压缩优化 (1080p → 720p 自动适配)
- [x] 进度更新细化 (实时反馈)
- [x] **常驻模型服务** (Persistent Server, 0s 加载)
- [x] **GPU 并发控制** (串行队列防崩溃)
### 阶段十一:社交媒体发布完善 (Day 7) ✅
> **目标**:实现全自动扫码登录和多平台发布
- [x] QR码自动登录 (Playwright headless + Stealth)
- [x] 多平台上传器架构 (B站/抖音/小红书)
- [x] Cookie 自动管理
- [x] 定时发布功能
### 阶段十二:用户体验优化 (Day 8) ✅
> **目标**:提升文件管理和历史记录功能
- [x] 文件名保留 (时间戳前缀 + 原始名称)
- [x] 视频持久化 (历史视频列表 API)
- [x] 素材/视频删除功能
### 阶段十三:发布模块优化 (Day 9) ✅
> **目标**:代码质量优化 + 发布功能验证
- [x] B站/抖音登录+发布验证通过
- [x] 资源清理保障 (try-finally)
- [x] 超时保护 (消除无限循环)
- [x] 完整类型提示
### 阶段十四:用户认证系统 (Day 9) ✅
> **目标**:实现安全、隔离的多用户认证体系
- [x] Supabase 云数据库集成 (本地自托管)
- [x] JWT + HttpOnly Cookie 认证架构
- [x] 用户表与权限表设计 (RLS 准备)
- [x] 认证部署文档 (Docs/SUPABASE_DEPLOY.md)
### 阶段十五:部署稳定性优化 (Day 9) ✅
> **目标**:确保生产环境服务长期稳定
- [x] 依赖冲突修复 (bcrypt)
- [x] 前端构建修复 (Production Build)
- [x] PM2 进程守护配置
- [x] 部署手册更新 (Docs/DEPLOY_MANUAL.md)
### 阶段十六HTTPS 全栈部署 (Day 10) ✅
> **目标**:实现安全的公网 HTTPS 访问
- [x] 阿里云 Nginx 反向代理配置
- [x] Let's Encrypt SSL 证书集成
- [x] Supabase 自托管部署 (Docker)
- [x] 端口冲突解决 (3003/8008/8444)
- [x] Basic Auth 管理后台保护
### 阶段十七:声音克隆功能集成 (Day 13) ✅
> **目标**:实现用户自定义声音克隆能力
- [x] Qwen3-TTS HTTP 服务 (独立 FastAPI端口 8009)
- [x] 声音克隆服务封装 (voice_clone_service.py)
- [x] 参考音频管理 API (上传/列表/删除)
- [x] 前端 TTS 模式选择 UI
- [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)
### 阶段十九:深度性能优化与服务守护 (Day 16) ✅
> **目标**:提升系统响应速度与服务稳定性
- [x] Flash Attention 2 集成 (Qwen3-TTS 加速 5x)
- [x] LatentSync 性能调优 (OMP 线程限制 + 原生 Flash Attn)
- [x] Watchdog 服务守护 (自动重启僵死服务)
- [x] 文档体系更新 (部署手册与运维指南)
---
## 项目目录结构 (最终)
---
## 开发时间估算
| 阶段 | 预计时间 | 说明 |
|------|----------|------|
| 阶段一 | 2-3 天 | 环境搭建 + 效果验证 |
| 阶段二 | 3-4 天 | 后端 API 开发 |
| 阶段三 | 3-4 天 | 前端 UI 开发 |
| 阶段四 | 2 天 | 社交发布集成 |
| 阶段五 | 按需 | 持续优化 |
**总计**:约 10-13 天可完成 MVP
---
### 阶段二十:代码质量与安全优化 (Day 20) ✅
> **目标**:全面提升代码健壮性、安全性与配置灵活性
- [x] **安全性修复**:硬编码 Cookie/Key 移除ffprobe 安全调用,日志脱敏
- [x] **配置化改造**存储路径、CORS、录屏开关全面环境变量化
- [x] **性能优化**API 异步改造 (httpx/asyncio),大文件流式上传
- [x] **构建优化**Remotion 预编译,统一启动脚本 `run_backend.sh`
---
## 验证计划
### 阶段一验证
1. 运行 `test_pipeline.py` 脚本
2. 检查生成视频的唇形同步效果
3. 确认音画同步
### 阶段二验证
1. 使用 Postman/curl 测试所有 API 端点
2. 验证任务队列正常工作
3. 检查视频生成完整流程
### 阶段三验证
1. 在浏览器中完成完整操作流程
2. 验证上传、生成、下载功能
3. 检查响应式布局
### 阶段四验证
1. 发布一个测试视频到抖音
2. 验证定时发布功能
3. 检查发布状态同步
---
## 硬件要求
| 配置 | 最低要求 | 推荐配置 |
|------|----------|----------|
| **GPU** | NVIDIA GTX 1060 6GB | RTX 3060 12GB+ |
| **内存** | 16GB | 32GB |
| **存储** | 100GB SSD | 500GB SSD |
| **CUDA** | 11.7+ | 12.0+ |
---
## 下一步行动
1. **确认你的 GPU 配置** - MuseTalk 需要 NVIDIA GPU
2. **选择开发起点** - 从阶段一开始验证效果
3. **确定项目位置** - 在哪个目录创建项目
---
> [!IMPORTANT]
> 请确认以上计划是否符合你的需求,有任何需要调整的地方请告诉我。

View File

@@ -1,8 +1,8 @@
# ViGent2 开发任务清单 (Task Log)
**项目**: ViGent2 数字人口播视频生成系统
**进度**: 100% (Day 20 - 代码质量与安全优化)
**更新时间**: 2026-02-07
**项目**: ViGent2 数字人口播视频生成系统
**进度**: 100% (Day 21 - 缺陷修复与持久化回归治理)
**更新时间**: 2026-02-08
---
@@ -10,12 +10,19 @@
> 这里记录了每一天的核心开发内容与 milestone。
### Day 20: 代码质量与安全优化 (Current)
### Day 21: 缺陷修复与持久化回归治理 (Current)
- [x] **Remotion 崩溃容错**: 渲染进程 SIGABRT 退出时检查输出文件,避免误判失败导致标题/字幕丢失。
- [x] **首页作品选择持久化**: 修复 `fetchGeneratedVideos` 无条件覆盖恢复值的问题,新增 `preferVideoId` 参数控制选中逻辑。
- [x] **发布页作品选择持久化**: 根因为签名 URL 不稳定,全面改用 `video.id` 替代 `path` 进行选择/持久化/比较。
- [x] **预取缓存补全**: 首页预取发布页数据时加入 `id` 字段,确保缓存数据可用于持久化匹配。
### Day 20: 代码质量与安全优化
- [x] **功能性修复**: LatentSync 回退逻辑、任务状态接口认证、User 类型统一。
- [x] **性能优化**: N+1 查询修复、视频上传流式处理、httpx 异步替换、GLM 异步包装。
- [x] **安全修复**: 硬编码 Cookie 配置化、日志敏感信息脱敏、ffprobe 安全调用、CORS 配置化。
- [x] **配置优化**: 存储路径环境变量化、Remotion 预编译加速、LatentSync 绝对路径。
- [x] **文档更新**: 更新 Doc_Rules.md 清单,补齐后端与部署文档;更新 SUBTITLE_DEPLOY.md, FRONTEND_DEV.md, implementation_plan.md。
- [x] **缺陷修复**: 修复 Remotion 路径解析、发布页持久化竞态、首页选中回归、素材闭包陷阱。
### Day 19: 自动发布稳定性与发布体验优化 🚀
- [x] **抖音发布稳定性**: 上传入口、封面流程、发布重试、登录失效识别与网络失败快速返回全面增强。

View File

@@ -15,24 +15,24 @@
## ✨ 功能特性
### 核心能力
- 🎬 **高清唇形同步** - LatentSync 1.6 驱动512×512 高分辨率 Latent Diffusion 模型。
- 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音) 和 **Qwen3-TTS** (3秒极速声音克隆)。
- 📝 **智能字幕** - 集成 faster-whisper + Remotion自动生成逐字高亮 (卡拉OK效果) 字幕。
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
- 🖼️ **作品预览一致性** - 标题/字幕预览按素材分辨率缩放,效果更接近成片。
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。
- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
### 核心能力
- 🎬 **高清唇形同步** - LatentSync 1.6 驱动512×512 高分辨率 Latent Diffusion 模型。
- 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音) 和 **Qwen3-TTS** (3秒极速声音克隆)。
- 📝 **智能字幕** - 集成 faster-whisper + Remotion自动生成逐字高亮 (卡拉OK效果) 字幕。
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
- 🖼️ **作品预览一致性** - 标题/字幕预览按素材分辨率缩放,效果更接近成片。
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。
- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
### 平台化功能
- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
- 📸 **发布结果可视化** - 抖音/微信视频号发布成功后返回截图,发布页结果卡片可直接查看。
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。
### 平台化功能
- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
- 📸 **发布结果可视化** - 抖音/微信视频号发布成功后返回截图,发布页结果卡片可直接查看。
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。
---
@@ -40,7 +40,7 @@
| 领域 | 核心技术 | 说明 |
|------|----------|------|
| **前端** | Next.js 16 | TypeScript, TailwindCSS, SWR |
| **前端** | Next.js 16 | TypeScript, TailwindCSS, SWR |
| **后端** | FastAPI | Python 3.10, AsyncIO, PM2 |
| **数据库** | Supabase | PostgreSQL, Storage (本地/S3), Auth |
| **唇形同步** | LatentSync 1.6 | PyTorch 2.5, Diffusers, DeepCache |
@@ -58,11 +58,11 @@
- **[部署手册 (DEPLOY_MANUAL.md)](Docs/DEPLOY_MANUAL.md)** - 👈 **部署请看这里**!包含完整的环境搭建步骤。
- [参考音频服务部署 (QWEN3_TTS_DEPLOY.md)](Docs/QWEN3_TTS_DEPLOY.md) - 声音克隆模型部署指南。
- [LatentSync 部署指南](models/LatentSync/DEPLOY.md) - 唇形同步模型独立部署。
- [用户认证部署 (AUTH_DEPLOY.md)](Docs/AUTH_DEPLOY.md) - Supabase 与 Auth 系统配置。
- [Supabase 部署指南 (SUPABASE_DEPLOY.md)](Docs/SUPABASE_DEPLOY.md) - Supabase 与认证系统配置。
### 开发文档
- [后端开发指南](Docs/BACKEND_README.md) - 接口规范与开发流程。
- [后端开发规范](Docs/BACKEND_DEV.md) - 分层约定与开发习惯。
### 开发文档
- [后端开发指南](Docs/BACKEND_README.md) - 接口规范与开发流程。
- [后端开发规范](Docs/BACKEND_DEV.md) - 分层约定与开发习惯。
- [前端开发指南](Docs/FRONTEND_DEV.md) - UI 组件与页面规范。
- [开发日志 (DevLogs)](Docs/DevLogs/) - 每日开发进度与技术决策记录。
@@ -74,9 +74,11 @@
ViGent2/
├── backend/ # FastAPI 后端服务
│ ├── app/ # 核心业务逻辑
│ ├── scripts/ # 运维脚本 (Watchdog 等)
── tests/ # 测试用例
│ ├── assets/ # 字体 / 样式 / BGM
── user_data/ # 用户隔离数据 (Cookie 等)
│ └── scripts/ # 运维脚本 (Watchdog 等)
├── frontend/ # Next.js 前端应用
├── remotion/ # Remotion 视频渲染 (标题/字幕合成)
├── models/ # AI 模型仓库
│ ├── LatentSync/ # 唇形同步服务
│ └── Qwen3-TTS/ # 声音克隆服务

View File

@@ -15,7 +15,6 @@ DEFAULT_TTS_VOICE=zh-CN-YunxiNeural
# GPU 选择 (0=第一块GPU, 1=第二块GPU)
LATENTSYNC_GPU_ID=1
# 使用本地模式 (true) 或远程 API (false)
# 使用本地模式 (true) 或远程 API (false)
LATENTSYNC_LOCAL=true
@@ -67,6 +66,10 @@ ADMIN_PASSWORD=lam1988324
GLM_API_KEY=32440cd3f3444d1f8fe721304acea8bd.YXNLrk7eIJMKcg4t
GLM_MODEL=glm-4.7-flash
# =============== Supabase Storage 本地路径 ===============
# 确保存储卷映射正确,避免硬编码路径
SUPABASE_STORAGE_LOCAL_PATH=/home/rongye/ProgramFiles/Supabase/volumes/storage/stub/stub
# =============== 抖音视频下载 Cookie ===============
# 用于从抖音 URL 提取视频文案功能,会过期需要定期更新
DOUYIN_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

View File

@@ -1,10 +0,0 @@
from . import admin
from . import ai
from . import assets
from . import auth
from . import login_helper
from . import materials
from . import publish
from . import ref_audios
from . import tools
from . import videos

View File

@@ -1 +0,0 @@
from app.modules.admin.router import router

View File

@@ -1 +0,0 @@
from app.modules.ai.router import router

View File

@@ -1 +0,0 @@
from app.modules.assets.router import router

View File

@@ -1 +0,0 @@
from app.modules.auth.router import router

View File

@@ -1 +0,0 @@
from app.modules.login_helper.router import router

View File

@@ -1 +0,0 @@
from app.modules.materials.router import router

View File

@@ -1 +0,0 @@
from app.modules.publish.router import router

View File

@@ -1 +0,0 @@
from app.modules.ref_audios.router import router

View File

@@ -1 +0,0 @@
from app.modules.tools.router import router

View File

@@ -1 +0,0 @@
from app.modules.videos.router import router

View File

@@ -30,7 +30,7 @@ class Settings(BaseSettings):
# Douyin Playwright 配置
DOUYIN_HEADLESS_MODE: str = "headless-new"
DOUYIN_USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
DOUYIN_USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36"
DOUYIN_LOCALE: str = "zh-CN"
DOUYIN_TIMEZONE_ID: str = "Asia/Shanghai"
DOUYIN_CHROME_PATH: str = "/usr/bin/google-chrome"

View File

@@ -4,7 +4,17 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.core import config
from app.core.response import error_response
from app.api import materials, videos, publish, login_helper, auth, admin, ref_audios, ai, tools, assets
# 直接从 modules 导入路由,消除 api 转发层
from app.modules.materials.router import router as materials_router
from app.modules.videos.router import router as videos_router
from app.modules.publish.router import router as publish_router
from app.modules.login_helper.router import router as login_helper_router
from app.modules.auth.router import router as auth_router
from app.modules.admin.router import router as admin_router
from app.modules.ref_audios.router import router as ref_audios_router
from app.modules.ai.router import router as ai_router
from app.modules.tools.router import router as tools_router
from app.modules.assets.router import router as assets_router
from loguru import logger
import os
@@ -104,16 +114,16 @@ app.mount("/uploads", StaticFiles(directory=str(settings.UPLOAD_DIR)), name="upl
app.mount("/assets", StaticFiles(directory=str(settings.ASSETS_DIR)), name="assets")
# 注册路由
app.include_router(materials.router, prefix="/api/materials", tags=["Materials"])
app.include_router(videos.router, prefix="/api/videos", tags=["Videos"])
app.include_router(publish.router, prefix="/api/publish", tags=["Publish"])
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.include_router(assets.router, prefix="/api/assets", tags=["Assets"])
app.include_router(materials_router, prefix="/api/materials", tags=["Materials"])
app.include_router(videos_router, prefix="/api/videos", tags=["Videos"])
app.include_router(publish_router, prefix="/api/publish", tags=["Publish"])
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.include_router(assets_router, prefix="/api/assets", tags=["Assets"])
@app.on_event("startup")

View File

@@ -15,5 +15,7 @@ class GenerateRequest(BaseModel):
title_style_id: Optional[str] = None
subtitle_font_size: Optional[int] = None
title_font_size: Optional[int] = None
title_top_margin: Optional[int] = None
subtitle_bottom_margin: Optional[int] = None
bgm_id: Optional[str] = None
bgm_volume: Optional[float] = 0.2

View File

@@ -216,6 +216,16 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
title_style = {}
title_style["font_size"] = int(req.title_font_size)
if req.title_top_margin is not None and req.title:
if title_style is None:
title_style = {}
title_style["top_margin"] = int(req.title_top_margin)
if req.subtitle_bottom_margin is not None and req.enable_subtitles:
if subtitle_style is None:
subtitle_style = {}
subtitle_style["bottom_margin"] = int(req.subtitle_bottom_margin)
if use_remotion:
subtitle_style = prepare_style_for_remotion(
subtitle_style,

View File

@@ -17,20 +17,20 @@ from app.services.storage import storage_service
# Import platform uploaders
from .uploader.bilibili_uploader import BilibiliUploader
from .uploader.douyin_uploader import DouyinUploader
from .uploader.xiaohongshu_uploader import XiaohongshuUploader
from .uploader.weixin_uploader import WeixinUploader
from .uploader.xiaohongshu_uploader import XiaohongshuUploader
from .uploader.weixin_uploader import WeixinUploader
class PublishService:
"""Social media publishing service (with user isolation)"""
# 支持的平台配置
PLATFORMS: Dict[str, Dict[str, Any]] = {
"douyin": {"name": "抖音", "url": "https://creator.douyin.com/", "enabled": True},
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": True},
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame", "enabled": True},
"xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/", "enabled": True},
}
PLATFORMS: Dict[str, Dict[str, Any]] = {
"douyin": {"name": "抖音", "url": "https://creator.douyin.com/", "enabled": True},
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": True},
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame", "enabled": True},
"xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/", "enabled": True},
}
def __init__(self) -> None:
# 存储活跃的登录会话,用于跟踪登录状态
@@ -175,36 +175,36 @@ class PublishService:
tid=kwargs.get('tid', 122),
copyright=kwargs.get('copyright', 1)
)
elif platform == "douyin":
uploader = DouyinUploader(
title=title,
file_path=local_video_path,
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
description=description,
user_id=user_id,
)
elif platform == "xiaohongshu":
uploader = XiaohongshuUploader(
title=title,
file_path=local_video_path,
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
description=description
)
elif platform == "weixin":
uploader = WeixinUploader(
title=title,
file_path=local_video_path,
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
description=description,
user_id=user_id,
)
else:
elif platform == "douyin":
uploader = DouyinUploader(
title=title,
file_path=local_video_path,
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
description=description,
user_id=user_id,
)
elif platform == "xiaohongshu":
uploader = XiaohongshuUploader(
title=title,
file_path=local_video_path,
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
description=description
)
elif platform == "weixin":
uploader = WeixinUploader(
title=title,
file_path=local_video_path,
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
description=description,
user_id=user_id,
)
else:
logger.warning(f"[发布] {platform} 上传功能尚未实现")
return {
"success": False,
@@ -236,30 +236,38 @@ class PublishService:
async def login(self, platform: str, user_id: Optional[str] = None) -> Dict[str, Any]:
"""
启动QR码登录流程
Args:
platform: 平台 ID
user_id: 用户 ID (用于 Cookie 隔离)
Returns:
dict: 包含二维码base64图片
"""
if platform not in self.PLATFORMS:
return {"success": False, "message": "不支持的平台"}
try:
from .qr_login_service import QRLoginService
# 获取用户专属的 Cookie 目录
cookies_dir = self._get_cookies_dir(user_id)
# 清理旧的活跃会话(避免残留会话干扰新登录)
session_key = self._get_session_key(platform, user_id)
if session_key in self.active_login_sessions:
old_service = self.active_login_sessions.pop(session_key)
try:
await old_service._cleanup()
except Exception:
pass
# 创建QR登录服务
qr_service = QRLoginService(platform, cookies_dir)
# 存储活跃会话 (带用户隔离)
session_key = self._get_session_key(platform, user_id)
self.active_login_sessions[session_key] = qr_service
# 启动登录并获取二维码
result = await qr_service.start_login()
@@ -273,27 +281,28 @@ class PublishService:
}
def get_login_session_status(self, platform: str, user_id: Optional[str] = None) -> Dict[str, Any]:
"""获取活跃登录会话的状态"""
"""获取活跃登录会话的状态(仅用于扫码轮询)"""
session_key = self._get_session_key(platform, user_id)
# 1. 如果有活跃的扫码会话,优先检查它
# 只检查活跃的扫码会话,不检查 Cookie 文件
# Cookie 文件检查会导致"重新登录"时误判为已登录
if session_key in self.active_login_sessions:
qr_service = self.active_login_sessions[session_key]
status = qr_service.get_login_status()
# 如果登录成功且Cookie已保存清理会话
if status["success"] and status["cookies_saved"]:
del self.active_login_sessions[session_key]
return {"success": True, "message": "登录成功"}
return {"success": False, "message": "等待扫码..."}
# 2. 检查本地Cookie文件是否存在
cookie_file = self._get_cookie_path(platform, user_id)
if cookie_file.exists():
return {"success": True, "message": "已登录 (历史状态)"}
return {"success": False, "message": "未登录"}
# 刷脸验证:传递新二维码给前端
result: Dict[str, Any] = {"success": False, "message": "等待扫码..."}
if status.get("face_verify_qr"):
result["face_verify_qr"] = status["face_verify_qr"]
return result
# 没有活跃会话 → 返回 False前端不应在无会话时轮询
return {"success": False, "message": "无活跃登录会话"}
def logout(self, platform: str, user_id: Optional[str] = None) -> Dict[str, Any]:
"""

View File

@@ -1,59 +1,67 @@
"""
QR码自动登录服务
后端Playwright无头模式获取二维码前端扫码后自动保存Cookie
"""
"""
QR码自动登录服务
后端Playwright无头模式获取二维码前端扫码后自动保存Cookie
"""
import asyncio
import time
import base64
import json
from pathlib import Path
import base64
import json
from pathlib import Path
from typing import Optional, Dict, Any, List, Sequence, Mapping, Union
from playwright.async_api import async_playwright, Page, Frame, BrowserContext, Browser, Playwright as PW
from loguru import logger
from app.core.config import settings
class QRLoginService:
"""QR码登录服务"""
# 登录监控超时 (秒)
LOGIN_TIMEOUT = 120
"""QR码登录服务"""
# 登录监控超时 (秒)
LOGIN_TIMEOUT = 180
def __init__(self, platform: str, cookies_dir: Path) -> None:
self.platform = platform
self.cookies_dir = cookies_dir
self.qr_code_image: Optional[str] = None
self.login_success: bool = False
self.cookies_data: Optional[Dict[str, Any]] = None
# Playwright 资源 (手动管理生命周期)
self.playwright: Optional[PW] = None
self.browser: Optional[Browser] = None
self.context: Optional[BrowserContext] = None
# 每个平台使用多个选择器 (使用逗号分隔Playwright会同时等待它们)
self.cookies_dir = cookies_dir
self.qr_code_image: Optional[str] = None
self.login_success: bool = False
self.cookies_data: Optional[Dict[str, Any]] = None
# Playwright 资源 (手动管理生命周期)
self.playwright: Optional[PW] = None
self.browser: Optional[Browser] = None
self.context: Optional[BrowserContext] = None
# 抖音 check_qrconnect API 响应拦截
self._qr_api_confirmed: bool = False
self._qr_redirect_url: Optional[str] = None
self._douyin_needs_verify: bool = False # 需要APP验证
# 刷脸验证二维码(点击刷脸后页面展示新二维码,需要前端再次展示给用户)
self._face_verify_qr: Optional[str] = None # base64 截图
# 每个平台使用多个选择器 (使用逗号分隔Playwright会同时等待它们)
self.platform_configs = {
"bilibili": {
"url": "https://passport.bilibili.com/login",
"qr_selectors": [
"div[class*='qrcode'] canvas", # 常见canvas二维码
"div[class*='qrcode'] img", # 常见图片二维码
".qrcode-img img", # 旧版
".login-scan-box img", # 扫码框
"div[class*='scan'] img"
],
"success_indicator": "https://www.bilibili.com/"
},
"douyin": {
"url": "https://creator.douyin.com/",
"qr_selectors": [
".qrcode img", # 优先尝试
"img[alt='qrcode']",
"canvas[class*='qr']",
"img[src*='qr']"
],
"success_indicator": "https://creator.douyin.com/creator-micro"
},
"bilibili": {
"url": "https://passport.bilibili.com/login",
"qr_selectors": [
"div[class*='qrcode'] canvas", # 常见canvas二维码
"div[class*='qrcode'] img", # 常见图片二维码
".qrcode-img img", # 旧版
".login-scan-box img", # 扫码框
"div[class*='scan'] img"
],
"success_indicator": "https://www.bilibili.com/"
},
"douyin": {
"url": "https://creator.douyin.com/",
"qr_selectors": [
".qrcode img", # 优先尝试
"img[alt='qrcode']",
"canvas[class*='qr']",
"img[src*='qr']"
],
"success_indicator": "https://creator.douyin.com/creator-micro"
},
"xiaohongshu": {
"url": "https://creator.xiaohongshu.com/",
"qr_selectors": [
@@ -79,10 +87,15 @@ class QRLoginService:
}
def _resolve_headless_mode(self) -> str:
if self.platform != "weixin":
return "headless"
mode = (settings.WEIXIN_HEADLESS_MODE or "").strip().lower()
return mode or "headful"
# 抖音和微信使用 headful 模式xvfb 虚拟显示),避免反爬检测
# 其他平台使用 headless-new
if self.platform == "douyin":
mode = (settings.DOUYIN_HEADLESS_MODE or "").strip().lower()
return mode or "headful"
if self.platform == "weixin":
mode = (settings.WEIXIN_HEADLESS_MODE or "").strip().lower()
return mode or "headful"
return "headless-new"
def _is_square_bbox(self, bbox: Optional[Dict[str, float]], min_side: int = 100) -> bool:
if not bbox:
@@ -158,20 +171,20 @@ class QRLoginService:
except Exception:
continue
return None
async def start_login(self) -> Dict[str, Any]:
"""
启动登录流程
Returns:
dict: 包含二维码base64和状态
"""
if self.platform not in self.platform_configs:
return {"success": False, "message": "不支持的平台"}
config = self.platform_configs[self.platform]
try:
async def start_login(self) -> Dict[str, Any]:
"""
启动登录流程
Returns:
dict: 包含二维码base64和状态
"""
if self.platform not in self.platform_configs:
return {"success": False, "message": "不支持的平台"}
config = self.platform_configs[self.platform]
try:
# 1. 启动 Playwright (不使用 async with手动管理生命周期)
self.playwright = await async_playwright().start()
@@ -180,46 +193,66 @@ class QRLoginService:
launch_args = [
'--disable-blink-features=AutomationControlled',
'--no-sandbox',
'--disable-dev-shm-usage'
'--disable-dev-shm-usage',
]
if headless and mode in ("new", "headless-new", "headless_new"):
launch_args.append("--headless=new")
if not headless:
# headful 模式下 xvfb 没有 GPU需要软件渲染
launch_args.extend([
'--use-gl=swiftshader',
'--disable-gpu',
])
# Stealth模式启动浏览器
launch_options: Dict[str, Any] = {
"headless": headless,
"args": launch_args,
}
if self.platform == "weixin":
# 根据平台选择对应的浏览器配置
if self.platform == "douyin":
chrome_path = (settings.DOUYIN_CHROME_PATH or "").strip()
browser_channel = (settings.DOUYIN_BROWSER_CHANNEL or "").strip()
user_agent = settings.DOUYIN_USER_AGENT
locale = settings.DOUYIN_LOCALE
timezone_id = settings.DOUYIN_TIMEZONE_ID
elif self.platform == "weixin":
chrome_path = (settings.WEIXIN_CHROME_PATH or "").strip()
if chrome_path:
if Path(chrome_path).exists():
launch_options["executable_path"] = chrome_path
else:
logger.warning(f"[weixin] WEIXIN_CHROME_PATH not found: {chrome_path}")
else:
channel = (settings.WEIXIN_BROWSER_CHANNEL or "").strip()
if channel:
launch_options["channel"] = channel
browser_channel = (settings.WEIXIN_BROWSER_CHANNEL or "").strip()
user_agent = settings.WEIXIN_USER_AGENT
locale = settings.WEIXIN_LOCALE
timezone_id = settings.WEIXIN_TIMEZONE_ID
else:
# B站、小红书等使用通用默认值
chrome_path = (settings.WEIXIN_CHROME_PATH or "").strip()
browser_channel = ""
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
locale = "zh-CN"
timezone_id = "Asia/Shanghai"
if chrome_path and Path(chrome_path).exists():
launch_options["executable_path"] = chrome_path
elif browser_channel:
launch_options["channel"] = browser_channel
self.browser = await self.playwright.chromium.launch(**launch_options)
# 配置真实浏览器特征
self.context = await self.browser.new_context(
viewport={'width': 1920, 'height': 1080},
user_agent=settings.WEIXIN_USER_AGENT,
locale=settings.WEIXIN_LOCALE,
timezone_id=settings.WEIXIN_TIMEZONE_ID
user_agent=user_agent,
locale=locale,
timezone_id=timezone_id
)
page = await self.context.new_page()
# 注入stealth.js
stealth_path = Path(__file__).parent / 'uploader' / 'stealth.min.js'
if stealth_path.exists():
await page.add_init_script(path=str(stealth_path))
logger.debug(f"[{self.platform}] Stealth模式已启用")
page = await self.context.new_page()
# 注入stealth.js
stealth_path = Path(__file__).parent / 'uploader' / 'stealth.min.js'
if stealth_path.exists():
await page.add_init_script(path=str(stealth_path))
logger.debug(f"[{self.platform}] Stealth模式已启用")
urls_to_try = [config["url"]]
if self.platform == "weixin":
urls_to_try = [
@@ -228,6 +261,60 @@ class QRLoginService:
]
qr_image = None
# 抖音:拦截 QR 登录相关 API 响应,检测登录成功
if self.platform == "douyin":
async def _on_douyin_qr_response(response):
try:
url = response.url or ""
if "check_qrconnect" not in url.lower():
return
body = None
try:
body = await response.json()
except Exception:
try:
text = await response.text()
import re as _re
m = _re.search(r'\{.*\}', text, _re.DOTALL)
if m:
body = json.loads(m.group())
except Exception:
pass
if not body:
return
data = body.get("data", {})
redirect_url = data.get("redirect_url", "")
status_val = data.get("status", "")
desc = data.get("description", body.get("description", ""))
logger.info(
f"[douyin][qr-poll] status={status_val} "
f"desc={desc[:60]} redirect={'yes' if redirect_url else 'no'}"
)
# 检测需要APP验证
if "完成验证" in desc or "验证后" in desc:
self._douyin_needs_verify = True
logger.warning("[douyin] 需要APP验证")
if self._qr_api_confirmed:
return
# 检测登录成功:出现 redirect_url
if redirect_url:
self._qr_redirect_url = redirect_url
self._qr_api_confirmed = True
logger.success(f"[douyin] 登录确认redirect_url={redirect_url[:120]}")
except Exception as e:
logger.debug(f"[douyin][qr-poll] error: {e}")
page.on("response", _on_douyin_qr_response)
for url in urls_to_try:
logger.info(f"[{self.platform}] 打开登录页: {url}")
wait_until = "domcontentloaded" if self.platform == "weixin" else "networkidle"
@@ -240,72 +327,94 @@ class QRLoginService:
qr_image = await self._extract_qr_code(page, config["qr_selectors"])
if qr_image:
break
if not qr_image:
await self._cleanup()
return {"success": False, "message": "未找到二维码"}
logger.info(f"[{self.platform}] 二维码已获取,等待扫码...")
# 启动后台监控任务 (浏览器保持开启)
asyncio.create_task(
self._monitor_login_status(page, config["success_indicator"])
)
return {
"success": True,
"qr_code": qr_image,
"message": "请扫码登录"
}
except Exception as e:
logger.exception(f"[{self.platform}] 启动登录失败: {e}")
await self._cleanup()
return {"success": False, "message": f"启动失败: {str(e)}"}
async def _extract_qr_code(self, page: Page, selectors: List[str]) -> Optional[str]:
"""
提取二维码图片 (优化策略顺序)
根据日志分析抖音和B站使用 Text 策略成功率最高
"""
qr_element = None
# 针对抖音和B站优先使用 Text 策略 (成功率最高,速度最快)
if self.platform in ("douyin", "bilibili"):
# 尝试最多2次 (首次 + 1次重试)
for attempt in range(2):
if attempt > 0:
logger.info(f"[{self.platform}] 等待页面加载后重试...")
await asyncio.sleep(2)
# 策略1: Text (优先,成功率最高)
qr_element = await self._try_text_strategy(page)
if qr_element:
try:
screenshot = await qr_element.screenshot()
return base64.b64encode(screenshot).decode()
except Exception as e:
logger.warning(f"[{self.platform}] Text策略截图失败: {e}")
qr_element = None
# 策略2: CSS (备用)
if not qr_element:
try:
combined_selector = ", ".join(selectors)
logger.debug(f"[{self.platform}] 策略2(CSS): 开始等待...")
# 增加超时到5秒抖音页面加载较慢
el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
if el:
logger.info(f"[{self.platform}] 策略2(CSS): 匹配成功")
screenshot = await el.screenshot()
return base64.b64encode(screenshot).decode()
except Exception as e:
logger.warning(f"[{self.platform}] 策略2(CSS) 失败: {e}")
# 如果已成功,退出循环
if qr_element:
break
else:
if not qr_image:
await self._cleanup()
return {"success": False, "message": "未找到二维码"}
logger.info(f"[{self.platform}] 二维码已获取,等待扫码...")
# 启动后台监控任务 (浏览器保持开启)
asyncio.create_task(
self._monitor_login_status(page, config["success_indicator"])
)
return {
"success": True,
"qr_code": qr_image,
"message": "请扫码登录"
}
except Exception as e:
logger.exception(f"[{self.platform}] 启动登录失败: {e}")
await self._cleanup()
return {"success": False, "message": f"启动失败: {str(e)}"}
async def _extract_qr_code(self, page: Page, selectors: List[str]) -> Optional[str]:
"""
提取二维码图片 (优化策略顺序)
抖音CSS 优先Text 策略每次超时 15 秒)
B站Text 优先
其他CSS -> Text
"""
qr_element = None
if self.platform == "douyin":
# 抖音CSS 优先Text 备用CSS 成功率高且快)
for attempt in range(2):
if attempt > 0:
logger.info(f"[{self.platform}] 等待页面加载后重试...")
await asyncio.sleep(2)
# 策略1: CSS (快速)
try:
combined_selector = ", ".join(selectors)
logger.debug(f"[{self.platform}] 策略CSS: 开始等待...")
el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
if el:
logger.info(f"[{self.platform}] 策略CSS: 匹配成功")
screenshot = await el.screenshot()
return base64.b64encode(screenshot).decode()
except Exception as e:
logger.warning(f"[{self.platform}] 策略CSS 失败: {e}")
# 策略2: Text (备用)
qr_element = await self._try_text_strategy(page)
if qr_element:
try:
screenshot = await qr_element.screenshot()
return base64.b64encode(screenshot).decode()
except Exception as e:
logger.warning(f"[{self.platform}] Text策略截图失败: {e}")
elif self.platform == "bilibili":
# B站Text 优先
for attempt in range(2):
if attempt > 0:
logger.info(f"[{self.platform}] 等待页面加载后重试...")
await asyncio.sleep(2)
qr_element = await self._try_text_strategy(page)
if qr_element:
try:
screenshot = await qr_element.screenshot()
return base64.b64encode(screenshot).decode()
except Exception as e:
logger.warning(f"[{self.platform}] Text策略截图失败: {e}")
qr_element = None
if not qr_element:
try:
combined_selector = ", ".join(selectors)
logger.debug(f"[{self.platform}] 策略CSS: 开始等待...")
el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
if el:
logger.info(f"[{self.platform}] 策略CSS: 匹配成功")
screenshot = await el.screenshot()
return base64.b64encode(screenshot).decode()
except Exception as e:
logger.warning(f"[{self.platform}] 策略CSS 失败: {e}")
else:
# 其他平台 (小红书/微信等):保持原顺序 CSS -> Text
# 策略1: CSS 选择器
try:
@@ -328,36 +437,31 @@ class QRLoginService:
logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功")
except Exception as e:
logger.warning(f"[{self.platform}] 策略1(CSS) 失败: {e}")
# 策略2: Text
# 策略2: Text
if not qr_element:
qr_element = await self._try_text_strategy(page)
if not qr_element and self.platform == "weixin":
qr_element = await self._try_text_strategy_in_frames(page)
# 如果找到元素,截图返回
if qr_element:
try:
screenshot = await qr_element.screenshot()
return base64.b64encode(screenshot).decode()
except Exception as e:
logger.error(f"[{self.platform}] 截图失败: {e}")
# 所有策略失败
logger.error(f"[{self.platform}] 所有QR码提取策略失败")
# 保存调试截图
debug_dir = Path(__file__).parent.parent.parent / 'debug_screenshots'
debug_dir.mkdir(exist_ok=True)
await page.screenshot(path=str(debug_dir / f"{self.platform}_debug.png"))
return None
# 如果找到元素,截图返回
if qr_element:
try:
screenshot = await qr_element.screenshot()
return base64.b64encode(screenshot).decode()
except Exception as e:
logger.error(f"[{self.platform}] 截图失败: {e}")
# 所有策略失败
logger.error(f"[{self.platform}] 所有QR码提取策略失败")
return None
async def _try_text_strategy(self, page: Union[Page, Frame]) -> Optional[Any]:
"""基于文本查找二维码图片"""
try:
logger.debug(f"[{self.platform}] 策略Text: 开始搜索...")
"""基于文本查找二维码图片"""
try:
logger.debug(f"[{self.platform}] 策略Text: 开始搜索...")
keywords = [
"扫码登录",
"二维码",
@@ -368,138 +472,265 @@ class QRLoginService:
"请使用微信扫码",
"视频号"
]
for kw in keywords:
try:
text_el = page.get_by_text(kw, exact=False).first
await text_el.wait_for(state="visible", timeout=2000)
# 向上查找图片
parent = text_el
for _ in range(5):
parent = parent.locator("..")
for kw in keywords:
try:
text_el = page.get_by_text(kw, exact=False).first
await text_el.wait_for(state="visible", timeout=2000)
# 向上查找图片
parent = text_el
for _ in range(5):
parent = parent.locator("..")
candidates = parent.locator("img, canvas")
min_side = 120 if self.platform == "weixin" else 100
best = await self._pick_best_candidate(candidates, min_side=min_side)
if best:
logger.info(f"[{self.platform}] 策略Text: 成功")
return best
except Exception:
continue
except Exception as e:
logger.warning(f"[{self.platform}] 策略Text 失败: {e}")
return None
async def _monitor_login_status(self, page: Page, success_url: str):
"""监控登录状态"""
try:
logger.info(f"[{self.platform}] 开始监控登录状态...")
except Exception:
continue
except Exception as e:
logger.warning(f"[{self.platform}] 策略Text 失败: {e}")
return None
async def _monitor_login_status(self, page: Page, success_url: str):
"""监控登录状态 — 简洁版
策略:
1. 监听页面 URL 变化和 session cookie 出现(通用,适用所有平台)
2. 抖音特殊:如果 API 拦截到 redirect_url直接导航过去拿 cookie
3. 抖音特殊如果需要APP验证且JS轮询停了等用户验证完后
用 page.goto 重新访问首页,让服务器分配 session
"""
try:
logger.info(f"[{self.platform}] 开始监控登录状态...")
key_cookies = {
"bilibili": ["SESSDATA"],
"douyin": ["sessionid"],
"douyin": ["sessionid", "sessionid_ss", "sid_guard", "sid_tt", "uid_tt"],
"xiaohongshu": ["web_session"],
"weixin": [
"wxuin",
"wxsid",
"pass_ticket",
"webwx_data_ticket",
"uin",
"skey",
"p_uin",
"p_skey",
"pac_uid",
],
"weixin": ["wxuin", "wxsid", "pass_ticket", "uin", "skey",
"p_uin", "p_skey", "pac_uid"],
}
target_cookies = key_cookies.get(self.platform, [])
for i in range(self.LOGIN_TIMEOUT):
await asyncio.sleep(1)
try:
if not self.context: break # 避免意外关闭
cookies = [dict(cookie) for cookie in await self.context.cookies()]
current_url = page.url
has_cookie = any((c.get('name') in target_cookies) for c in cookies) if target_cookies else False
if i % 5 == 0:
logger.debug(f"[{self.platform}] 等待登录... HasCookie: {has_cookie}")
if success_url in current_url or has_cookie:
logger.success(f"[{self.platform}] 登录成功!")
self.login_success = True
await asyncio.sleep(2) # 缓冲
# 保存Cookie
final_cookies = [dict(cookie) for cookie in await self.context.cookies()]
await self._save_cookies(final_cookies)
break
except Exception as e:
logger.warning(f"[{self.platform}] 监控循环警告: {e}")
break
if not self.login_success:
logger.warning(f"[{self.platform}] 登录超时")
except Exception as e:
logger.error(f"[{self.platform}] 监控异常: {e}")
finally:
await self._cleanup()
async def _cleanup(self) -> None:
"""清理资源"""
if self.context:
try:
await self.context.close()
except Exception:
pass
self.context = None
if self.browser:
try:
await self.browser.close()
except Exception:
pass
self.browser = None
if self.playwright:
try:
await self.playwright.stop()
except Exception:
pass
self.playwright = None
initial_url = page.url
_verify_detected_at: Optional[int] = None # 检测到需要验证的时间点(循环计数)
for i in range(self.LOGIN_TIMEOUT):
await asyncio.sleep(1)
if not self.context:
break
try:
# ── 检查 session cookie ──
cookies = [dict(c) for c in await self.context.cookies()]
cookie_names = [c.get("name") for c in cookies]
has_session = any(n in cookie_names for n in target_cookies) if target_cookies else False
current_url = page.url
# 每10秒打一次日志
if i % 10 == 0:
logger.info(
f"[{self.platform}] 等待登录... i={i} "
f"URL={current_url[:80]} session={has_session} "
f"cookies={len(cookies)}"
)
# ── 成功条件:有 session cookie ──
if has_session:
logger.success(f"[{self.platform}] 登录成功检测到session cookie")
self.login_success = True
await asyncio.sleep(2)
final = [dict(c) for c in await self.context.cookies()]
await self._save_cookies(final)
break
# ── 成功条件URL 跳转到目标页 ──
if success_url in current_url:
logger.success(f"[{self.platform}] 登录成功URL={current_url[:80]}")
self.login_success = True
await asyncio.sleep(2)
final = [dict(c) for c in await self.context.cookies()]
await self._save_cookies(final)
break
# ── 抖音API 拦截到 redirect_url → 直接导航 ──
if self.platform == "douyin" and self._qr_api_confirmed and self._qr_redirect_url:
logger.info(f"[douyin] 导航到 redirect_url...")
try:
await page.goto(self._qr_redirect_url, wait_until="domcontentloaded", timeout=30000)
except Exception:
pass
await asyncio.sleep(3)
# 重置,下一轮循环会检查 cookie
self._qr_api_confirmed = False
self._qr_redirect_url = None
continue
# ── 抖音需要APP验证点击"手机刷脸验证"选项 ──
if self.platform == "douyin" and self._douyin_needs_verify:
if _verify_detected_at is None:
_verify_detected_at = i
logger.info("[douyin] 检测到身份验证弹窗,将点击手机刷脸验证...")
elapsed = i - _verify_detected_at
# 第一次:点击"手机刷脸验证"选项
if elapsed == 2:
try:
clicked = await page.evaluate("""() => {
// 查找身份验证弹窗中的选项
const allEls = document.querySelectorAll('div, span, p, a, li');
for (const el of allEls) {
const text = (el.textContent || '').trim();
// 点击"手机刷脸验证"
if (text.includes('刷脸验证') && text.length < 30) {
el.click();
return '刷脸验证';
}
}
return null;
}""")
if clicked:
logger.info(f"[douyin] 已点击验证选项: {clicked}")
else:
logger.warning("[douyin] 未找到验证选项")
except Exception as e:
logger.warning(f"[douyin] 点击验证选项异常: {e}")
# 点击后等待新二维码出现,提取弹窗内二维码截图
if elapsed == 5 and not self._face_verify_qr:
try:
# 用 JS 在"刷脸验证"弹窗内找最大的正方形 img即二维码跳过头像
qr_selector = await page.evaluate("""() => {
// 找到包含"刷脸验证"文字的弹窗
const allEls = document.querySelectorAll('div, h2, h3, span, p');
let modal = null;
for (const el of allEls) {
const text = (el.textContent || '').trim();
if (text.includes('刷脸验证') && text.length < 20) {
modal = el;
for (let i = 0; i < 8; i++) {
if (!modal.parentElement) break;
modal = modal.parentElement;
if (modal.offsetWidth > 250 && modal.offsetHeight > 250) break;
}
break;
}
}
if (!modal) return null;
// 用 offsetWidth/Height显示尺寸而非 naturalWidth源文件可能很大
const imgs = modal.querySelectorAll('img');
let best = null;
let bestArea = 0;
for (const img of imgs) {
const w = img.offsetWidth;
const h = img.offsetHeight;
if (w < 80 || h < 80) continue;
const ratio = Math.abs(w - h) / Math.max(w, h);
if (ratio > 0.3) continue;
const area = w * h;
if (area > bestArea) {
bestArea = area;
best = img;
}
}
if (best) {
best.setAttribute('data-face-qr', 'true');
return 'img[data-face-qr="true"]';
}
return null;
}""")
if qr_selector:
qr_el = page.locator(qr_selector).first
if await qr_el.is_visible():
screenshot = await qr_el.screenshot()
self._face_verify_qr = base64.b64encode(screenshot).decode()
logger.info("[douyin] 刷脸弹窗内二维码截图已捕获")
else:
logger.warning("[douyin] 二维码元素不可见")
if not self._face_verify_qr:
# 兜底:整页截图
logger.warning("[douyin] 未在弹窗内找到二维码,使用全页截图")
screenshot = await page.screenshot()
self._face_verify_qr = base64.b64encode(screenshot).decode()
except Exception as e:
logger.warning(f"[douyin] 截取刷脸二维码异常: {e}")
# 之后每10秒打一次日志
if elapsed > 0 and elapsed % 10 == 0:
logger.info(f"[douyin] 等待用户完成手机验证... ({elapsed}s)")
except Exception as e:
logger.warning(f"[{self.platform}] 监控异常: {e}")
if not self.login_success:
logger.warning(f"[{self.platform}] 登录超时")
except Exception as e:
logger.error(f"[{self.platform}] 监控异常: {e}")
finally:
await self._cleanup()
async def _cleanup(self) -> None:
"""清理资源"""
if self.context:
try:
await self.context.close()
except Exception:
pass
self.context = None
if self.browser:
try:
await self.browser.close()
except Exception:
pass
self.browser = None
if self.playwright:
try:
await self.playwright.stop()
except Exception:
pass
self.playwright = None
async def _save_cookies(self, cookies: Sequence[Mapping[str, Any]]) -> None:
"""保存Cookie到文件"""
try:
cookie_file = self.cookies_dir / f"{self.platform}_cookies.json"
if self.platform == "bilibili":
# Bilibili 使用简单格式 (biliup库需要)
"""保存Cookie到文件"""
try:
cookie_file = self.cookies_dir / f"{self.platform}_cookies.json"
if self.platform == "bilibili":
# Bilibili 使用简单格式 (biliup库需要)
cookie_dict = {c.get('name'): c.get('value') for c in cookies if c.get('name')}
required = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']
cookie_dict = {k: v for k, v in cookie_dict.items() if k in required}
with open(cookie_file, 'w', encoding='utf-8') as f:
json.dump(cookie_dict, f, indent=2)
self.cookies_data = cookie_dict
else:
# Douyin/Xiaohongshu 使用 Playwright storage_state 完整格式
# 这样可以直接用 browser.new_context(storage_state=file)
storage_state = {
"cookies": cookies,
"origins": []
}
with open(cookie_file, 'w', encoding='utf-8') as f:
json.dump(storage_state, f, indent=2)
self.cookies_data = storage_state
logger.success(f"[{self.platform}] Cookie已保存")
except Exception as e:
logger.error(f"[{self.platform}] 保存Cookie失败: {e}")
def get_login_status(self) -> Dict[str, Any]:
"""获取登录状态"""
return {
"success": self.login_success,
"cookies_saved": self.cookies_data is not None
}
required = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']
cookie_dict = {k: v for k, v in cookie_dict.items() if k in required}
with open(cookie_file, 'w', encoding='utf-8') as f:
json.dump(cookie_dict, f, indent=2)
self.cookies_data = cookie_dict
else:
# Douyin/Xiaohongshu 使用 Playwright storage_state 完整格式
# 这样可以直接用 browser.new_context(storage_state=file)
storage_state = {
"cookies": cookies,
"origins": []
}
with open(cookie_file, 'w', encoding='utf-8') as f:
json.dump(storage_state, f, indent=2)
self.cookies_data = storage_state
logger.success(f"[{self.platform}] Cookie已保存")
except Exception as e:
logger.error(f"[{self.platform}] 保存Cookie失败: {e}")
def get_login_status(self) -> Dict[str, Any]:
"""获取登录状态"""
result: Dict[str, Any] = {
"success": self.login_success,
"cookies_saved": self.cookies_data is not None
}
# 刷脸验证:返回新二维码截图给前端展示
if self._face_verify_qr:
result["face_verify_qr"] = self._face_verify_qr
return result

View File

@@ -5,6 +5,7 @@ Remotion 视频渲染服务
import asyncio
import json
import os
import subprocess
from pathlib import Path
from typing import Optional
@@ -114,6 +115,16 @@ class RemotionService:
process.wait()
if process.returncode != 0:
# Remotion 渲染可能在完成输出后进程崩溃 (如 SIGABRT code -6)
# 如果输出文件已存在且大小合理,视为成功
output_file = Path(output_path)
if output_file.exists() and output_file.stat().st_size > 1024:
logger.warning(
f"Remotion process exited with code {process.returncode}, "
f"but output file exists ({output_file.stat().st_size} bytes). Treating as success."
)
return output_path
error_msg = "\n".join(output_lines[-20:]) # 最后 20 行
raise RuntimeError(f"Remotion render failed (code {process.returncode}):\n{error_msg}")

View File

@@ -10,9 +10,9 @@ import os
import shutil
# Supabase Storage 本地存储根目录(从环境变量读取,支持不同部署环境)
SUPABASE_STORAGE_LOCAL_PATH = Path(
os.getenv("SUPABASE_STORAGE_LOCAL_PATH", "/home/rongye/ProgramFiles/Supabase/volumes/storage/stub/stub")
)
# Supabase Storage 本地存储根目录(从环境变量读取,支持不同部署环境)
_default_storage_path = "/var/lib/supabase/storage" # 生产环境默认路径
SUPABASE_STORAGE_LOCAL_PATH = Path(os.getenv("SUPABASE_STORAGE_LOCAL_PATH", _default_storage_path))
class StorageService:
def __init__(self):

View File

@@ -127,6 +127,22 @@ class WeixinUploader(BaseUploader):
return False
def _attach_debug_listeners(self, page) -> None:
# post_create 响应监听始终注册(不依赖 debug 开关)
def log_post_create(response):
try:
url = response.url or ""
if "/post/post_create" in url:
if response.status < 400:
self._post_create_submitted = True
logger.info("[weixin][publish] post_create API ok")
else:
self._publish_api_error = f"发布请求失败HTTP {response.status}"
logger.warning(f"[weixin][publish] post_create_failed status={response.status}")
except Exception:
pass
page.on("response", log_post_create)
if not self._debug_artifacts_enabled():
return
@@ -1210,15 +1226,7 @@ class WeixinUploader(BaseUploader):
return False
async def _wait_for_publish_result(self, page):
success_texts = [
"\u53d1\u5e03\u6210\u529f",
"\u53d1\u5e03\u5b8c\u6210",
"\u5df2\u53d1\u5e03",
"\u5ba1\u6838\u4e2d",
"\u5f85\u5ba1\u6838",
"\u63d0\u4ea4\u6210\u529f",
"\u5df2\u63d0\u4ea4",
]
"""点击发表后等待结果:页面离开创建页即视为成功"""
failure_texts = [
"\u53d1\u5e03\u5931\u8d25",
"\u53d1\u5e03\u5f02\u5e38",
@@ -1229,38 +1237,33 @@ class WeixinUploader(BaseUploader):
"\u7f51\u7edc\u5f02\u5e38",
]
# 记录点击发表时的 URL用于判断是否跳转
create_url = page.url
start_time = time.time()
last_capture = -1
while time.time() - start_time < self.PUBLISH_TIMEOUT:
current_url = page.url
# API 层面报错 → 直接失败
if self._publish_api_error:
return False, self._publish_api_error, False
if self._post_create_submitted and (
"/post/list" in current_url
or "/platform/post/list" in current_url
):
return True, "发布成功:已进入内容列表", False
# 核心判定URL 离开了创建页(跳转到列表页或其他页面)→ 发布成功
if current_url != create_url and "/post/create" not in current_url:
logger.info(f"[weixin] page navigated away from create page: {current_url}")
return True, "发布成功:页面已跳转", False
if "channels.weixin.qq.com/platform" in current_url:
for text in success_texts:
if await self._is_text_visible(page, text, exact=False):
return True, f"发布成功:{text}", False
# post_create API 已确认成功 → 也视为成功
if self._post_create_submitted:
logger.info("[weixin] post_create API confirmed success")
return True, "发布成功:API 已确认", False
# 检查页面上的失败文案
for text in failure_texts:
if await self._is_text_visible(page, text, exact=False):
return False, f"发布失败:{text}", False
for text in success_texts:
if await self._is_text_visible(page, text, exact=False):
return True, f"发布成功:{text}", False
logger.info("[weixin] waiting for publish result...")
elapsed = int(time.time() - start_time)
if elapsed % 20 == 0 and elapsed != last_capture:
last_capture = elapsed
await self._save_debug_screenshot(page, "publish_waiting")
await asyncio.sleep(self.POLL_INTERVAL)
return False, "发布超时", True

View File

@@ -14,6 +14,7 @@
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"swr": "^2.3.8"
},
"devDependencies": {
@@ -6006,6 +6007,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -15,6 +15,7 @@
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"swr": "^2.3.8"
},
"devDependencies": {

View File

@@ -1,10 +1,12 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { getCurrentUser, User } from "@/shared/lib/auth";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { getCurrentUser, User } from "@/shared/lib/auth";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner";
interface UserListItem {
id: string;
@@ -18,7 +20,7 @@ interface UserListItem {
export default function AdminPage() {
const router = useRouter();
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [, setCurrentUser] = useState<User | null>(null);
const [users, setUsers] = useState<UserListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -28,6 +30,7 @@ export default function AdminPage() {
useEffect(() => {
checkAdmin();
fetchUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const checkAdmin = async () => {
@@ -41,9 +44,9 @@ export default function AdminPage() {
const fetchUsers = async () => {
try {
const { data: res } = await api.get<ApiResponse<UserListItem[]>>('/api/admin/users');
setUsers(unwrap(res));
} catch (err) {
const { data: res } = await api.get<ApiResponse<UserListItem[]>>('/api/admin/users');
setUsers(unwrap(res));
} catch {
setError('获取用户列表失败');
} finally {
setLoading(false);
@@ -57,7 +60,7 @@ export default function AdminPage() {
expires_days: expireDays || null
});
fetchUsers();
} catch (err) {
} catch {
// axios interceptor handles 401/403
} finally {
setActivatingId(null);
@@ -70,8 +73,8 @@ export default function AdminPage() {
try {
await api.post(`/api/admin/users/${userId}/deactivate`);
fetchUsers();
} catch (err) {
alert('操作失败');
} catch {
toast.error('操作失败');
}
};
@@ -106,9 +109,9 @@ export default function AdminPage() {
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-white"></h1>
<a href="/" className="text-purple-300 hover:text-purple-200">
<Link href="/" className="text-purple-300 hover:text-purple-200">
</a>
</Link>
</div>
{error && (

View File

@@ -3,7 +3,8 @@ 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";
import { Toaster } from "sonner";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -39,10 +40,18 @@ export default function RootLayout({
>
<AuthProvider>
<TaskProvider>
<GlobalTaskIndicator />
{children}
</TaskProvider>
</AuthProvider>
<Toaster
position="top-center"
richColors
closeButton
toastOptions={{
duration: 3000,
className: "text-sm",
}}
/>
</body>
</html>
);

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { login } from "@/shared/lib/auth";
import { login } from "@/shared/lib/auth";
export default function LoginPage() {
const router = useRouter();
@@ -30,7 +30,7 @@ export default function LoginPage() {
} else {
setError(result.message || '登录失败');
}
} catch (err) {
} catch {
setError('网络错误,请稍后重试');
} finally {
setLoading(false);

View File

@@ -2,10 +2,10 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { register } from "@/shared/lib/auth";
import { register } from "@/shared/lib/auth";
export default function RegisterPage() {
const router = useRouter();
useRouter(); // 保留以便后续扩展
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
@@ -43,7 +43,7 @@ export default function RegisterPage() {
} else {
setError(result.message || '注册失败');
}
} catch (err) {
} catch {
setError('网络错误,请稍后重试');
} finally {
setLoading(false);

View File

@@ -44,7 +44,7 @@ export default function AccountSettingsDropdown() {
if (confirm('确定要退出登录吗?')) {
try {
await api.post('/api/auth/logout');
} catch (e) { }
} catch { }
window.location.href = '/login';
}
};
@@ -76,14 +76,15 @@ export default function AccountSettingsDropdown() {
setTimeout(async () => {
try {
await api.post('/api/auth/logout');
} catch (e) { }
} catch { }
window.location.href = '/login';
}, 1500);
} else {
setError(res.message || '修改失败');
}
} catch (err: any) {
setError(err.response?.data?.message || '修改失败,请重试');
} catch (err: unknown) {
const axiosErr = err as { response?: { data?: { message?: string } } };
setError(axiosErr.response?.data?.message || '修改失败,请重试');
} finally {
setLoading(false);
}

View File

@@ -2,11 +2,14 @@
import { useTask } from "@/contexts/TaskContext";
import Link from "next/link";
import { usePathname } from "next/navigation";
export default function GlobalTaskIndicator() {
const { currentTask, isGenerating } = useTask();
const pathname = usePathname();
if (!isGenerating) return null;
// 首页已有专门的进度条展示,因此在首页不显示顶部全局进度条
if (!isGenerating || pathname === "/") 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">

View File

@@ -1,8 +1,8 @@
"use client";
import { useState, useEffect } from "react";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
import { useEffect, useCallback } from "react";
import { Loader2 } from "lucide-react";
import { useScriptExtraction } from "./script-extraction/useScriptExtraction";
interface ScriptExtractionModalProps {
isOpen: boolean;
@@ -13,177 +13,66 @@ interface ScriptExtractionModalProps {
export default function ScriptExtractionModal({
isOpen,
onClose,
onApply
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);
const {
isLoading,
script,
rewrittenScript,
error,
doRewrite,
step,
dragActive,
selectedFile,
activeTab,
inputUrl,
setDoRewrite,
setActiveTab,
setInputUrl,
handleDrag,
handleDrop,
handleFileChange,
handleExtract,
copyToClipboard,
resetToConfig,
clearSelectedFile,
clearInputUrl,
} = useScriptExtraction({ isOpen });
// New state for URL mode
const [activeTab, setActiveTab] = useState<'file' | 'url'>('url');
const [inputUrl, setInputUrl] = useState("");
// 快捷键ESC 关闭Enter 提交(仅在 config 步骤)
const canExtract = (activeTab === "file" && selectedFile) || (activeTab === "url" && inputUrl.trim());
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
} else if (e.key === "Enter" && !e.shiftKey && step === "config" && canExtract && !isLoading) {
e.preventDefault();
handleExtract();
}
}, [onClose, step, canExtract, isLoading, handleExtract]);
// 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: res } = await api.post<ApiResponse<{ original_script: string; rewritten_script?: string }>>(
'/api/tools/extract-script',
formData,
{
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 180000 // 3 minutes timeout
});
const payload = unwrap(res);
setScript(payload.original_script);
setRewrittenScript(payload.rewritten_script || "");
setStep('result');
} catch (err: any) {
console.error(err);
const msg = err.response?.data?.message || 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;
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
const handleApplyAndClose = (text: string) => {
onApply?.(text);
onClose();
};
const handleExtractNext = () => {
resetToConfig();
clearSelectedFile();
clearInputUrl();
};
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"
>
<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 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">
@@ -199,25 +88,24 @@ export default function ScriptExtractionModal({
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{step === 'config' && (
{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'
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'
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"
}`}
>
📂
@@ -225,7 +113,7 @@ export default function ScriptExtractionModal({
</div>
{/* URL Input Area */}
{activeTab === 'url' && (
{activeTab === "url" && (
<div className="space-y-2 py-4">
<div className="relative">
<input
@@ -237,119 +125,150 @@ export default function ScriptExtractionModal({
/>
{inputUrl && (
<button
onClick={() => setInputUrl("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white p-1"
onClick={clearInputUrl}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
>
</button>
)}
</div>
<p className="text-xs text-gray-400 px-1">
B站等主流平台分享链接
<p className="text-xs text-gray-500 pl-1">
B站
</p>
</div>
)}
{/* File Upload Area */}
{activeTab === 'file' && (
{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' : ''}
`}
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${dragActive
? "border-purple-500 bg-purple-500/10"
: "border-white/10 hover:border-white/20"
}`}
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 className="space-y-2">
<p className="text-white">{selectedFile.name}</p>
<p className="text-sm text-gray-400">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
<button
onClick={clearSelectedFile}
className="text-xs text-purple-400 hover:text-purple-300"
>
</button>
</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 className="space-y-4">
<div className="text-4xl">📁</div>
<p className="text-gray-400">
/
<label className="text-purple-400 hover:text-purple-300 cursor-pointer">
<input
type="file"
accept=".mp4,.mov,.avi,.mp3,.wav,.m4a"
onChange={handleFileChange}
className="hidden"
/>
</label>
</p>
<p className="text-xs text-gray-500">
MP4, MOV, AVI, MP3, WAV, M4A
</p>
</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">
<div className="flex items-center gap-3 bg-white/5 rounded-xl p-4 border border-white/10">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={doRewrite}
onChange={e => setDoRewrite(e.target.checked)}
className="w-5 h-5 accent-purple-600 rounded"
onChange={(e) => setDoRewrite(e.target.checked)}
className="w-4 h-4 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
/>
<div>
<div className="text-white font-medium"> AI 稿</div>
<div className="text-xs text-gray-400">稿</div>
</div>
<span className="text-sm text-gray-300">
AI
</span>
</label>
</div>
{/* Error */}
{error && (
<div className="p-3 bg-red-500/20 text-red-200 rounded-lg text-sm text-center">
{error}
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
<div className="flex justify-center pt-2">
{/* Action Button */}
<div className="flex gap-3 pt-2">
<button
onClick={onClose}
className="flex-1 py-3 px-4 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-colors"
>
</button>
<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()}
disabled={
(activeTab === "file" && !selectedFile) ||
(activeTab === "url" && !inputUrl.trim()) ||
isLoading
}
className="flex-1 py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-xl transition-all font-medium shadow-lg flex items-center justify-center gap-2"
>
{activeTab === 'url' ? '🔗 解析并提取' : '🚀 开始提取'}
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : null}
</button>
</div>
</div>
)}
{step === 'processing' && (
{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>
<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>
{activeTab === "url" && "正在下载视频..."}
<br />
{doRewrite
? "正在进行语音识别和 AI 智能改写..."
: "正在进行语音识别..."}
<br />
<span className="opacity-75">
</span>
</p>
</div>
)}
{step === 'result' && (
{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>
AI 稿{" "}
<span className="text-xs font-normal text-purple-400/70">
()
</span>
</h4>
{onApply && (
<button
onClick={() => {
onApply(rewrittenScript);
onClose();
}}
onClick={() => handleApplyAndClose(rewrittenScript)}
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"
>
📥
@@ -377,10 +296,7 @@ export default function ScriptExtractionModal({
</h4>
{onApply && (
<button
onClick={() => {
onApply(script);
onClose();
}}
onClick={() => handleApplyAndClose(script)}
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"
>
📥
@@ -402,14 +318,7 @@ export default function ScriptExtractionModal({
<div className="flex justify-center pt-4">
<button
onClick={() => {
setStep('config');
setScript("");
setRewrittenScript("");
setSelectedFile(null);
setInputUrl("");
// Keep current tab active
}}
onClick={handleExtractNext}
className="px-6 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
>

View File

@@ -0,0 +1,210 @@
import { useState, useEffect, useCallback } from "react";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner";
export type ExtractionStep = "config" | "processing" | "result";
export type InputTab = "file" | "url";
const VALID_FILE_TYPES = [".mp4", ".mov", ".avi", ".mp3", ".wav", ".m4a"];
interface UseScriptExtractionOptions {
isOpen: boolean;
}
export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => {
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<ExtractionStep>("config");
const [dragActive, setDragActive] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [activeTab, setActiveTab] = useState<InputTab>("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 = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
}, []);
const handleFile = useCallback((file: File) => {
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf("."));
if (!VALID_FILE_TYPES.includes(ext)) {
setError(`不支持的文件格式 ${ext},请上传视频或音频文件`);
return;
}
setSelectedFile(file);
setError(null);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files?.[0]) {
handleFile(e.dataTransfer.files[0]);
}
},
[handleFile]
);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.[0]) {
handleFile(e.target.files[0]);
}
},
[handleFile]
);
const handleExtract = useCallback(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: res } = await api.post<
ApiResponse<{ original_script: string; rewritten_script?: string }>
>("/api/tools/extract-script", formData, {
headers: { "Content-Type": "multipart/form-data" },
timeout: 180000, // 3 minutes timeout
});
const payload = unwrap(res);
setScript(payload.original_script);
setRewrittenScript(payload.rewritten_script || "");
setStep("result");
} catch (err: unknown) {
console.error(err);
const axiosErr = err as {
response?: { data?: { message?: string } };
message?: string;
};
const msg =
axiosErr.response?.data?.message || axiosErr.message || "请求失败";
setError(msg);
setStep("config");
} finally {
setIsLoading(false);
}
}, [activeTab, selectedFile, inputUrl, doRewrite]);
const copyToClipboard = useCallback((text: string) => {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(text)
.then(() => {
toast.success("已复制到剪贴板");
})
.catch(() => {
fallbackCopyTextToClipboard(text);
});
} else {
fallbackCopyTextToClipboard(text);
}
}, []);
const fallbackCopyTextToClipboard = (text: string) => {
const textArea = document.createElement("textarea");
textArea.value = text;
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 {
const successful = document.execCommand("copy");
if (successful) {
toast.success("已复制到剪贴板");
} else {
toast.error("复制失败,请手动复制");
}
} catch {
toast.error("复制失败,请手动复制");
}
document.body.removeChild(textArea);
};
const resetToConfig = useCallback(() => {
setStep("config");
}, []);
const clearSelectedFile = useCallback(() => {
setSelectedFile(null);
}, []);
const clearInputUrl = useCallback(() => {
setInputUrl("");
}, []);
return {
// State
isLoading,
script,
rewrittenScript,
error,
doRewrite,
step,
dragActive,
selectedFile,
activeTab,
inputUrl,
// Setters
setDoRewrite,
setActiveTab,
setInputUrl,
// Handlers
handleDrag,
handleDrop,
handleFileChange,
handleExtract,
copyToClipboard,
resetToConfig,
clearSelectedFile,
clearInputUrl,
};
};

View File

@@ -87,7 +87,9 @@ export function TaskProvider({ children }: { children: ReactNode }) {
const savedTaskId = localStorage.getItem(taskKey);
if (savedTaskId) {
console.log("[TaskContext] 恢复任务:", savedTaskId);
// eslint-disable-next-line react-hooks/set-state-in-effect
setTaskId(savedTaskId);
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsGenerating(true);
}
}

View File

@@ -9,13 +9,15 @@ export interface BgmItem {
}
interface UseBgmOptions {
storageKey: string;
selectedBgmId: string;
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
}
export const useBgm = ({
storageKey,
// selectedBgmId 用于参数类型推断,不在此 hook 内部直接使用
// eslint-disable-next-line @typescript-eslint/no-unused-vars
selectedBgmId,
setSelectedBgmId,
}: UseBgmOptions) => {
@@ -32,21 +34,20 @@ export const useBgm = ({
const items: BgmItem[] = Array.isArray(payload.bgm) ? payload.bgm : [];
setBgmList(items);
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
setSelectedBgmId((prev) => {
if (prev && items.some((item) => item.id === prev)) return prev;
if (savedBgmId && items.some((item) => item.id === savedBgmId)) return savedBgmId;
return items[0]?.id || "";
});
} catch (error: any) {
const message = error?.response?.data?.message || error?.message || '加载失败';
} catch (error: unknown) {
const axiosErr = error as { response?: { data?: { message?: string } }; message?: string };
const message = axiosErr?.response?.data?.message || axiosErr?.message || '加载失败';
setBgmError(message);
setBgmList([]);
console.error("获取背景音乐失败:", error);
} finally {
setBgmLoading(false);
}
}, [setSelectedBgmId, storageKey]);
}, [setSelectedBgmId]);
return {
bgmList,

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner";
interface GeneratedVideo {
id: string;
@@ -11,7 +12,7 @@ interface GeneratedVideo {
}
interface UseGeneratedVideosOptions {
storageKey: string;
selectedVideoId: string | null;
setSelectedVideoId: React.Dispatch<React.SetStateAction<string | null>>;
setGeneratedVideo: React.Dispatch<React.SetStateAction<string | null>>;
@@ -19,7 +20,7 @@ interface UseGeneratedVideosOptions {
}
export const useGeneratedVideos = ({
storageKey,
selectedVideoId,
setSelectedVideoId,
setGeneratedVideo,
@@ -36,32 +37,42 @@ export const useGeneratedVideos = ({
const videos: GeneratedVideo[] = payload.videos || [];
setGeneratedVideos(videos);
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
const currentId = preferVideoId || selectedVideoId || savedSelectedVideoId || null;
let nextId: string | null = null;
let nextUrl: string | null = null;
if (currentId) {
const found = videos.find(v => v.id === currentId);
if (found) {
nextId = found.id;
nextUrl = resolveMediaUrl(found.path);
// 只在明确指定 preferVideoId 时才自动选中
// "__latest__" 表示选中最新的(第一个),用于新视频生成完成后
// 其他值表示选中指定 ID 的视频
// 不传则不设置选中项,由 useHomePersistence 恢复
if (preferVideoId && videos.length > 0) {
if (preferVideoId === "__latest__") {
setSelectedVideoId(videos[0].id);
setGeneratedVideo(resolveMediaUrl(videos[0].path));
} else {
const found = videos.find(v => v.id === preferVideoId);
if (found) {
setSelectedVideoId(found.id);
setGeneratedVideo(resolveMediaUrl(found.path));
}
}
}
if (!nextId && videos.length > 0) {
nextId = videos[0].id;
nextUrl = resolveMediaUrl(videos[0].path);
}
if (nextId) {
setSelectedVideoId(nextId);
setGeneratedVideo(nextUrl);
}
} catch (error) {
console.error("获取历史视频失败:", error);
}
}, [resolveMediaUrl, selectedVideoId, setGeneratedVideo, setSelectedVideoId, storageKey]);
}, [resolveMediaUrl, setGeneratedVideo, setSelectedVideoId]);
// 【核心修复】当 selectedVideoId 变化时(例如从持久化恢复),自动同步 generatedVideo (URL)
// 之前的逻辑只在 fetch 时设置,导致外部恢复 ID 后 URL 不同步
useEffect(() => {
if (!selectedVideoId || generatedVideos.length === 0) {
// 如果没有选中 ID或者列表为空不要轻易置空 URL除非明确需要
// 这里保持现状,由 fetchGeneratedVideos 或 deleteVideo 处理置空
return;
}
const video = generatedVideos.find(v => v.id === selectedVideoId);
if (video) {
const url = resolveMediaUrl(video.path);
setGeneratedVideo(url);
}
}, [selectedVideoId, generatedVideos, resolveMediaUrl, setGeneratedVideo]);
const deleteVideo = useCallback(async (videoId: string) => {
if (!confirm("确定要删除这个视频吗?")) return;
@@ -73,7 +84,7 @@ export const useGeneratedVideos = ({
}
fetchGeneratedVideos();
} catch (error) {
alert("删除失败: " + error);
toast.error("删除失败: " + error);
}
}, [fetchGeneratedVideos, selectedVideoId, setGeneratedVideo, setSelectedVideoId]);

View File

@@ -13,6 +13,9 @@ import { clampTitle } from "@/shared/lib/title";
import { useTitleInput } from "@/shared/hooks/useTitleInput";
import { useAuth } from "@/contexts/AuthContext";
import { useTask } from "@/contexts/TaskContext";
import { toast } from "sonner";
import { usePublishPrefetch } from "@/shared/hooks/usePublishPrefetch";
import { PublishAccount } from "@/shared/types/publish";
import { useBgm } from "@/features/home/model/useBgm";
import { useGeneratedVideos } from "@/features/home/model/useGeneratedVideos";
import { useHomePersistence } from "@/features/home/model/useHomePersistence";
@@ -30,26 +33,7 @@ const VOICES = [
{ id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" },
];
const PUBLISH_PREFETCH_KEY = "vigent_publish_prefetch_v1";
const PUBLISH_PREFETCH_TTL = 2 * 60 * 1000;
interface PublishAccount {
platform: string;
name: string;
logged_in: boolean;
enabled: boolean;
}
interface PublishVideo {
name: string;
path: string;
}
interface PublishPrefetchCache {
ts: number;
accounts?: PublishAccount[];
videos?: PublishVideo[];
}
const FIXED_REF_TEXT =
"其实生活中有许多美好的瞬间,比如清晨的阳光,或者一杯温热的清茶。希望这次生成的音色能够自然、流畅,完美还原出我最真实的声音状态。";
@@ -105,6 +89,7 @@ export const useHomeController = () => {
// 使用全局任务状态
const { currentTask, isGenerating, startTask } = useTask();
const prevIsGenerating = useRef(isGenerating);
const [generatedVideo, setGeneratedVideo] = useState<string | null>(null);
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
@@ -114,13 +99,15 @@ export const useHomeController = () => {
const [enableSubtitles, setEnableSubtitles] = useState<boolean>(true);
const [selectedSubtitleStyleId, setSelectedSubtitleStyleId] = useState<string>("");
const [selectedTitleStyleId, setSelectedTitleStyleId] = useState<string>("");
const [subtitleFontSize, setSubtitleFontSize] = useState<number>(60);
const [titleFontSize, setTitleFontSize] = useState<number>(90);
const [subtitleFontSize, setSubtitleFontSize] = useState<number>(80);
const [titleFontSize, setTitleFontSize] = useState<number>(120);
const [subtitleSizeLocked, setSubtitleSizeLocked] = useState<boolean>(false);
const [titleSizeLocked, setTitleSizeLocked] = useState<boolean>(false);
const [titleTopMargin, setTitleTopMargin] = useState<number>(62);
const [subtitleBottomMargin, setSubtitleBottomMargin] = useState<number>(80);
const [showStylePreview, setShowStylePreview] = useState<boolean>(false);
const [materialDimensions, setMaterialDimensions] = useState<{ width: number; height: number } | null>(null);
const [previewContainerWidth, setPreviewContainerWidth] = useState<number>(0);
// 背景音乐相关状态
const [selectedBgmId, setSelectedBgmId] = useState<string>("");
@@ -139,7 +126,7 @@ export const useHomeController = () => {
const [editMaterialName, setEditMaterialName] = useState("");
const bgmItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
const bgmListContainerRef = useRef<HTMLDivElement | null>(null);
const titlePreviewContainerRef = useRef<HTMLDivElement | null>(null);
const materialItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
const videoItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
@@ -166,8 +153,8 @@ export const useHomeController = () => {
await api.put(`/api/ref-audios/${encodeURIComponent(audioId)}`, { new_name: editName });
setEditingAudioId(null);
fetchRefAudios(); // 刷新列表
} catch (err: any) {
alert("重命名失败: " + err);
} catch (err: unknown) {
toast.error("重命名失败: " + String(err));
}
};
@@ -200,9 +187,10 @@ export const useHomeController = () => {
setEditingMaterialId(null);
setEditMaterialName("");
fetchMaterials();
} catch (err: any) {
const errorMsg = err.response?.data?.message || err.message || String(err);
alert(`重命名失败: ${errorMsg}`);
} catch (err: unknown) {
const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
toast.error(`重命名失败: ${errorMsg}`);
}
};
@@ -225,26 +213,8 @@ export const useHomeController = () => {
// 获取存储 key 的前缀(登录用户使用 userId未登录使用 guest
const storageKey = userId || "guest";
const readPublishPrefetch = () => {
if (typeof window === "undefined") return null;
const raw = sessionStorage.getItem(PUBLISH_PREFETCH_KEY);
if (!raw) return null;
try {
const cache = JSON.parse(raw) as PublishPrefetchCache;
if (!cache?.ts) return null;
if (Date.now() - cache.ts > PUBLISH_PREFETCH_TTL) return null;
return cache;
} catch {
return null;
}
};
const updatePublishPrefetch = (patch: Partial<PublishPrefetchCache>) => {
if (typeof window === "undefined") return;
const existing = readPublishPrefetch() || { ts: Date.now() };
const next = { ...existing, ...patch, ts: Date.now() };
sessionStorage.setItem(PUBLISH_PREFETCH_KEY, JSON.stringify(next));
};
// 使用共用的发布预加载 hook
const { updatePrefetch: updatePublishPrefetch } = usePublishPrefetch();
const {
materials,
@@ -270,7 +240,7 @@ export const useHomeController = () => {
refreshTitleStyles,
} = useTitleSubtitleStyles({
isAuthLoading,
storageKey,
setSelectedSubtitleStyleId,
setSelectedTitleStyleId,
});
@@ -296,7 +266,7 @@ export const useHomeController = () => {
bgmError,
fetchBgmList,
} = useBgm({
storageKey,
selectedBgmId,
setSelectedBgmId,
});
@@ -319,7 +289,7 @@ export const useHomeController = () => {
fetchGeneratedVideos,
deleteVideo,
} = useGeneratedVideos({
storageKey,
selectedVideoId,
setSelectedVideoId,
setGeneratedVideo,
@@ -347,15 +317,18 @@ export const useHomeController = () => {
return () => {
active = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthLoading, userId]);
useEffect(() => {
if (generatedVideos.length === 0) return;
const prefetched = generatedVideos.map((video) => ({
id: video.id,
name: formatDate(video.created_at) + ` (${video.size_mb.toFixed(1)}MB)`,
path: video.path.startsWith("/") ? video.path.slice(1) : video.path,
}));
updatePublishPrefetch({ videos: prefetched });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [generatedVideos]);
const { isRestored } = useHomePersistence({
@@ -383,6 +356,10 @@ export const useHomeController = () => {
setTitleFontSize,
setSubtitleSizeLocked,
setTitleSizeLocked,
titleTopMargin,
setTitleTopMargin,
subtitleBottomMargin,
setSubtitleBottomMargin,
selectedBgmId,
setSelectedBgmId,
bgmVolume,
@@ -417,8 +394,21 @@ export const useHomeController = () => {
refreshTitleStyles(),
fetchBgmList(),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthLoading]);
// 监听任务完成,自动刷新视频列表并选中最新
useEffect(() => {
if (prevIsGenerating.current && !isGenerating) {
if (currentTask?.status === "completed") {
void fetchGeneratedVideos("__latest__");
} else {
void fetchGeneratedVideos();
}
}
prevIsGenerating.current = isGenerating;
}, [isGenerating, currentTask, fetchGeneratedVideos]);
useEffect(() => {
const material = materials.find((item) => item.id === selectedMaterial);
if (!material?.path) {
@@ -462,25 +452,6 @@ export const useHomeController = () => {
};
}, [materials, selectedMaterial]);
useEffect(() => {
if (!showStylePreview) return;
const container = titlePreviewContainerRef.current;
if (!container) return;
setPreviewContainerWidth(container.getBoundingClientRect().width);
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setPreviewContainerWidth(entry.contentRect.width);
}
});
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, [showStylePreview]);
useEffect(() => {
if (subtitleSizeLocked || subtitleStyles.length === 0) return;
@@ -502,16 +473,8 @@ export const useHomeController = () => {
}
}, [titleStyles, selectedTitleStyleId, titleSizeLocked]);
useEffect(() => {
if (!enableBgm || selectedBgmId || bgmList.length === 0) return;
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
const savedItem = savedBgmId && bgmList.find((item) => item.id === savedBgmId);
if (savedItem) {
setSelectedBgmId(savedBgmId);
return;
}
setSelectedBgmId(bgmList[0].id);
}, [enableBgm, selectedBgmId, bgmList, storageKey, setSelectedBgmId]);
// 移除重复的 BGM 持久化恢复逻辑 (已统一移动到 useHomePersistence 中)
// useEffect(() => { ... })
useEffect(() => {
if (!selectedBgmId) return;
@@ -530,6 +493,23 @@ export const useHomeController = () => {
}
}, [selectedMaterial, materials]);
// 【修复】历史视频默认选中逻辑
// 当持久化恢复完成,且列表加载完毕,如果没选中任何视频,默认选中第一个
useEffect(() => {
if (isRestored && generatedVideos.length > 0 && !selectedVideoId) {
const firstId = generatedVideos[0].id;
setSelectedVideoId(firstId);
setGeneratedVideo(resolveMediaUrl(generatedVideos[0].path));
}
}, [isRestored, generatedVideos, selectedVideoId, setSelectedVideoId, setGeneratedVideo, resolveMediaUrl]);
// 【修复】BGM 默认选中逻辑
useEffect(() => {
if (isRestored && bgmList.length > 0 && !selectedBgmId && enableBgm) {
setSelectedBgmId(bgmList[0].id);
}
}, [isRestored, bgmList, selectedBgmId, enableBgm, setSelectedBgmId]);
useEffect(() => {
if (!selectedVideoId) return;
const target = videoItemRefs.current[selectedVideoId];
@@ -593,7 +573,7 @@ export const useHomeController = () => {
setRecordingTime((prev) => prev + 1);
}, 1000);
} catch (err) {
alert("无法访问麦克风,请检查权限设置");
toast.error("无法访问麦克风,请检查权限设置");
console.error(err);
}
};
@@ -631,7 +611,7 @@ export const useHomeController = () => {
// AI 生成标题和标签
const handleGenerateMeta = async () => {
if (!text.trim()) {
alert("请先输入口播文案");
toast.error("请先输入口播文案");
return;
}
@@ -649,10 +629,11 @@ export const useHomeController = () => {
// 同步到发布页 localStorage
localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(payload.tags || []));
} catch (err: any) {
} catch (err: unknown) {
console.error("AI generate meta failed:", err);
const errorMsg = err.response?.data?.message || err.message || String(err);
alert(`AI 生成失败: ${errorMsg}`);
const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
toast.error(`AI 生成失败: ${errorMsg}`);
} finally {
setIsGeneratingMeta(false);
}
@@ -661,20 +642,20 @@ export const useHomeController = () => {
// 生成视频
const handleGenerate = async () => {
if (!selectedMaterial || !text.trim()) {
alert("请先选择素材并填写文案");
toast.error("请先选择素材并填写文案");
return;
}
// 声音克隆模式校验
if (ttsMode === "voiceclone") {
if (!selectedRefAudio) {
alert("请选择或上传参考音频");
toast.error("请选择或上传参考音频");
return;
}
}
if (enableBgm && !selectedBgmId) {
alert("请选择背景音乐");
toast.error("请选择背景音乐");
return;
}
@@ -684,12 +665,12 @@ export const useHomeController = () => {
// 查找选中的素材对象以获取路径
const materialObj = materials.find((m) => m.id === selectedMaterial);
if (!materialObj) {
alert("素材数据异常");
toast.error("素材数据异常");
return;
}
// 构建请求参数
const payload: Record<string, any> = {
const payload: Record<string, unknown> = {
material_path: materialObj.path,
text: text,
tts_mode: ttsMode,
@@ -713,6 +694,14 @@ export const useHomeController = () => {
payload.title_font_size = Math.round(titleFontSize);
}
if (videoTitle.trim()) {
payload.title_top_margin = Math.round(titleTopMargin);
}
if (enableSubtitles) {
payload.subtitle_bottom_margin = Math.round(subtitleBottomMargin);
}
if (enableBgm && selectedBgmId) {
payload.bgm_id = selectedBgmId;
payload.bgm_volume = bgmVolume;
@@ -816,14 +805,16 @@ export const useHomeController = () => {
subtitleFontSize,
setSubtitleFontSize,
setSubtitleSizeLocked,
titleTopMargin,
setTitleTopMargin,
subtitleBottomMargin,
setSubtitleBottomMargin,
enableSubtitles,
setEnableSubtitles,
resolveAssetUrl,
getFontFormat,
buildTextShadow,
previewContainerWidth,
materialDimensions,
titlePreviewContainerRef,
ttsMode,
setTtsMode,
voices: VOICES,

View File

@@ -35,6 +35,10 @@ interface UseHomePersistenceOptions {
setTitleFontSize: React.Dispatch<React.SetStateAction<number>>;
setSubtitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
setTitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
titleTopMargin: number;
setTitleTopMargin: React.Dispatch<React.SetStateAction<number>>;
subtitleBottomMargin: number;
setSubtitleBottomMargin: React.Dispatch<React.SetStateAction<number>>;
selectedBgmId: string;
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
bgmVolume: number;
@@ -71,6 +75,10 @@ export const useHomePersistence = ({
setTitleFontSize,
setSubtitleSizeLocked,
setTitleSizeLocked,
titleTopMargin,
setTitleTopMargin,
subtitleBottomMargin,
setSubtitleBottomMargin,
selectedBgmId,
setSelectedBgmId,
bgmVolume,
@@ -100,6 +108,8 @@ export const useHomePersistence = ({
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
const savedBgmVolume = localStorage.getItem(`vigent_${storageKey}_bgmVolume`);
const savedEnableBgm = localStorage.getItem(`vigent_${storageKey}_enableBgm`);
const savedTitleTopMargin = localStorage.getItem(`vigent_${storageKey}_titleTopMargin`);
const savedSubtitleBottomMargin = localStorage.getItem(`vigent_${storageKey}_subtitleBottomMargin`);
setText(savedText || "大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。");
setVideoTitle(savedTitle ? clampTitle(savedTitle) : "");
@@ -132,6 +142,16 @@ export const useHomePersistence = ({
if (savedEnableBgm !== null) setEnableBgm(savedEnableBgm === 'true');
if (savedSelectedVideoId) setSelectedVideoId(savedSelectedVideoId);
if (savedTitleTopMargin) {
const parsed = parseInt(savedTitleTopMargin, 10);
if (!Number.isNaN(parsed)) setTitleTopMargin(parsed);
}
if (savedSubtitleBottomMargin) {
const parsed = parseInt(savedSubtitleBottomMargin, 10);
if (!Number.isNaN(parsed)) setSubtitleBottomMargin(parsed);
}
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsRestored(true);
}, [
isAuthLoading,
@@ -148,6 +168,8 @@ export const useHomePersistence = ({
setText,
setTitleFontSize,
setTitleSizeLocked,
setTitleTopMargin,
setSubtitleBottomMargin,
setTtsMode,
setVideoTitle,
setVoice,
@@ -212,6 +234,18 @@ export const useHomePersistence = ({
}
}, [titleFontSize, storageKey, isRestored]);
useEffect(() => {
if (isRestored) {
localStorage.setItem(`vigent_${storageKey}_titleTopMargin`, String(titleTopMargin));
}
}, [titleTopMargin, storageKey, isRestored]);
useEffect(() => {
if (isRestored) {
localStorage.setItem(`vigent_${storageKey}_subtitleBottomMargin`, String(subtitleBottomMargin));
}
}, [subtitleBottomMargin, storageKey, isRestored]);
useEffect(() => {
if (isRestored) {
localStorage.setItem(`vigent_${storageKey}_bgmId`, selectedBgmId);

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from "react";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner";
interface Material {
id: string;
@@ -40,19 +41,20 @@ export const useMaterials = ({
setMaterials(nextMaterials);
setLastMaterialCount(nextMaterials.length);
const nextSelected = nextMaterials.find((item: Material) => item.id === selectedMaterial)?.id
|| nextMaterials[0]?.id
|| "";
if (nextSelected !== selectedMaterial) {
setSelectedMaterial(nextSelected);
}
setSelectedMaterial((prev) => {
// 如果当前选中的素材在列表中依然存在,保持选中
const exists = nextMaterials.some((item) => item.id === prev);
if (exists) return prev;
// 否则默认选中第一个
return nextMaterials[0]?.id || "";
});
} catch (error) {
console.error("获取素材失败:", error);
setFetchError(String(error));
} finally {
setIsFetching(false);
}
}, [selectedMaterial, setSelectedMaterial]);
}, [setSelectedMaterial]);
const deleteMaterial = useCallback(async (materialId: string) => {
if (!confirm("确定要删除这个素材吗?")) return;
@@ -63,7 +65,7 @@ export const useMaterials = ({
setSelectedMaterial("");
}
} catch (error) {
alert("删除失败: " + error);
toast.error("删除失败: " + error);
}
}, [fetchMaterials, selectedMaterial, setSelectedMaterial]);
@@ -99,10 +101,11 @@ export const useMaterials = ({
setUploadProgress(100);
setIsUploading(false);
fetchMaterials();
} catch (err: any) {
} catch (err: unknown) {
console.error("Upload failed:", err);
setIsUploading(false);
const errorMsg = err.response?.data?.message || err.message || String(err);
const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
setUploadError(`上传失败: ${errorMsg}`);
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { BgmItem } from "@/features/home/model/useBgm";
import { toast } from "sonner";
interface RefAudio {
id: string;
@@ -64,12 +65,12 @@ export const useMediaPlayers = ({
const audioUrl = resolveMediaUrl(audio.path) || audio.path;
if (!audioUrl) {
alert("无法播放该参考音频");
toast.error("无法播放该参考音频");
return;
}
const player = new Audio(audioUrl);
player.onended = () => setPlayingAudioId(null);
player.play().catch((err) => alert("播放失败: " + err));
player.play().catch((err) => toast.error("播放失败: " + err));
audioPlayerRef.current = player;
setPlayingAudioId(audio.id);
}, [playingAudioId, resolveMediaUrl, stopAudio, stopBgm]);
@@ -81,7 +82,7 @@ export const useMediaPlayers = ({
const bgmUrl = resolveBgmUrl(bgm.id);
if (!bgmUrl) {
alert("无法播放该背景音乐");
toast.error("无法播放该背景音乐");
return;
}
@@ -96,7 +97,7 @@ export const useMediaPlayers = ({
const player = new Audio(bgmUrl);
player.volume = Math.max(0, Math.min(bgmVolume, 1));
player.onended = () => setPlayingBgmId(null);
player.play().catch((err) => alert("播放失败: " + err));
player.play().catch((err) => toast.error("播放失败: " + err));
bgmPlayerRef.current = player;
setPlayingBgmId(bgm.id);
}, [bgmVolume, playingBgmId, resolveBgmUrl, setEnableBgm, setSelectedBgmId, stopAudio, stopBgm]);

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from "react";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner";
interface RefAudio {
id: string;
@@ -60,10 +61,11 @@ export const useRefAudios = ({
setSelectedRefAudio(payload);
setRefText(payload.ref_text);
setIsUploadingRef(false);
} catch (err: any) {
} catch (err: unknown) {
console.error("Upload ref audio failed:", err);
setIsUploadingRef(false);
const errorMsg = err.response?.data?.message || err.message || String(err);
const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
setUploadRefError(`上传失败: ${errorMsg}`);
}
}, [fetchRefAudios, fixedRefText, setRefText, setSelectedRefAudio]);
@@ -78,7 +80,7 @@ export const useRefAudios = ({
setRefText('');
}
} catch (error) {
alert("删除失败: " + error);
toast.error("删除失败: " + error);
}
}, [fetchRefAudios, selectedRefAudio, setRefText, setSelectedRefAudio]);

View File

@@ -34,14 +34,14 @@ export interface TitleStyleOption {
interface UseTitleSubtitleStylesOptions {
isAuthLoading: boolean;
storageKey: string;
setSelectedSubtitleStyleId: React.Dispatch<React.SetStateAction<string>>;
setSelectedTitleStyleId: React.Dispatch<React.SetStateAction<string>>;
}
export const useTitleSubtitleStyles = ({
isAuthLoading,
storageKey,
setSelectedSubtitleStyleId,
setSelectedTitleStyleId,
}: UseTitleSubtitleStylesOptions) => {
@@ -57,17 +57,15 @@ export const useTitleSubtitleStyles = ({
const styles: SubtitleStyleOption[] = payload.styles || [];
setSubtitleStyles(styles);
const savedStyleId = localStorage.getItem(`vigent_${storageKey}_subtitleStyle`);
setSelectedSubtitleStyleId((prev) => {
if (prev && styles.some((s) => s.id === prev)) return prev;
if (savedStyleId && styles.some((s) => s.id === savedStyleId)) return savedStyleId;
const defaultStyle = styles.find((s) => s.is_default) || styles[0];
return defaultStyle?.id || "";
});
} catch (error) {
console.error("获取字幕样式失败:", error);
}
}, [setSelectedSubtitleStyleId, storageKey]);
}, [setSelectedSubtitleStyleId]);
const refreshTitleStyles = useCallback(async () => {
try {
@@ -78,21 +76,21 @@ export const useTitleSubtitleStyles = ({
const styles: TitleStyleOption[] = payload.styles || [];
setTitleStyles(styles);
const savedStyleId = localStorage.getItem(`vigent_${storageKey}_titleStyle`);
setSelectedTitleStyleId((prev) => {
if (prev && styles.some((s) => s.id === prev)) return prev;
if (savedStyleId && styles.some((s) => s.id === savedStyleId)) return savedStyleId;
const defaultStyle = styles.find((s) => s.is_default) || styles[0];
return defaultStyle?.id || "";
});
} catch (error) {
console.error("获取标题样式失败:", error);
}
}, [setSelectedTitleStyleId, storageKey]);
}, [setSelectedTitleStyleId]);
useEffect(() => {
if (isAuthLoading) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
refreshSubtitleStyles();
// eslint-disable-next-line react-hooks/set-state-in-effect
refreshTitleStyles();
}, [isAuthLoading, refreshSubtitleStyles, refreshTitleStyles]);

View File

@@ -0,0 +1,226 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
interface SubtitleStyleOption {
id: string;
label: string;
font_family?: string;
font_file?: string;
font_size?: number;
highlight_color?: string;
normal_color?: string;
stroke_color?: string;
stroke_size?: number;
letter_spacing?: number;
bottom_margin?: number;
is_default?: boolean;
}
interface TitleStyleOption {
id: string;
label: string;
font_family?: string;
font_file?: string;
font_size?: number;
color?: string;
stroke_color?: string;
stroke_size?: number;
letter_spacing?: number;
font_weight?: number;
top_margin?: number;
is_default?: boolean;
}
interface FloatingStylePreviewProps {
onClose: () => void;
videoTitle: string;
titleStyles: TitleStyleOption[];
selectedTitleStyleId: string;
titleFontSize: number;
subtitleStyles: SubtitleStyleOption[];
selectedSubtitleStyleId: string;
subtitleFontSize: number;
titleTopMargin: number;
subtitleBottomMargin: number;
enableSubtitles: boolean;
resolveAssetUrl: (path?: string | null) => string | null;
getFontFormat: (fontFile?: string) => string;
buildTextShadow: (color: string, size: number) => string;
previewBaseWidth: number;
previewBaseHeight: number;
}
const DESKTOP_WIDTH = 280;
export function FloatingStylePreview({
onClose,
videoTitle,
titleStyles,
selectedTitleStyleId,
titleFontSize,
subtitleStyles,
selectedSubtitleStyleId,
subtitleFontSize,
titleTopMargin,
subtitleBottomMargin,
enableSubtitles,
resolveAssetUrl,
getFontFormat,
buildTextShadow,
previewBaseWidth,
previewBaseHeight,
}: FloatingStylePreviewProps) {
const isMobile = typeof window !== "undefined" && window.innerWidth < 640;
const windowWidth = isMobile
? Math.min(window.innerWidth - 32, 360)
: DESKTOP_WIDTH;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const previewScale = windowWidth / previewBaseWidth;
const previewHeight = previewBaseHeight * previewScale;
const activeSubtitleStyle = subtitleStyles.find((s) => s.id === selectedSubtitleStyleId)
|| subtitleStyles.find((s) => s.is_default)
|| subtitleStyles[0];
const activeTitleStyle = titleStyles.find((s) => s.id === selectedTitleStyleId)
|| titleStyles.find((s) => s.is_default)
|| titleStyles[0];
const previewTitleText = videoTitle.trim() || "这里是标题预览";
const subtitleHighlightText = "最近一个叫Cloudbot";
const subtitleNormalText = "的开源项目在GitHub上彻底火了";
const subtitleHighlightColor = activeSubtitleStyle?.highlight_color || "#FFE600";
const subtitleNormalColor = activeSubtitleStyle?.normal_color || "#FFFFFF";
const subtitleStrokeColor = activeSubtitleStyle?.stroke_color || "#000000";
const subtitleStrokeSize = activeSubtitleStyle?.stroke_size ?? 3;
const subtitleLetterSpacing = activeSubtitleStyle?.letter_spacing ?? 2;
const subtitleFontFamilyName = `SubtitlePreview-${activeSubtitleStyle?.id || "default"}`;
const subtitleFontUrl = activeSubtitleStyle?.font_file
? resolveAssetUrl(`fonts/${activeSubtitleStyle.font_file}`)
: null;
const titleColor = activeTitleStyle?.color || "#FFFFFF";
const titleStrokeColor = activeTitleStyle?.stroke_color || "#000000";
const titleStrokeSize = activeTitleStyle?.stroke_size ?? 8;
const titleLetterSpacing = activeTitleStyle?.letter_spacing ?? 4;
const titleFontWeight = activeTitleStyle?.font_weight ?? 900;
const titleFontFamilyName = `TitlePreview-${activeTitleStyle?.id || "default"}`;
const titleFontUrl = activeTitleStyle?.font_file
? resolveAssetUrl(`fonts/${activeTitleStyle.font_file}`)
: null;
const content = (
<div
style={{
position: "fixed",
left: "16px",
top: "16px",
width: `${windowWidth}px`,
zIndex: 150,
maxHeight: "calc(100dvh - 32px)",
overflow: "hidden",
}}
className="rounded-xl border border-white/20 bg-gray-900/95 backdrop-blur-md shadow-2xl"
>
{/* 标题栏 */}
<div
className="flex items-center justify-between px-3 py-2 border-b border-white/10 select-none"
>
<div className="flex items-center gap-2 text-sm text-gray-300">
<span></span>
</div>
<button
onClick={onClose}
className="p-1 rounded hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{/* 预览内容 */}
<div
className="relative overflow-hidden rounded-b-xl"
style={{ height: `${previewHeight}px` }}
>
{(titleFontUrl || subtitleFontUrl) && (
<style>{`
${titleFontUrl ? `@font-face { font-family: '${titleFontFamilyName}'; src: url('${titleFontUrl}') format('${getFontFormat(activeTitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
${subtitleFontUrl ? `@font-face { font-family: '${subtitleFontFamilyName}'; src: url('${subtitleFontUrl}') format('${getFontFormat(activeSubtitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
`}</style>
)}
<div className="absolute inset-0 opacity-20 bg-gradient-to-br from-purple-500/40 via-transparent to-pink-500/30" />
<div
className="absolute top-0 left-0"
style={{
width: `${previewBaseWidth}px`,
height: `${previewBaseHeight}px`,
transform: `scale(${previewScale})`,
transformOrigin: 'top left',
}}
>
<div
className="w-full text-center"
style={{
position: 'absolute',
top: `${titleTopMargin}px`,
left: 0,
right: 0,
color: titleColor,
fontSize: `${titleFontSize}px`,
fontWeight: titleFontWeight,
fontFamily: titleFontUrl
? `'${titleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
textShadow: buildTextShadow(titleStrokeColor, titleStrokeSize),
letterSpacing: `${titleLetterSpacing}px`,
lineHeight: 1.2,
opacity: videoTitle.trim() ? 1 : 0.7,
padding: '0 5%',
}}
>
{previewTitleText}
</div>
<div
className="w-full text-center"
style={{
position: 'absolute',
bottom: `${subtitleBottomMargin}px`,
left: 0,
right: 0,
fontSize: `${subtitleFontSize}px`,
fontFamily: subtitleFontUrl
? `'${subtitleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
textShadow: buildTextShadow(subtitleStrokeColor, subtitleStrokeSize),
letterSpacing: `${subtitleLetterSpacing}px`,
lineHeight: 1.35,
padding: '0 6%',
}}
>
{enableSubtitles ? (
<>
<span style={{ color: subtitleHighlightColor }}>{subtitleHighlightText}</span>
<span style={{ color: subtitleNormalColor }}>{subtitleNormalText}</span>
</>
) : (
<span className="text-gray-400 text-sm"></span>
)}
</div>
</div>
</div>
</div>
);
return createPortal(content, document.body);
}

View File

@@ -65,14 +65,16 @@ export function HomePage() {
subtitleFontSize,
setSubtitleFontSize,
setSubtitleSizeLocked,
titleTopMargin,
setTitleTopMargin,
subtitleBottomMargin,
setSubtitleBottomMargin,
enableSubtitles,
setEnableSubtitles,
resolveAssetUrl,
getFontFormat,
buildTextShadow,
previewContainerWidth,
materialDimensions,
titlePreviewContainerRef,
ttsMode,
setTtsMode,
voices,
@@ -201,20 +203,17 @@ export function HomePage() {
setSubtitleFontSize(value);
setSubtitleSizeLocked(true);
}}
titleTopMargin={titleTopMargin}
onTitleTopMarginChange={setTitleTopMargin}
subtitleBottomMargin={subtitleBottomMargin}
onSubtitleBottomMarginChange={setSubtitleBottomMargin}
enableSubtitles={enableSubtitles}
onToggleSubtitles={setEnableSubtitles}
resolveAssetUrl={resolveAssetUrl}
getFontFormat={getFontFormat}
buildTextShadow={buildTextShadow}
previewScale={previewContainerWidth && (materialDimensions?.width || 1280)
? previewContainerWidth / (materialDimensions?.width || 1280)
: 1}
previewAspectRatio={materialDimensions
? `${materialDimensions.width} / ${materialDimensions.height}`
: "16 / 9"}
previewBaseWidth={materialDimensions?.width || 1280}
previewBaseHeight={materialDimensions?.height || 720}
previewContainerRef={titlePreviewContainerRef}
previewBaseWidth={materialDimensions?.width || 1080}
previewBaseHeight={materialDimensions?.height || 1920}
/>
{/* 配音方式选择 */}

View File

@@ -70,6 +70,7 @@ export function RefAudioPanel({
useEffect(() => {
if (!recordedBlob) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setRecordedUrl(null);
return;
}
@@ -162,8 +163,8 @@ export function RefAudioPanel({
className="w-full bg-black/50 text-white text-xs px-1 py-0.5 rounded border border-purple-500 focus:outline-none"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') onSaveEditing(audio.id, e as any);
if (e.key === 'Escape') onCancelEditing(e as any);
if (e.key === 'Enter') onSaveEditing(audio.id, e as unknown as MouseEvent);
if (e.key === 'Escape') onCancelEditing(e as unknown as MouseEvent);
}}
/>
<button onClick={(e) => onSaveEditing(audio.id, e)} className="text-green-400 hover:text-green-300 text-xs">

View File

@@ -16,12 +16,12 @@ export function ScriptEditor({
isGeneratingMeta,
}: ScriptEditorProps) {
return (
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<div className="flex justify-between items-center gap-2 mb-4">
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
<div className="flex flex-wrap 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">
<div className="flex gap-2 flex-shrink-0">
<button
onClick={onOpenExtractModal}
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"

View File

@@ -1,5 +1,5 @@
import type { RefObject } from "react";
import { Eye } from "lucide-react";
import { FloatingStylePreview } from "@/features/home/ui/FloatingStylePreview";
interface SubtitleStyleOption {
id: string;
@@ -48,16 +48,17 @@ interface TitleSubtitlePanelProps {
onSelectSubtitleStyle: (id: string) => void;
subtitleFontSize: number;
onSubtitleFontSizeChange: (value: number) => void;
titleTopMargin: number;
onTitleTopMarginChange: (value: number) => void;
subtitleBottomMargin: number;
onSubtitleBottomMarginChange: (value: number) => void;
enableSubtitles: boolean;
onToggleSubtitles: (value: boolean) => void;
resolveAssetUrl: (path?: string | null) => string | null;
getFontFormat: (fontFile?: string) => string;
buildTextShadow: (color: string, size: number) => string;
previewScale?: number;
previewAspectRatio?: string;
previewBaseWidth?: number;
previewBaseHeight?: number;
previewContainerRef?: RefObject<HTMLDivElement | null>;
}
export function TitleSubtitlePanel({
@@ -77,51 +78,18 @@ export function TitleSubtitlePanel({
onSelectSubtitleStyle,
subtitleFontSize,
onSubtitleFontSizeChange,
titleTopMargin,
onTitleTopMarginChange,
subtitleBottomMargin,
onSubtitleBottomMarginChange,
enableSubtitles,
onToggleSubtitles,
resolveAssetUrl,
getFontFormat,
buildTextShadow,
previewScale = 1,
previewAspectRatio = '16 / 9',
previewBaseWidth = 1280,
previewBaseHeight = 720,
previewContainerRef,
previewBaseWidth = 1080,
previewBaseHeight = 1920,
}: TitleSubtitlePanelProps) {
const activeSubtitleStyle = subtitleStyles.find((s) => s.id === selectedSubtitleStyleId)
|| subtitleStyles.find((s) => s.is_default)
|| subtitleStyles[0];
const activeTitleStyle = titleStyles.find((s) => s.id === selectedTitleStyleId)
|| titleStyles.find((s) => s.is_default)
|| titleStyles[0];
const previewTitleText = videoTitle.trim() || "这里是标题预览";
const subtitleHighlightText = "最近一个叫Cloudbot";
const subtitleNormalText = "的开源项目在GitHub上彻底火了";
const subtitleHighlightColor = activeSubtitleStyle?.highlight_color || "#FFE600";
const subtitleNormalColor = activeSubtitleStyle?.normal_color || "#FFFFFF";
const subtitleStrokeColor = activeSubtitleStyle?.stroke_color || "#000000";
const subtitleStrokeSize = activeSubtitleStyle?.stroke_size ?? 3;
const subtitleLetterSpacing = activeSubtitleStyle?.letter_spacing ?? 2;
const subtitleBottomMargin = activeSubtitleStyle?.bottom_margin ?? 0;
const subtitleFontFamilyName = `SubtitlePreview-${activeSubtitleStyle?.id || "default"}`;
const subtitleFontUrl = activeSubtitleStyle?.font_file
? resolveAssetUrl(`fonts/${activeSubtitleStyle.font_file}`)
: null;
const titleColor = activeTitleStyle?.color || "#FFFFFF";
const titleStrokeColor = activeTitleStyle?.stroke_color || "#000000";
const titleStrokeSize = activeTitleStyle?.stroke_size ?? 8;
const titleLetterSpacing = activeTitleStyle?.letter_spacing ?? 4;
const titleTopMargin = activeTitleStyle?.top_margin ?? 0;
const titleFontWeight = activeTitleStyle?.font_weight ?? 900;
const titleFontFamilyName = `TitlePreview-${activeTitleStyle?.id || "default"}`;
const titleFontUrl = activeTitleStyle?.font_file
? resolveAssetUrl(`fonts/${activeTitleStyle.font_file}`)
: null;
return (
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
<div className="flex items-center justify-between mb-4 gap-2">
@@ -138,78 +106,24 @@ export function TitleSubtitlePanel({
</div>
{showStylePreview && (
<div
ref={previewContainerRef}
className="mb-4 rounded-xl border border-white/10 bg-black/40 relative overflow-hidden"
style={{ aspectRatio: previewAspectRatio, minHeight: '180px' }}
>
{(titleFontUrl || subtitleFontUrl) && (
<style>{`
${titleFontUrl ? `@font-face { font-family: '${titleFontFamilyName}'; src: url('${titleFontUrl}') format('${getFontFormat(activeTitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
${subtitleFontUrl ? `@font-face { font-family: '${subtitleFontFamilyName}'; src: url('${subtitleFontUrl}') format('${getFontFormat(activeSubtitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
`}</style>
)}
<div className="absolute inset-0 opacity-20 bg-gradient-to-br from-purple-500/40 via-transparent to-pink-500/30" />
<div
className="absolute top-0 left-0"
style={{
width: `${previewBaseWidth}px`,
height: `${previewBaseHeight}px`,
transform: `scale(${previewScale})`,
transformOrigin: 'top left',
}}
>
<div
className="w-full text-center"
style={{
position: 'absolute',
top: `${titleTopMargin}px`,
left: 0,
right: 0,
color: titleColor,
fontSize: `${titleFontSize}px`,
fontWeight: titleFontWeight,
fontFamily: titleFontUrl
? `'${titleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
textShadow: buildTextShadow(titleStrokeColor, titleStrokeSize),
letterSpacing: `${titleLetterSpacing}px`,
lineHeight: 1.2,
opacity: videoTitle.trim() ? 1 : 0.7,
padding: '0 5%',
}}
>
{previewTitleText}
</div>
<div
className="w-full text-center"
style={{
position: 'absolute',
bottom: `${subtitleBottomMargin}px`,
left: 0,
right: 0,
fontSize: `${subtitleFontSize}px`,
fontFamily: subtitleFontUrl
? `'${subtitleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
textShadow: buildTextShadow(subtitleStrokeColor, subtitleStrokeSize),
letterSpacing: `${subtitleLetterSpacing}px`,
lineHeight: 1.35,
padding: '0 6%',
}}
>
{enableSubtitles ? (
<>
<span style={{ color: subtitleHighlightColor }}>{subtitleHighlightText}</span>
<span style={{ color: subtitleNormalColor }}>{subtitleNormalText}</span>
</>
) : (
<span className="text-gray-400 text-sm"></span>
)}
</div>
</div>
</div>
<FloatingStylePreview
onClose={onTogglePreview}
videoTitle={videoTitle}
titleStyles={titleStyles}
selectedTitleStyleId={selectedTitleStyleId}
titleFontSize={titleFontSize}
subtitleStyles={subtitleStyles}
selectedSubtitleStyleId={selectedSubtitleStyleId}
subtitleFontSize={subtitleFontSize}
titleTopMargin={titleTopMargin}
subtitleBottomMargin={subtitleBottomMargin}
enableSubtitles={enableSubtitles}
resolveAssetUrl={resolveAssetUrl}
getFontFormat={getFontFormat}
buildTextShadow={buildTextShadow}
previewBaseWidth={previewBaseWidth}
previewBaseHeight={previewBaseHeight}
/>
)}
<div className="mb-4">
@@ -249,14 +163,26 @@ export function TitleSubtitlePanel({
<label className="text-xs text-gray-400 mb-2 block">: {titleFontSize}px</label>
<input
type="range"
min="48"
max="110"
min="60"
max="150"
step="1"
value={titleFontSize}
onChange={(e) => onTitleFontSizeChange(parseInt(e.target.value, 10))}
className="w-full accent-purple-500"
/>
</div>
<div className="mt-3">
<label className="text-xs text-gray-400 mb-2 block">: {titleTopMargin}px</label>
<input
type="range"
min="0"
max="300"
step="1"
value={titleTopMargin}
onChange={(e) => onTitleTopMarginChange(parseInt(e.target.value, 10))}
className="w-full accent-purple-500"
/>
</div>
</div>
)}
@@ -284,7 +210,7 @@ export function TitleSubtitlePanel({
<label className="text-xs text-gray-400 mb-2 block">: {subtitleFontSize}px</label>
<input
type="range"
min="32"
min="40"
max="90"
step="1"
value={subtitleFontSize}
@@ -292,6 +218,18 @@ export function TitleSubtitlePanel({
className="w-full accent-purple-500"
/>
</div>
<div className="mt-3">
<label className="text-xs text-gray-400 mb-2 block">: {subtitleBottomMargin}px</label>
<input
type="range"
min="0"
max="300"
step="1"
value={subtitleBottomMargin}
onChange={(e) => onSubtitleBottomMarginChange(parseInt(e.target.value, 10))}
className="w-full accent-purple-500"
/>
</div>
</div>
)}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
import useSWR from "swr";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
@@ -6,38 +6,17 @@ import { formatDate, getApiBaseUrl, isAbsoluteUrl, resolveMediaUrl } from "@/sha
import { clampTitle } from "@/shared/lib/title";
import { useTitleInput } from "@/shared/hooks/useTitleInput";
import { useAuth } from "@/contexts/AuthContext";
interface Account {
platform: string;
name: string;
logged_in: boolean;
enabled: boolean;
}
interface Video {
name: string;
path: string;
}
export interface PublishResult {
platform: string;
success: boolean;
message: string;
url?: string | null;
screenshot_url?: string;
}
import { useTask } from "@/contexts/TaskContext";
import { toast } from "sonner";
import { usePublishPrefetch } from "@/shared/hooks/usePublishPrefetch";
import {
PublishAccount as Account,
PublishVideo as Video,
PublishResult,
} from "@/shared/types/publish";
const fetcher = (url: string) =>
api.get<ApiResponse<any>>(url).then((res) => unwrap(res.data));
const PREFETCH_KEY = "vigent_publish_prefetch_v1";
const PREFETCH_TTL = 2 * 60 * 1000;
type PublishPrefetchCache = {
ts: number;
accounts?: Account[];
videos?: Video[];
};
api.get<ApiResponse<{ success?: boolean; face_verify_qr?: string }>>(url).then((res) => unwrap(res.data));
export const usePublishController = () => {
const apiBase = getApiBaseUrl();
@@ -57,37 +36,25 @@ export const usePublishController = () => {
const [qrCodeImage, setQrCodeImage] = useState<string | null>(null);
const [qrPlatform, setQrPlatform] = useState<string | null>(null);
const [isLoadingQR, setIsLoadingQR] = useState(false);
const [faceVerifyQr, setFaceVerifyQr] = useState<string | null>(null);
// 使用全局认证状态
const { userId, isLoading: isAuthLoading } = useAuth();
// 是否已从 localStorage 恢复完成
const [isRestored, setIsRestored] = useState(false);
const { isGenerating } = useTask();
const prevIsGenerating = useRef(isGenerating);
const { readPrefetch, updatePrefetch } = usePublishPrefetch();
// ---- 视频选择持久化:用 ref 而非 state彻底避免 effect 竞态 ----
const videoRestoredRef = useRef(false);
const titleRestoredRef = useRef(false);
const getStorageKey = useCallback(() => userId || "guest", [userId]);
const titleInput = useTitleInput({
value: title,
onChange: setTitle,
});
const readPrefetch = () => {
if (typeof window === "undefined") return null;
const raw = sessionStorage.getItem(PREFETCH_KEY);
if (!raw) return null;
try {
const cache = JSON.parse(raw) as PublishPrefetchCache;
if (!cache?.ts) return null;
if (Date.now() - cache.ts > PREFETCH_TTL) return null;
return cache;
} catch {
return null;
}
};
const updatePrefetch = (patch: Partial<PublishPrefetchCache>) => {
if (typeof window === "undefined") return;
const existing = readPrefetch() || { ts: Date.now() };
const next = { ...existing, ...patch, ts: Date.now() };
sessionStorage.setItem(PREFETCH_KEY, JSON.stringify(next));
};
// ---- 数据加载 ----
const fetchAccounts = async () => {
try {
@@ -102,21 +69,20 @@ export const usePublishController = () => {
}
};
const fetchVideos = async () => {
const fetchVideos = async (autoSelectLatest = false) => {
try {
const { data: res } = await api.get<ApiResponse<{ videos: any[] }>>(
"/api/videos/generated"
);
const payload = unwrap(res);
const nextVideos = (payload.videos || []).map((v: any) => ({
id: v.id as string,
name: formatDate(v.created_at) + ` (${v.size_mb.toFixed(1)}MB)`,
path: v.path.startsWith("/") ? v.path.slice(1) : v.path,
}));
setVideos(nextVideos);
if (nextVideos.length > 0) {
setSelectedVideo(nextVideos[0].path);
if (nextVideos.length > 0 && autoSelectLatest) {
setSelectedVideo(nextVideos[0].id);
}
updatePrefetch({ videos: nextVideos });
} catch (error) {
@@ -124,91 +90,137 @@ export const usePublishController = () => {
}
};
// 初始加载
useEffect(() => {
const cache = readPrefetch();
if (cache?.accounts) {
setAccounts(cache.accounts);
setIsAccountsLoading(false);
}
if (cache?.videos) {
setVideos(cache.videos);
if (!selectedVideo && cache.videos.length > 0) {
setSelectedVideo(cache.videos[0].path);
}
setIsVideosLoading(false);
}
if (cache?.accounts) { setAccounts(cache.accounts); setIsAccountsLoading(false); }
if (cache?.videos) { setVideos(cache.videos); setIsVideosLoading(false); }
if (!cache?.accounts) setIsAccountsLoading(true);
if (!cache?.videos) setIsVideosLoading(true);
let active = true;
void Promise.allSettled([
fetchAccounts(),
fetchVideos(),
]).finally(() => {
void Promise.allSettled([fetchAccounts(), fetchVideos(false)]).finally(() => {
if (!active) return;
setIsAccountsLoading(false);
setIsVideosLoading(false);
});
return () => {
active = false;
};
return () => { active = false; };
}, []);
// ---- 视频选择恢复(唯一一个 effect条件极简 ----
// 等 auth 完成 + videos 有数据 → 恢复一次,之后再也不跑
useEffect(() => {
if (isAuthLoading || videos.length === 0 || videoRestoredRef.current) return;
videoRestoredRef.current = true;
const key = getStorageKey();
const saved = localStorage.getItem(`vigent_${key}_publish_selected_video`);
if (saved && videos.some(v => v.id === saved)) {
setSelectedVideo(saved);
} else {
setSelectedVideo(videos[0].id);
}
}, [isAuthLoading, videos, getStorageKey]);
// ---- 视频选择保存 ----
useEffect(() => {
if (!videoRestoredRef.current || !selectedVideo || isAuthLoading) return;
localStorage.setItem(`vigent_${getStorageKey()}_publish_selected_video`, selectedVideo);
}, [selectedVideo, isAuthLoading, getStorageKey]);
// ---- 任务完成 → 自动选中最新 ----
useEffect(() => {
if (prevIsGenerating.current && !isGenerating) {
void fetchVideos(true);
}
prevIsGenerating.current = isGenerating;
}, [isGenerating]);
// ---- 标题/标签恢复与保存 ----
useEffect(() => {
if (isAuthLoading || titleRestoredRef.current) return;
titleRestoredRef.current = true;
const key = getStorageKey();
const savedTitle = localStorage.getItem(`vigent_${key}_publish_title`);
const savedTags = localStorage.getItem(`vigent_${key}_publish_tags`);
if (savedTitle) setTitle(clampTitle(savedTitle));
if (savedTags) {
try {
const parsed = JSON.parse(savedTags);
setTags(Array.isArray(parsed) ? parsed.join(", ") : savedTags);
} catch { setTags(savedTags); }
}
}, [isAuthLoading, getStorageKey]);
useEffect(() => {
if (!titleRestoredRef.current || isAuthLoading) return;
const key = getStorageKey();
const t = setTimeout(() => localStorage.setItem(`vigent_${key}_publish_title`, title), 300);
return () => clearTimeout(t);
}, [title, isAuthLoading, getStorageKey]);
useEffect(() => {
if (!titleRestoredRef.current || isAuthLoading) return;
const key = getStorageKey();
const t = setTimeout(() => localStorage.setItem(`vigent_${key}_publish_tags`, tags), 300);
return () => clearTimeout(t);
}, [tags, isAuthLoading, getStorageKey]);
// ---- 页面滚动 ----
useEffect(() => {
if (typeof window === "undefined") return;
if ("scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual";
}
if ("scrollRestoration" in window.history) window.history.scrollRestoration = "manual";
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
}, []);
// 获取存储 key 的前缀(登录用户使用 userId未登录使用 guest
const storageKey = userId || "guest";
// 从 localStorage 恢复用户输入(等待认证完成后)
// ---- 发布防误操作 ----
useEffect(() => {
if (isAuthLoading) return;
if (!isPublishing) return;
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "发布进行中,请勿刷新页面";
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isPublishing]);
// 从 localStorage 恢复用户输入(带用户隔离,未登录用户使用 guest
const savedTitle = localStorage.getItem(`vigent_${storageKey}_publish_title`);
const savedTags = localStorage.getItem(`vigent_${storageKey}_publish_tags`);
if (savedTitle) setTitle(clampTitle(savedTitle));
if (savedTags) {
// 兼容 JSON 数组格式AI 生成)和字符串格式(手动输入)
try {
const parsed = JSON.parse(savedTags);
if (Array.isArray(parsed)) {
setTags(parsed.join(", "));
} else {
setTags(savedTags);
// ---- SWR Polling for Login Status ----
useSWR(
qrPlatform ? `${apiBase}/api/publish/login/status/${qrPlatform}` : null,
fetcher,
{
refreshInterval: 2000,
onSuccess: (data) => {
if (data.success) {
setQrCodeImage(null);
setQrPlatform(null);
setFaceVerifyQr(null);
toast.success("✅ 登录成功!");
fetchAccounts();
} else if (data.face_verify_qr && !faceVerifyQr) {
// 刷脸验证:后端捕获了新二维码页面截图
setFaceVerifyQr(data.face_verify_qr);
}
} catch {
setTags(savedTags);
}
},
}
// 恢复完成后才允许保存
setIsRestored(true);
}, [storageKey, isAuthLoading]);
// 保存用户输入到 localStorage恢复完成后才保存未登录用户也可保存
useEffect(() => {
if (!isRestored) return;
const timeout = setTimeout(() => {
localStorage.setItem(`vigent_${storageKey}_publish_title`, title);
}, 300);
return () => clearTimeout(timeout);
}, [title, storageKey, isRestored]);
);
useEffect(() => {
if (!isRestored) return;
const timeout = setTimeout(() => {
localStorage.setItem(`vigent_${storageKey}_publish_tags`, tags);
}, 300);
return () => clearTimeout(timeout);
}, [tags, storageKey, isRestored]);
let timer: NodeJS.Timeout;
if (qrPlatform) {
timer = setTimeout(() => {
if (qrPlatform) {
setQrPlatform(null);
setQrCodeImage(null);
toast.error("登录超时,请重试");
}
}, 180000);
}
return () => clearTimeout(timer);
}, [qrPlatform]);
// ---- 操作函数 ----
const togglePlatform = (platform: string) => {
if (selectedPlatforms.includes(platform)) {
@@ -220,96 +232,40 @@ export const usePublishController = () => {
const handlePublish = async () => {
if (!selectedVideo || !title || selectedPlatforms.length === 0) {
alert("请选择视频、填写标题并选择至少一个平台");
toast.error("请选择视频、填写标题并选择至少一个平台");
return;
}
const video = videos.find(v => v.id === selectedVideo);
if (!video) {
toast.error("未找到选中的视频");
return;
}
setIsPublishing(true);
setPublishResults([]);
const tagList = tags.split(/[,\s]+/).filter((t) => t.trim());
for (const platform of selectedPlatforms) {
try {
const { data: res } = await api.post<ApiResponse<any>>("/api/publish", {
video_path: selectedVideo,
platform,
title,
tags: tagList,
description: "",
video_path: video.path, platform, title, tags: tagList, description: "",
});
const result = unwrap(res);
const screenshotUrl =
typeof result.screenshot_url === "string"
? resolveMediaUrl(result.screenshot_url) || result.screenshot_url
: undefined;
const nextResult: PublishResult = {
const screenshotUrl = typeof result.screenshot_url === "string"
? resolveMediaUrl(result.screenshot_url) || result.screenshot_url : undefined;
setPublishResults((prev) => [...prev, {
platform: result.platform || platform,
success: Boolean(result.success),
message: result.message || "",
url: result.url,
screenshot_url: screenshotUrl,
};
setPublishResults((prev) => [...prev, nextResult]);
}]);
} catch (error: any) {
const message = error.response?.data?.message || String(error);
setPublishResults((prev) => [
...prev,
{ platform, success: false, message },
]);
setPublishResults((prev) => [...prev, { platform, success: false, message }]);
}
}
setIsPublishing(false);
};
useEffect(() => {
if (!isPublishing) return;
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "发布进行中,请勿刷新页面";
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [isPublishing]);
// SWR Polling for Login Status
useSWR(
qrPlatform ? `${apiBase}/api/publish/login/status/${qrPlatform}` : null,
fetcher,
{
refreshInterval: 2000,
onSuccess: (data) => {
if (data.success) {
setQrCodeImage(null);
setQrPlatform(null);
alert("✅ 登录成功!");
fetchAccounts();
}
},
}
);
// Timeout logic for QR code (business logic: stop after 2 mins)
useEffect(() => {
let timer: NodeJS.Timeout;
if (qrPlatform) {
timer = setTimeout(() => {
if (qrPlatform) {
setQrPlatform(null);
setQrCodeImage(null);
alert("登录超时,请重试");
}
}, 120000);
}
return () => clearTimeout(timer);
}, [qrPlatform]);
const handleLogin = async (platform: string) => {
setIsLoadingQR(true);
setQrPlatform(platform);
@@ -317,16 +273,15 @@ export const usePublishController = () => {
try {
const { data: res } = await api.post<ApiResponse<any>>(`/api/publish/login/${platform}`);
const result = unwrap(res);
if (result.success && result.qr_code) {
setQrCodeImage(result.qr_code);
} else {
setQrPlatform(null);
alert(result.message || "登录失败");
toast.error(result.message || "登录失败");
}
} catch (error: any) {
setQrPlatform(null);
alert(`登录失败: ${error.response?.data?.message || error.message}`);
toast.error(`登录失败: ${error.response?.data?.message || error.message}`);
} finally {
setIsLoadingQR(false);
}
@@ -337,14 +292,10 @@ export const usePublishController = () => {
try {
const { data: res } = await api.post<ApiResponse<any>>(`/api/publish/logout/${platform}`);
const result = unwrap(res);
if (result.success) {
alert("注销");
fetchAccounts();
} else {
alert(result.message || "注销失败");
}
if (result.success) { toast.success("已注销"); fetchAccounts(); }
else { toast.error(result.message || "注销失败"); }
} catch (error: any) {
alert(`注销失败: ${error.response?.data?.message || error.message}`);
toast.error(`注销失败: ${error.response?.data?.message || error.message}`);
}
};
@@ -361,51 +312,27 @@ export const usePublishController = () => {
return videos.filter((v) => v.name.toLowerCase().includes(query));
}, [videos, videoFilter]);
const handlePreviewVideo = (path: string) => {
const previewPath = isAbsoluteUrl(path)
? path
: path.startsWith("/")
? path
: `/${path}`;
const handlePreviewVideo = (videoId: string) => {
const video = videos.find(v => v.id === videoId);
if (!video) return;
const previewPath = isAbsoluteUrl(video.path) ? video.path : video.path.startsWith("/") ? video.path : `/${video.path}`;
setPreviewVideoUrl(resolveMediaUrl(previewPath) || previewPath);
};
const closeQrModal = () => {
setQrCodeImage(null);
setQrPlatform(null);
setFaceVerifyQr(null);
};
return {
apiBase,
accounts,
videos,
isAccountsLoading,
isVideosLoading,
selectedVideo,
setSelectedVideo,
videoFilter,
setVideoFilter,
previewVideoUrl,
setPreviewVideoUrl,
selectedPlatforms,
title,
titleInput,
tags,
setTags,
isPublishing,
publishResults,
qrCodeImage,
qrPlatform,
isLoadingQR,
fetchAccounts,
fetchVideos,
togglePlatform,
handlePublish,
handleLogin,
handleLogout,
platformIcons,
filteredVideos,
handlePreviewVideo,
closeQrModal,
apiBase, accounts, videos, isAccountsLoading, isVideosLoading,
selectedVideo, setSelectedVideo, videoFilter, setVideoFilter,
previewVideoUrl, setPreviewVideoUrl, selectedPlatforms,
title, titleInput, tags, setTags,
isPublishing, publishResults, qrCodeImage, qrPlatform, isLoadingQR, faceVerifyQr,
fetchAccounts, fetchVideos, togglePlatform, handlePublish,
handleLogin, handleLogout, platformIcons, filteredVideos,
handlePreviewVideo, closeQrModal,
};
};

View File

@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import VideoPreviewModal from "@/components/VideoPreviewModal";
import AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
import { usePublishController } from "@/features/publish/model/usePublishController";
@@ -11,6 +12,7 @@ import {
QrCode,
Search,
Eye,
Loader2,
} from "lucide-react";
export function PublishPage() {
@@ -34,6 +36,7 @@ export function PublishPage() {
qrCodeImage,
qrPlatform,
isLoadingQR,
faceVerifyQr,
togglePlatform,
handlePublish,
handleLogin,
@@ -61,12 +64,29 @@ export function PublishPage() {
<div className="animate-spin w-16 h-16 border-4 border-purple-500 border-t-transparent rounded-full" />
<p className="text-gray-600 mt-4">...</p>
</div>
) : faceVerifyQr ? (
<>
<Image
src={`data:image/png;base64,${faceVerifyQr}`}
alt="Face Verify QR"
width={400}
height={300}
className="w-full h-auto rounded-lg"
unoptimized
/>
<p className="text-center text-orange-600 font-medium mt-4">
APP扫描上方二维码完成刷脸验证
</p>
</>
) : qrCodeImage ? (
<>
<img
<Image
src={`data:image/png;base64,${qrCodeImage}`}
alt="QR Code"
width={280}
height={280}
className="w-full h-auto"
unoptimized
/>
<p className="text-center text-gray-600 mt-4">
使
@@ -145,9 +165,11 @@ export function PublishPage() {
>
<div className="flex items-center gap-3">
{platformIcons[account.platform] ? (
<img
<Image
src={platformIcons[account.platform].src}
alt={platformIcons[account.platform].alt}
width={28}
height={28}
className="h-7 w-7"
/>
) : (
@@ -239,9 +261,9 @@ export function PublishPage() {
<div className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar" style={{ contentVisibility: "auto" }}>
{filteredVideos.map((v) => (
<div
key={v.path}
onClick={() => setSelectedVideo(v.path)}
className={`p-3 rounded-lg border transition-all flex items-center justify-between group cursor-pointer ${selectedVideo === v.path
key={v.id}
onClick={() => setSelectedVideo(v.id)}
className={`p-3 rounded-lg border transition-all flex items-center justify-between group cursor-pointer ${selectedVideo === v.id
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
@@ -253,7 +275,7 @@ export function PublishPage() {
<button
onClick={(e) => {
e.stopPropagation();
handlePreviewVideo(v.path);
handlePreviewVideo(v.id);
}}
onMouseEnter={() => {
const src = v.path.startsWith("/") ? v.path : `/${v.path}`;
@@ -269,7 +291,7 @@ export function PublishPage() {
>
<Eye className="h-4 w-4" />
</button>
{selectedVideo === v.path && (
{selectedVideo === v.id && (
<span className="text-xs text-purple-300"></span>
)}
</div>
@@ -331,9 +353,11 @@ export function PublishPage() {
>
<span className="block mb-1">
{platformIcons[account.platform] ? (
<img
<Image
src={platformIcons[account.platform].src}
alt={platformIcons[account.platform].alt}
width={28}
height={28}
className="h-7 w-7 mx-auto"
/>
) : (
@@ -352,7 +376,12 @@ export function PublishPage() {
disabled={isPublishing || selectedPlatforms.length === 0}
className="w-full py-4 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-bold text-lg hover:shadow-lg hover:from-purple-500 hover:to-pink-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPublishing ? "正在发布...请勿刷新或关闭网页" : "立即发布"}
{isPublishing ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
...
</span>
) : "立即发布"}
</button>
{/* 发布结果 */}
@@ -367,15 +396,17 @@ export function PublishPage() {
}`}
>
<div className="flex items-center gap-2 mb-1">
{platformIcons[result.platform] ? (
<img
src={platformIcons[result.platform].src}
alt={platformIcons[result.platform].alt}
className="h-5 w-5"
/>
) : (
<span className="text-lg">🌐</span>
)}
{platformIcons[result.platform] ? (
<Image
src={platformIcons[result.platform].src}
alt={platformIcons[result.platform].alt}
width={20}
height={20}
className="h-5 w-5"
/>
) : (
<span className="text-lg">🌐</span>
)}
<span className={`font-medium ${result.success ? "text-green-400" : "text-red-400"}`}>
{result.success ? "发布成功" : "发布失败"}
</span>
@@ -390,10 +421,13 @@ export function PublishPage() {
rel="noreferrer"
className="block"
>
<img
<Image
src={result.screenshot_url}
alt="发布成功截图"
width={400}
height={300}
className="w-full rounded-md border border-white/10"
unoptimized
/>
</a>
</div>

View File

@@ -37,7 +37,7 @@ api.interceptors.response.use(
// 调用 logout API 清除 HttpOnly cookie
try {
await fetch('/api/auth/logout', { method: 'POST' });
} catch (e) {
} catch {
// 忽略错误
}

View File

@@ -0,0 +1,53 @@
import { useCallback } from "react";
import { PublishPrefetchCache } from "@/shared/types/publish";
const PREFETCH_KEY = "vigent_publish_prefetch_v1";
const PREFETCH_TTL = 2 * 60 * 1000; // 2 分钟
/**
* 发布预加载缓存 hook
* 用于在首页预加载发布页所需的账号和视频数据
*/
export const usePublishPrefetch = () => {
const readPrefetch = useCallback((): PublishPrefetchCache | null => {
if (typeof window === "undefined") return null;
const raw = sessionStorage.getItem(PREFETCH_KEY);
if (!raw) return null;
try {
const cache = JSON.parse(raw) as PublishPrefetchCache;
if (!cache?.ts) return null;
if (Date.now() - cache.ts > PREFETCH_TTL) return null;
return cache;
} catch {
return null;
}
}, []);
const updatePrefetch = useCallback((patch: Partial<PublishPrefetchCache>) => {
if (typeof window === "undefined") return;
const existing = (() => {
const raw = sessionStorage.getItem(PREFETCH_KEY);
if (!raw) return { ts: Date.now() };
try {
const cache = JSON.parse(raw) as PublishPrefetchCache;
if (!cache?.ts || Date.now() - cache.ts > PREFETCH_TTL) return { ts: Date.now() };
return cache;
} catch {
return { ts: Date.now() };
}
})();
const next = { ...existing, ...patch, ts: Date.now() };
sessionStorage.setItem(PREFETCH_KEY, JSON.stringify(next));
}, []);
const clearPrefetch = useCallback(() => {
if (typeof window === "undefined") return;
sessionStorage.removeItem(PREFETCH_KEY);
}, []);
return {
readPrefetch,
updatePrefetch,
clearPrefetch,
};
};

View File

@@ -1,15 +1,13 @@
/**
* 认证工具函数
* 统一使用 axios 实例,与其他 API 调用保持一致的错误处理
*/
import api from "@/shared/api/axios";
import { User } from "@/shared/types/user";
// Re-export User 类型以保持向后兼容
export type { User };
const API_BASE = typeof window === 'undefined'
? (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8006')
: '';
export interface AuthResponse {
success: boolean;
message: string;
@@ -27,58 +25,38 @@ interface ApiResponse<T> {
* 用户注册
*/
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({ phone, password, username })
const { data: payload } = await api.post<ApiResponse<null>>('/api/auth/register', {
phone, password, username
});
const payload = await res.json();
const data = payload as ApiResponse<null>;
return { success: data.success, message: data.message };
return { success: payload.success, message: payload.message };
}
/**
* 用户登录
*/
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({ phone, password })
const { data: payload } = await api.post<ApiResponse<{ user?: User }>>('/api/auth/login', {
phone, password
});
const payload = await res.json();
const data = payload as ApiResponse<{ user?: User }>;
return { success: data.success, message: data.message, user: data.data?.user };
return { success: payload.success, message: payload.message, user: payload.data?.user };
}
/**
* 用户登出
*/
export async function logout(): Promise<AuthResponse> {
const res = await fetch(`${API_BASE}/api/auth/logout`, {
method: 'POST',
credentials: 'include'
});
const payload = await res.json();
const data = payload as ApiResponse<null>;
return { success: data.success, message: data.message };
const { data: payload } = await api.post<ApiResponse<null>>('/api/auth/logout');
return { success: payload.success, message: payload.message };
}
/**
* 修改密码
*/
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 })
const { data: payload } = await api.post<ApiResponse<null>>('/api/auth/change-password', {
old_password: oldPassword, new_password: newPassword
});
const payload = await res.json();
const data = payload as ApiResponse<null>;
return { success: data.success, message: data.message };
return { success: payload.success, message: payload.message };
}
/**
@@ -86,13 +64,8 @@ export async function changePassword(oldPassword: string, newPassword: string):
*/
export async function getCurrentUser(): Promise<User | null> {
try {
const res = await fetch(`${API_BASE}/api/auth/me`, {
credentials: 'include'
});
if (!res.ok) return null;
const payload = await res.json();
const data = payload as ApiResponse<User>;
return data.data || null;
const { data: payload } = await api.get<ApiResponse<User>>('/api/auth/me');
return payload.data || null;
} catch {
return null;
}

View File

@@ -0,0 +1,35 @@
/**
* 发布相关共用类型定义
* 用于 useHomeController 和 usePublishController 之间共享
*/
/** 发布平台账号 */
export interface PublishAccount {
platform: string;
name: string;
logged_in: boolean;
enabled: boolean;
}
/** 可发布的视频 */
export interface PublishVideo {
id: string;
name: string;
path: string;
}
/** 发布预加载缓存 */
export interface PublishPrefetchCache {
ts: number;
accounts?: PublishAccount[];
videos?: PublishVideo[];
}
/** 发布结果 */
export interface PublishResult {
platform: string;
success: boolean;
message: string;
url?: string | null;
screenshot_url?: string;
}

View File

@@ -134,8 +134,14 @@ async function main() {
// Bundle the Remotion project
console.log('Bundling Remotion project...');
// 修复: 使用 process.cwd() 解析 src/index.ts确保在 dist/render.js 和 ts-node 下都能找到
// 假设脚本总是在 remotion 根目录下运行 (由 python service 保证)
const entryPoint = path.resolve(process.cwd(), 'src/index.ts');
console.log(`Entry point: ${entryPoint}`);
const bundleLocation = await bundle({
entryPoint: path.resolve(__dirname, './src/index.ts'),
entryPoint,
webpackOverride: (config) => config,
publicDir,
});

View File

@@ -10,5 +10,13 @@ export DOUYIN_RECORD_VIDEO=false
PORT=${PORT:-8006}
cd "$BASE_DIR/backend"
# 加载 .env 文件(让 os.getenv 也能读到)
if [ -f .env ]; then
set -a
source .env
set +a
fi
exec xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
./venv/bin/uvicorn app.main:app --host 0.0.0.0 --port "$PORT"