This commit is contained in:
Kevin Wong
2026-02-08 16:23:39 +08:00
parent 1a291a03b8
commit ee342cc40f
24 changed files with 1414 additions and 1082 deletions

View File

@@ -29,15 +29,27 @@ backend/
├── app/
│ ├── core/ # config、deps、security、response
│ ├── modules/ # 业务模块(路由 + 逻辑)
│ │ ├── videos/
│ │ ├── materials/
│ │ ├── publish/
│ │ ├── auth/
│ │ ── ...
│ │ ├── 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
```
@@ -84,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. 代码约定
@@ -110,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

@@ -15,11 +15,22 @@ backend/
├── app/
│ ├── 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 # 依赖清单
```
@@ -41,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`: 获取素材列表
@@ -54,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
@@ -79,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

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

@@ -65,6 +65,15 @@ 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)
#### 严重缺陷修复
@@ -85,6 +94,10 @@ pm2 restart vigent2-latentsync
- *原因*: 重构移除旧逻辑后,新用户或无缓存用户进入页面无默认选中。
- *修复*: 在 `isRestored` 且无选中时,增加兜底逻辑自动选中列表第一项。
- [x] **REF-1**: 持久化逻辑全站收敛
- *优化*: 清理 `useBgm`, `useGeneratedVideos`, `useTitleSubtitleStyles` 中的冗余 `localStorage` 读取
- *优化*: 修复 `useMaterials` 中的闭包陷阱(使用函数式更新),防止覆盖已恢复的状态。
- [x] **REG-3**: 素材选择持久化失效 (闭包陷阱)
- *原因*: `useMaterials` 加载回调中捕获了旧的 `selectedMaterial` 状态,覆盖了已恢复的值
- *修复*: 改为函数式状态更新 (`setState(prev => ...)`),确保基于最新状态判断。
- [x] **REF-1**: 持久化逻辑全站收敛与排查
- *优化*: 清理 `useBgm`, `useGeneratedVideos`, `useTitleSubtitleStyles` 中的冗余 `localStorage` 读取,统一由 `useHomePersistence` 管理。
- *排查*: 深度排查 `useRefAudios`, `useTitleSubtitleStyles` 等模块,确认逻辑健壮,无类似回归风险。

View File

@@ -90,3 +90,159 @@ await api.post('/api/publish', { video_path: video.path, ... });
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 # 认证相关函数(统一使用 axios
└── 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 等)
## 类型定义规范

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

@@ -58,7 +58,7 @@
- **[部署手册 (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) - 接口规范与开发流程。
@@ -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

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

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

@@ -253,11 +253,19 @@ class PublishService:
# 获取用户专属的 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
# 启动登录并获取二维码
@@ -273,10 +281,11 @@ 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()
@@ -286,14 +295,14 @@ class PublishService:
del self.active_login_sessions[session_key]
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
# 2. 检查本地Cookie文件是否存在
cookie_file = self._get_cookie_path(platform, user_id)
if cookie_file.exists():
return {"success": True, "message": "已登录 (历史状态)"}
return {"success": False, "message": "未登录"}
# 没有活跃会话 → 返回 False前端不应在无会话时轮询
return {"success": False, "message": "无活跃登录会话"}
def logout(self, platform: str, user_id: Optional[str] = None) -> Dict[str, Any]:
"""

View File

@@ -17,7 +17,7 @@ class QRLoginService:
"""QR码登录服务"""
# 登录监控超时 (秒)
LOGIN_TIMEOUT = 120
LOGIN_TIMEOUT = 180
def __init__(self, platform: str, cookies_dir: Path) -> None:
self.platform = platform
@@ -31,6 +31,14 @@ class QRLoginService:
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": {
@@ -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:
@@ -180,36 +193,56 @@ 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()
@@ -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"
@@ -266,19 +353,47 @@ class QRLoginService:
async def _extract_qr_code(self, page: Page, selectors: List[str]) -> Optional[str]:
"""
提取二维码图片 (优化策略顺序)
根据日志分析抖音和B站使用 Text 策略成功率最高
抖音CSS 优先Text 策略每次超时 15 秒)
B站Text 优先
其他CSS -> Text
"""
qr_element = None
# 针对抖音和B站优先使用 Text 策略 (成功率最高,速度最快)
if self.platform in ("douyin", "bilibili"):
# 尝试最多2次 (首次 + 1次重试)
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)
# 策略1: Text (优先,成功率最高)
qr_element = await self._try_text_strategy(page)
if qr_element:
try:
@@ -288,23 +403,17 @@ class QRLoginService:
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秒抖音页面加载较慢
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}] 策略2(CSS): 匹配成功")
logger.info(f"[{self.platform}] 策略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
logger.warning(f"[{self.platform}] 策略CSS 失败: {e}")
else:
# 其他平台 (小红书/微信等):保持原顺序 CSS -> Text
# 策略1: CSS 选择器
@@ -347,11 +456,6 @@ class QRLoginService:
# 所有策略失败
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
async def _try_text_strategy(self, page: Union[Page, Frame]) -> Optional[Any]:
@@ -391,53 +495,176 @@ class QRLoginService:
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, [])
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:
if not self.context: break # 避免意外关闭
cookies = [dict(cookie) for cookie in await self.context.cookies()]
# ── 检查 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
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}")
# 每10秒打一次日志
if i % 10 == 0:
logger.info(
f"[{self.platform}] 等待登录... i={i} "
f"URL={current_url[:80]} session={has_session} "
f"cookies={len(cookies)}"
)
if success_url in current_url or has_cookie:
logger.success(f"[{self.platform}] 登录成功!")
# ── 成功条件:有 session cookie ──
if has_session:
logger.success(f"[{self.platform}] 登录成功检测到session cookie")
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)
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}")
break
logger.warning(f"[{self.platform}] 监控异常: {e}")
if not self.login_success:
logger.warning(f"[{self.platform}] 登录超时")
@@ -499,7 +726,11 @@ class QRLoginService:
def get_login_status(self) -> Dict[str, Any]:
"""获取登录状态"""
return {
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

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

@@ -99,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>("");
@@ -124,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>>({});
@@ -354,6 +356,10 @@ export const useHomeController = () => {
setTitleFontSize,
setSubtitleSizeLocked,
setTitleSizeLocked,
titleTopMargin,
setTitleTopMargin,
subtitleBottomMargin,
setSubtitleBottomMargin,
selectedBgmId,
setSelectedBgmId,
bgmVolume,
@@ -446,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;
@@ -707,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;
@@ -810,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,15 @@ 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);
}, [
@@ -149,6 +168,8 @@ export const useHomePersistence = ({
setText,
setTitleFontSize,
setTitleSizeLocked,
setTitleTopMargin,
setSubtitleBottomMargin,
setTtsMode,
setVideoTitle,
setVoice,
@@ -213,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

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

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

@@ -16,7 +16,7 @@ import {
} from "@/shared/types/publish";
const fetcher = (url: string) =>
api.get<ApiResponse<{ success?: boolean }>>(url).then((res) => unwrap(res.data));
api.get<ApiResponse<{ success?: boolean; face_verify_qr?: string }>>(url).then((res) => unwrap(res.data));
export const usePublishController = () => {
const apiBase = getApiBaseUrl();
@@ -36,6 +36,7 @@ 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();
const { isGenerating } = useTask();
@@ -194,8 +195,12 @@ export const usePublishController = () => {
if (data.success) {
setQrCodeImage(null);
setQrPlatform(null);
setFaceVerifyQr(null);
toast.success("✅ 登录成功!");
fetchAccounts();
} else if (data.face_verify_qr && !faceVerifyQr) {
// 刷脸验证:后端捕获了新二维码页面截图
setFaceVerifyQr(data.face_verify_qr);
}
},
}
@@ -210,7 +215,7 @@ export const usePublishController = () => {
setQrCodeImage(null);
toast.error("登录超时,请重试");
}
}, 120000);
}, 180000);
}
return () => clearTimeout(timer);
}, [qrPlatform]);
@@ -317,6 +322,7 @@ export const usePublishController = () => {
const closeQrModal = () => {
setQrCodeImage(null);
setQrPlatform(null);
setFaceVerifyQr(null);
};
return {
@@ -324,7 +330,7 @@ export const usePublishController = () => {
selectedVideo, setSelectedVideo, videoFilter, setVideoFilter,
previewVideoUrl, setPreviewVideoUrl, selectedPlatforms,
title, titleInput, tags, setTags,
isPublishing, publishResults, qrCodeImage, qrPlatform, isLoadingQR,
isPublishing, publishResults, qrCodeImage, qrPlatform, isLoadingQR, faceVerifyQr,
fetchAccounts, fetchVideos, togglePlatform, handlePublish,
handleLogin, handleLogout, platformIcons, filteredVideos,
handlePreviewVideo, closeQrModal,

View File

@@ -36,6 +36,7 @@ export function PublishPage() {
qrCodeImage,
qrPlatform,
isLoadingQR,
faceVerifyQr,
togglePlatform,
handlePublish,
handleLogin,
@@ -63,6 +64,20 @@ 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 ? (
<>
<Image

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"