From ee342cc40f50787af9f437d8dcff8f6dcb0a48fd Mon Sep 17 00:00:00 2001 From: Kevin Wong Date: Sun, 8 Feb 2026 16:23:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Docs/BACKEND_DEV.md | 50 +- Docs/BACKEND_README.md | 46 +- Docs/DEPLOY_MANUAL.md | 22 +- Docs/DevLogs/Day20.md | 19 +- Docs/DevLogs/Day21.md | 156 ++++ Docs/Doc_Rules.md | 71 +- Docs/FRONTEND_DEV.md | 84 +- Docs/implementation_plan.md | 427 --------- README.md | 50 +- backend/app/core/config.py | 2 +- backend/app/modules/videos/schemas.py | 2 + backend/app/modules/videos/workflow.py | 10 + backend/app/services/publish_service.py | 127 +-- backend/app/services/qr_login_service.py | 831 +++++++++++------- .../app/services/uploader/weixin_uploader.py | 57 +- .../features/home/model/useHomeController.ts | 47 +- .../features/home/model/useHomePersistence.ts | 33 + .../features/home/ui/FloatingStylePreview.tsx | 226 +++++ frontend/src/features/home/ui/HomePage.tsx | 21 +- .../src/features/home/ui/ScriptEditor.tsx | 6 +- .../features/home/ui/TitleSubtitlePanel.tsx | 174 ++-- .../publish/model/usePublishController.ts | 12 +- .../src/features/publish/ui/PublishPage.tsx | 15 + run_backend.sh | 8 + 24 files changed, 1414 insertions(+), 1082 deletions(-) delete mode 100644 Docs/implementation_plan.md create mode 100644 frontend/src/features/home/ui/FloatingStylePreview.tsx diff --git a/Docs/BACKEND_DEV.md b/Docs/BACKEND_DEV.md index 68a71c6..4dbc96e 100644 --- a/Docs/BACKEND_DEV.md +++ b/Docs/BACKEND_DEV.md @@ -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) --- diff --git a/Docs/BACKEND_README.md b/Docs/BACKEND_README.md index 2fea4ad..935e2b4 100644 --- a/Docs/BACKEND_README.md +++ b/Docs/BACKEND_README.md @@ -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 音色 ID(edgetts 模式) +- `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) diff --git a/Docs/DEPLOY_MANUAL.md b/Docs/DEPLOY_MANUAL.md index 2aadcc5..11f0aa6 100644 --- a/Docs/DEPLOY_MANUAL.md +++ b/Docs/DEPLOY_MANUAL.md @@ -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 diff --git a/Docs/DevLogs/Day20.md b/Docs/DevLogs/Day20.md index 571d56b..58eedf7 100644 --- a/Docs/DevLogs/Day20.md +++ b/Docs/DevLogs/Day20.md @@ -65,6 +65,15 @@ pm2 restart vigent2-latentsync # Remotion 已自动编译 ``` +### 🎨 交互与体验优化 (17:00) + +- [x] **UX-1**: PublishPage 图片加载优化 (`` → `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` 等模块,确认逻辑健壮,无类似回归风险。 diff --git a/Docs/DevLogs/Day21.md b/Docs/DevLogs/Day21.md index 5751270..ce5f599 100644 --- a/Docs/DevLogs/Day21.md +++ b/Docs/DevLogs/Day21.md @@ -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) +- 条件渲染 ``,按钮文本保持"预览样式"/"收起预览" +- 移除 `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 ? ( + <> + +

需要身份验证,请用抖音APP扫描上方二维码完成刷脸验证

+ +) : /* 普通登录二维码 */ } +``` + +--- + +### 四、微信视频号发布流程优化 + +#### 修复 — `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 +``` diff --git a/Docs/Doc_Rules.md b/Docs/Doc_Rules.md index e54f078..6081422 100644 --- a/Docs/Doc_Rules.md +++ b/Docs/Doc_Rules.md @@ -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 < **修正 (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] -> 请确认以上计划是否符合你的需求,有任何需要调整的地方请告诉我。 diff --git a/README.md b/README.md index 4bc9b3e..2b066ea 100644 --- a/README.md +++ b/README.md @@ -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/ # 声音克隆服务 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 06a8be1..31c1156 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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" diff --git a/backend/app/modules/videos/schemas.py b/backend/app/modules/videos/schemas.py index 5ddd76d..de27491 100644 --- a/backend/app/modules/videos/schemas.py +++ b/backend/app/modules/videos/schemas.py @@ -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 diff --git a/backend/app/modules/videos/workflow.py b/backend/app/modules/videos/workflow.py index 68e94a9..0166224 100644 --- a/backend/app/modules/videos/workflow.py +++ b/backend/app/modules/videos/workflow.py @@ -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, diff --git a/backend/app/services/publish_service.py b/backend/app/services/publish_service.py index 938ab12..6e49526 100644 --- a/backend/app/services/publish_service.py +++ b/backend/app/services/publish_service.py @@ -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]: """ diff --git a/backend/app/services/qr_login_service.py b/backend/app/services/qr_login_service.py index dffbdaf..a5746fe 100644 --- a/backend/app/services/qr_login_service.py +++ b/backend/app/services/qr_login_service.py @@ -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 diff --git a/backend/app/services/uploader/weixin_uploader.py b/backend/app/services/uploader/weixin_uploader.py index 1544556..41a8b63 100644 --- a/backend/app/services/uploader/weixin_uploader.py +++ b/backend/app/services/uploader/weixin_uploader.py @@ -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 diff --git a/frontend/src/features/home/model/useHomeController.ts b/frontend/src/features/home/model/useHomeController.ts index cb431e0..7e85655 100644 --- a/frontend/src/features/home/model/useHomeController.ts +++ b/frontend/src/features/home/model/useHomeController.ts @@ -99,13 +99,15 @@ export const useHomeController = () => { const [enableSubtitles, setEnableSubtitles] = useState(true); const [selectedSubtitleStyleId, setSelectedSubtitleStyleId] = useState(""); const [selectedTitleStyleId, setSelectedTitleStyleId] = useState(""); - const [subtitleFontSize, setSubtitleFontSize] = useState(60); - const [titleFontSize, setTitleFontSize] = useState(90); + const [subtitleFontSize, setSubtitleFontSize] = useState(80); + const [titleFontSize, setTitleFontSize] = useState(120); const [subtitleSizeLocked, setSubtitleSizeLocked] = useState(false); const [titleSizeLocked, setTitleSizeLocked] = useState(false); + const [titleTopMargin, setTitleTopMargin] = useState(62); + const [subtitleBottomMargin, setSubtitleBottomMargin] = useState(80); const [showStylePreview, setShowStylePreview] = useState(false); const [materialDimensions, setMaterialDimensions] = useState<{ width: number; height: number } | null>(null); - const [previewContainerWidth, setPreviewContainerWidth] = useState(0); + // 背景音乐相关状态 const [selectedBgmId, setSelectedBgmId] = useState(""); @@ -124,7 +126,7 @@ export const useHomeController = () => { const [editMaterialName, setEditMaterialName] = useState(""); const bgmItemRefs = useRef>({}); const bgmListContainerRef = useRef(null); - const titlePreviewContainerRef = useRef(null); + const materialItemRefs = useRef>({}); const videoItemRefs = useRef>({}); @@ -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, diff --git a/frontend/src/features/home/model/useHomePersistence.ts b/frontend/src/features/home/model/useHomePersistence.ts index 4aca830..78cc99d 100644 --- a/frontend/src/features/home/model/useHomePersistence.ts +++ b/frontend/src/features/home/model/useHomePersistence.ts @@ -35,6 +35,10 @@ interface UseHomePersistenceOptions { setTitleFontSize: React.Dispatch>; setSubtitleSizeLocked: React.Dispatch>; setTitleSizeLocked: React.Dispatch>; + titleTopMargin: number; + setTitleTopMargin: React.Dispatch>; + subtitleBottomMargin: number; + setSubtitleBottomMargin: React.Dispatch>; selectedBgmId: string; setSelectedBgmId: React.Dispatch>; 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); diff --git a/frontend/src/features/home/ui/FloatingStylePreview.tsx b/frontend/src/features/home/ui/FloatingStylePreview.tsx new file mode 100644 index 0000000..becedfd --- /dev/null +++ b/frontend/src/features/home/ui/FloatingStylePreview.tsx @@ -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 = ( +
+ {/* 标题栏 */} +
+
+ 样式预览 +
+ +
+ + {/* 预览内容 */} +
+ {(titleFontUrl || subtitleFontUrl) && ( + + )} +
+
+
+ {previewTitleText} +
+ +
+ {enableSubtitles ? ( + <> + {subtitleHighlightText} + {subtitleNormalText} + + ) : ( + 字幕已关闭 + )} +
+
+
+
+ ); + + return createPortal(content, document.body); +} diff --git a/frontend/src/features/home/ui/HomePage.tsx b/frontend/src/features/home/ui/HomePage.tsx index 25ed564..00e3627 100644 --- a/frontend/src/features/home/ui/HomePage.tsx +++ b/frontend/src/features/home/ui/HomePage.tsx @@ -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} /> {/* 配音方式选择 */} diff --git a/frontend/src/features/home/ui/ScriptEditor.tsx b/frontend/src/features/home/ui/ScriptEditor.tsx index dd221bf..1830df8 100644 --- a/frontend/src/features/home/ui/ScriptEditor.tsx +++ b/frontend/src/features/home/ui/ScriptEditor.tsx @@ -16,12 +16,12 @@ export function ScriptEditor({ isGeneratingMeta, }: ScriptEditorProps) { return ( -
-
+
+

✍️ 文案提取与编辑

-
+
)} diff --git a/frontend/src/features/publish/model/usePublishController.ts b/frontend/src/features/publish/model/usePublishController.ts index 846abb5..bf7e4e0 100644 --- a/frontend/src/features/publish/model/usePublishController.ts +++ b/frontend/src/features/publish/model/usePublishController.ts @@ -16,7 +16,7 @@ import { } from "@/shared/types/publish"; const fetcher = (url: string) => - api.get>(url).then((res) => unwrap(res.data)); + api.get>(url).then((res) => unwrap(res.data)); export const usePublishController = () => { const apiBase = getApiBaseUrl(); @@ -36,6 +36,7 @@ export const usePublishController = () => { const [qrCodeImage, setQrCodeImage] = useState(null); const [qrPlatform, setQrPlatform] = useState(null); const [isLoadingQR, setIsLoadingQR] = useState(false); + const [faceVerifyQr, setFaceVerifyQr] = useState(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, diff --git a/frontend/src/features/publish/ui/PublishPage.tsx b/frontend/src/features/publish/ui/PublishPage.tsx index 899868d..b5e5230 100644 --- a/frontend/src/features/publish/ui/PublishPage.tsx +++ b/frontend/src/features/publish/ui/PublishPage.tsx @@ -36,6 +36,7 @@ export function PublishPage() { qrCodeImage, qrPlatform, isLoadingQR, + faceVerifyQr, togglePlatform, handlePublish, handleLogin, @@ -63,6 +64,20 @@ export function PublishPage() {

正在获取二维码...

+ ) : faceVerifyQr ? ( + <> + Face Verify QR +

+ 需要身份验证,请用抖音APP扫描上方二维码完成刷脸验证 +

+ ) : qrCodeImage ? ( <>