From be6a3436bbd379174a0a3d9d385b3f3795c34dbc Mon Sep 17 00:00:00 2001 From: Kevin Wong Date: Thu, 5 Feb 2026 12:03:55 +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 | 128 +++++ Docs/BACKEND_README.md | 32 +- Docs/DEPLOY_MANUAL.md | 12 +- Docs/DevLogs/Day18.md | 109 ++++ Docs/FRONTEND_DEV.md | 24 + Docs/FRONTEND_README.md | 4 +- Docs/task_complete.md | 19 +- README.md | 9 +- backend/app/api/__init__.py | 10 + backend/app/api/admin.py | 186 +------ backend/app/api/ai.py | 46 +- backend/app/api/assets.py | 23 +- backend/app/api/auth.py | 339 +------------ backend/app/api/login_helper.py | 222 +------- backend/app/api/materials.py | 339 +------------ backend/app/api/publish.py | 147 +----- backend/app/api/ref_audios.py | 412 +-------------- backend/app/api/tools.py | 399 +-------------- backend/app/api/videos.py | 479 +----------------- backend/app/core/deps.py | 130 ++--- backend/app/core/response.py | 26 + backend/app/main.py | 79 ++- backend/app/modules/__init__.py | 0 backend/app/modules/admin/__init__.py | 0 backend/app/modules/admin/router.py | 164 ++++++ backend/app/modules/ai/__init__.py | 0 backend/app/modules/ai/router.py | 46 ++ backend/app/modules/assets/__init__.py | 0 backend/app/modules/assets/router.py | 23 + backend/app/modules/auth/__init__.py | 0 backend/app/modules/auth/router.py | 293 +++++++++++ backend/app/modules/login_helper/__init__.py | 0 backend/app/modules/login_helper/router.py | 221 ++++++++ backend/app/modules/materials/__init__.py | 0 backend/app/modules/materials/router.py | 416 +++++++++++++++ backend/app/modules/publish/__init__.py | 0 backend/app/modules/publish/router.py | 141 ++++++ backend/app/modules/ref_audios/__init__.py | 0 backend/app/modules/ref_audios/router.py | 416 +++++++++++++++ backend/app/modules/tools/__init__.py | 0 backend/app/modules/tools/router.py | 407 +++++++++++++++ backend/app/modules/videos/__init__.py | 0 backend/app/modules/videos/router.py | 57 +++ backend/app/modules/videos/schemas.py | 19 + backend/app/modules/videos/service.py | 87 ++++ backend/app/modules/videos/task_store.py | 118 +++++ backend/app/modules/videos/workflow.py | 328 ++++++++++++ backend/app/repositories/__init__.py | 0 backend/app/repositories/sessions.py | 31 ++ backend/app/repositories/users.py | 39 ++ backend/app/services/publish_service.py | 13 +- backend/app/services/storage.py | 21 +- frontend/public/platforms/bilibili.svg | 1 + frontend/public/platforms/douyin.svg | 1 + frontend/public/platforms/wechat.svg | 1 + frontend/public/platforms/xiaohongshu.svg | 1 + frontend/src/app/admin/page.tsx | 7 +- .../components/AccountSettingsDropdown.tsx | 11 +- .../src/components/ScriptExtractionModal.tsx | 20 +- frontend/src/components/VideoPreviewModal.tsx | 1 + frontend/src/contexts/AuthContext.tsx | 12 +- frontend/src/contexts/TaskContext.tsx | 8 +- frontend/src/features/home/model/useBgm.ts | 8 +- .../features/home/model/useGeneratedVideos.ts | 8 +- .../features/home/model/useHomeController.ts | 156 +++++- .../src/features/home/model/useMaterials.ts | 18 +- .../src/features/home/model/useRefAudios.ts | 15 +- .../home/model/useTitleSubtitleStyles.ts | 15 +- frontend/src/features/home/ui/HomePage.tsx | 23 + .../src/features/home/ui/MaterialSelector.tsx | 74 ++- .../src/features/home/ui/PreviewPanel.tsx | 2 +- .../publish/model/usePublishController.ts | 106 +++- .../src/features/publish/ui/PublishPage.tsx | 169 ++++-- frontend/src/shared/api/types.ts | 8 + frontend/src/shared/lib/auth.ts | 117 +++-- 75 files changed, 3896 insertions(+), 2900 deletions(-) create mode 100644 Docs/BACKEND_DEV.md create mode 100644 Docs/DevLogs/Day18.md create mode 100644 backend/app/core/response.py create mode 100644 backend/app/modules/__init__.py create mode 100644 backend/app/modules/admin/__init__.py create mode 100644 backend/app/modules/admin/router.py create mode 100644 backend/app/modules/ai/__init__.py create mode 100644 backend/app/modules/ai/router.py create mode 100644 backend/app/modules/assets/__init__.py create mode 100644 backend/app/modules/assets/router.py create mode 100644 backend/app/modules/auth/__init__.py create mode 100644 backend/app/modules/auth/router.py create mode 100644 backend/app/modules/login_helper/__init__.py create mode 100644 backend/app/modules/login_helper/router.py create mode 100644 backend/app/modules/materials/__init__.py create mode 100644 backend/app/modules/materials/router.py create mode 100644 backend/app/modules/publish/__init__.py create mode 100644 backend/app/modules/publish/router.py create mode 100644 backend/app/modules/ref_audios/__init__.py create mode 100644 backend/app/modules/ref_audios/router.py create mode 100644 backend/app/modules/tools/__init__.py create mode 100644 backend/app/modules/tools/router.py create mode 100644 backend/app/modules/videos/__init__.py create mode 100644 backend/app/modules/videos/router.py create mode 100644 backend/app/modules/videos/schemas.py create mode 100644 backend/app/modules/videos/service.py create mode 100644 backend/app/modules/videos/task_store.py create mode 100644 backend/app/modules/videos/workflow.py create mode 100644 backend/app/repositories/__init__.py create mode 100644 backend/app/repositories/sessions.py create mode 100644 backend/app/repositories/users.py create mode 100644 frontend/public/platforms/bilibili.svg create mode 100644 frontend/public/platforms/douyin.svg create mode 100644 frontend/public/platforms/wechat.svg create mode 100644 frontend/public/platforms/xiaohongshu.svg create mode 100644 frontend/src/shared/api/types.ts diff --git a/Docs/BACKEND_DEV.md b/Docs/BACKEND_DEV.md new file mode 100644 index 0000000..8cb210b --- /dev/null +++ b/Docs/BACKEND_DEV.md @@ -0,0 +1,128 @@ +# ViGent2 后端开发规范 + +本文档定义后端开发的结构规范、接口契约与实现习惯。目标是让新功能按统一范式落地,旧逻辑在修复时逐步抽离。 + +--- + +## 1. 模块化与分层原则 + +每个业务功能放入 `app/modules//`,以“薄路由 + 厚服务/流程”组织代码。 + +- **router.py**:只做参数校验、权限校验、调用 service/workflow、返回统一响应。 +- **schemas.py**:Pydantic 请求/响应模型。 +- **service.py**:业务逻辑与集成逻辑(非长流程)。 +- **workflow.py**:长流程/重任务编排(视频生成、渲染、异步任务)。 +- **__init__.py**:模块标记。 + +其它层级职责: + +- **repositories/**:数据读写(Supabase),不包含业务逻辑。 +- **services/**:外部依赖与基础能力(TTS、Storage、Remotion 等)。 +- **core/**:配置、安全、依赖注入、统一响应。 +- **api/**:仅做 router 透传,保持 `/api/*` 路由稳定。 + +--- + +## 2. 目录结构(当前约定) + +``` +backend/ +├── app/ +│ ├── api/ # 兼容路由入口,透传到 modules +│ ├── core/ # config、deps、security、response +│ ├── modules/ # 业务模块 +│ │ ├── videos/ +│ │ ├── materials/ +│ │ ├── publish/ +│ │ ├── auth/ +│ │ └── ... +│ ├── repositories/ # Supabase 数据访问 +│ ├── services/ # 外部服务集成 +│ └── tests/ +├── assets/ # 字体 / 样式 / bgm +├── scripts/ +└── requirements.txt +``` + +--- + +## 3. 接口契约规范(统一响应) + +所有 JSON API 返回统一结构: + +```json +{ + "success": true, + "message": "ok", + "data": { }, + "code": 0 +} +``` + +- 正常响应使用 `success_response`。 +- 错误通过 `HTTPException` 抛出,统一由全局异常处理返回 `{success:false, message, code}`。 +- 不再使用 `detail` 作为前端错误文案(前端已改为读 `message`)。 + +--- + +## 4. 认证与权限 + +- 认证方式:**HttpOnly Cookie** (`access_token`)。 +- `get_current_user` / `get_current_user_optional` 位于 `core/deps.py`。 +- Session 单设备校验使用 `repositories/sessions.py`。 + +--- + +## 5. 任务与状态 + +- 视频生成任务通过 `modules/videos/workflow.py` 统一编排。 +- 任务状态通过 `modules/videos/task_store.py` 读写,**不要直接维护全局 dict**。 +- 默认使用 Redis(`REDIS_URL`),不可用自动回退内存。 + +--- + +## 6. 文件与存储 + +- 所有文件上传/下载/删除/移动通过 `services/storage.py`。 +- 需要重命名时使用 `move_file`,避免直接读写 Storage。 + +--- + +## 7. 代码约定 + +- 只在 router 做校验与响应拼装。 +- 业务逻辑写在 service/workflow。 +- 数据库访问写在 repositories。 +- 统一使用 `loguru` 打日志。 + +--- + +## 8. 开发流程建议 + +- **新增功能**:先建模块,再写 router/service/workflow。 +- **修复 Bug**:顺手把涉及的逻辑抽到对应 service/workflow。 +- **核心流程变更**:必跑冒烟(登录/生成/发布)。 + +--- + +## 9. 常用环境变量 + +- `SUPABASE_URL` / `SUPABASE_KEY` +- `SUPABASE_PUBLIC_URL` +- `REDIS_URL` +- `GLM_API_KEY` +- `LATENTSYNC_*` + +--- + +## 10. 最小新增模块示例 + +``` +app/modules/foo/ +├── router.py +├── schemas.py +├── service.py +└── workflow.py +``` + +router 仅调用 service/workflow 并返回 `success_response`。 diff --git a/Docs/BACKEND_README.md b/Docs/BACKEND_README.md index 4257a69..b886f1c 100644 --- a/Docs/BACKEND_README.md +++ b/Docs/BACKEND_README.md @@ -1,6 +1,6 @@ # ViGent2 后端开发指南 -本文档为后端开发人员提供架构概览、接口规范以及开发流程指南。 +本文档提供后端架构概览与接口规范。开发规范与分层约定见 `Docs/BACKEND_DEV.md`。 --- @@ -13,15 +13,11 @@ ``` backend/ ├── app/ -│ ├── api/ # API 路由定义 (endpoints) -│ ├── core/ # 核心配置 (config.py, security.py) -│ ├── models/ # Pydantic 数据模型 (schemas) -│ ├── services/ # 业务逻辑服务层 -│ │ ├── auth_service.py # 用户认证服务 -│ │ ├── glm_service.py # GLM-4 大模型服务 -│ │ ├── lipsync_service.py # LatentSync 唇形同步 -│ │ ├── publish_service.py # 社交媒体发布 -│ │ └── voice_clone_service.py# Qwen3-TTS 声音克隆 +│ ├── api/ # 兼容路由入口 (透传到 modules) +│ ├── core/ # 核心配置 (config.py, security.py, response.py) +│ ├── modules/ # 业务模块 (router/service/workflow/schemas) +│ ├── repositories/ # Supabase 数据访问 +│ ├── services/ # 外部服务集成 (TTS/Remotion/Storage 等) │ └── tests/ # 单元测试与集成测试 ├── scripts/ # 运维脚本 (watchdog.py, init_db.py) ├── assets/ # 资源库 (fonts, bgm, styles) @@ -35,7 +31,7 @@ backend/ 后端服务默认运行在 `8006` 端口。 - **文档地址**: `http://localhost:8006/docs` (Swagger UI) -- **认证方式**: Bearer Token (JWT) +- **认证方式**: HttpOnly Cookie (JWT) ### 核心模块 @@ -53,8 +49,9 @@ backend/ > **修正 (16:20)**:任务查询与历史列表接口已更新为 `/api/videos/tasks/{task_id}` 与 `/api/videos/generated`。 3. **素材管理 (Materials)** - * `POST /api/materials/upload`: 上传素材 (Direct Upload to Supabase) + * `POST /api/materials`: 上传素材 * `GET /api/materials`: 获取素材列表 + * `PUT /api/materials/{material_id}`: 重命名素材 4. **社交发布 (Publish)** * `POST /api/publish`: 发布视频到 B站/抖音/小红书 @@ -64,6 +61,17 @@ backend/ * `GET /api/assets/title-styles`: 标题样式列表 * `GET /api/assets/bgm`: 背景音乐列表 +### 统一响应结构 + +```json +{ + "success": true, + "message": "ok", + "data": { }, + "code": 0 +} +``` + --- ## 🎛️ 视频生成扩展参数 diff --git a/Docs/DEPLOY_MANUAL.md b/Docs/DEPLOY_MANUAL.md index cff9fab..5208b81 100644 --- a/Docs/DEPLOY_MANUAL.md +++ b/Docs/DEPLOY_MANUAL.md @@ -28,8 +28,11 @@ node --version # 检查 FFmpeg ffmpeg -version -# 检查 pm2 (用于服务管理) -pm2 --version +# 检查 pm2 (用于服务管理) +pm2 --version + +# 检查 Redis (任务状态存储,推荐) +redis-server --version ``` 如果缺少依赖: @@ -158,8 +161,9 @@ cp .env.example .env | `LATENTSYNC_GPU_ID` | 1 | GPU 选择 (0 或 1) | | `LATENTSYNC_USE_SERVER` | false | 设为 true 以启用常驻服务加速 | | `LATENTSYNC_INFERENCE_STEPS` | 20 | 推理步数 (20-50) | -| `LATENTSYNC_GUIDANCE_SCALE` | 1.5 | 引导系数 (1.0-3.0) | -| `DEBUG` | true | 生产环境改为 false | +| `LATENTSYNC_GUIDANCE_SCALE` | 1.5 | 引导系数 (1.0-3.0) | +| `DEBUG` | true | 生产环境改为 false | +| `REDIS_URL` | `redis://localhost:6379/0` | 任务状态存储(不可用时回退内存) | --- diff --git a/Docs/DevLogs/Day18.md b/Docs/DevLogs/Day18.md new file mode 100644 index 0000000..0e5c802 --- /dev/null +++ b/Docs/DevLogs/Day18.md @@ -0,0 +1,109 @@ +# Day 18 - 后端模块化与规范完善 + +## 🧱 后端模块化重构 (10:10) + +### 内容 +- API 路由统一透传到 `modules/*`,路由仅负责参数/权限与响应 +- 视频生成逻辑下沉 `workflow`,任务状态抽到 `task_store` +- `TaskStore` 支持 Redis 优先、不可用时自动回退内存 +- Supabase 访问抽到 `repositories/*`,`deps/auth/admin` 全面改造 + +### 涉及文件 +- `backend/app/modules/videos/router.py` +- `backend/app/modules/videos/workflow.py` +- `backend/app/modules/videos/task_store.py` +- `backend/app/modules/videos/service.py` +- `backend/app/modules/*/router.py` +- `backend/app/repositories/users.py` +- `backend/app/repositories/sessions.py` +- `backend/app/core/deps.py` + +--- + +## ✅ 统一响应与异常处理 (11:00) + +### 内容 +- 统一 JSON 响应结构:`success/message/data/code` +- 全局异常处理中将 `detail` 转换为 `message` + +### 涉及文件 +- `backend/app/core/response.py` +- `backend/app/main.py` + +--- + +## 🎞️ 素材重命名与存储操作 (11:40) + +### 内容 +- 新增素材重命名接口 `PUT /api/materials/{material_id}` +- Storage 增加 `move_file` 以支持重命名/移动 + +### 涉及文件 +- `backend/app/modules/materials/router.py` +- `backend/app/services/storage.py` + +--- + +## 🧾 平台列表调整 (12:10) + +### 内容 +- 平台顺序调整为:抖音 → 微信视频号 → B站 → 小红书 +- 移除快手配置 + +### 涉及文件 +- `backend/app/services/publish_service.py` + +--- + +## 📘 后端开发规范补充 (12:30) + +### 内容 +- 新增 `BACKEND_DEV.md` 作为后端规范文档 +- `BACKEND_README.md` 同步模块化结构与响应格式 + +### 涉及文件 +- `Docs/BACKEND_DEV.md` +- `Docs/BACKEND_README.md` + +--- + +## 🚀 发布管理进入体验优化 (13:10) + +### 内容 +- 首页预取 `/publish` 路由,进入发布管理时更快 +- 发布页读取 `sessionStorage` 预取数据,首屏更快渲染 +- 账号与作品列表增加骨架屏,避免空白等待 + +### 涉及文件 +- `frontend/src/features/home/ui/HomePage.tsx` +- `frontend/src/features/home/model/useHomeController.ts` +- `frontend/src/features/publish/model/usePublishController.ts` +- `frontend/src/features/publish/ui/PublishPage.tsx` + +--- + +## 📁 首页素材加载优化 (13:30) + +### 内容 +- 素材列表签名 URL 并发生成(并发上限 8),缩短加载时间 +- 素材列表增加加载骨架,数量根据上次素材数量动态调整 + +### 涉及文件 +- `backend/app/modules/materials/router.py` +- `frontend/src/features/home/model/useMaterials.ts` +- `frontend/src/features/home/model/useHomeController.ts` +- `frontend/src/features/home/ui/HomePage.tsx` +- `frontend/src/features/home/ui/MaterialSelector.tsx` + +--- + +## 🎬 预览加载体验优化 (14:00) + +### 内容 +- 预览视频设置 `preload="metadata"`,缩短首帧等待 +- 发布页预览按钮悬停预取视频资源 + +### 涉及文件 +- `frontend/src/components/VideoPreviewModal.tsx` +- `frontend/src/features/home/ui/PreviewPanel.tsx` +- `frontend/src/features/publish/ui/PublishPage.tsx` diff --git a/Docs/FRONTEND_DEV.md b/Docs/FRONTEND_DEV.md index ddeed5a..e68045e 100644 --- a/Docs/FRONTEND_DEV.md +++ b/Docs/FRONTEND_DEV.md @@ -202,6 +202,30 @@ import { formatDate } from '@/shared/lib/media'; --- +## ⚡️ 体验优化规范 + +### 路由预取 + +- 首页进入发布管理时使用 `router.prefetch("/publish")` +- 只预取路由,不在首页渲染发布页组件 + +### 发布页数据预取缓存 + +- 使用 `sessionStorage` 保存最近的 `accounts/videos` +- 缓存 TTL 2 分钟,进入发布页先读缓存,随后后台刷新 + +### 骨架屏 + +- 账号列表、作品列表、素材列表在加载时显示骨架 +- 骨架数量应与历史数据数量相近(避免加载时数量跳变) + +### 预览加载优化 + +- 预览 `video` 使用 `preload="metadata"` +- 发布页预览按钮可进行短时 `preload` 预取 + +--- + ## 轻量 FSD 结构 - `app/`:页面入口,保持轻量 diff --git a/Docs/FRONTEND_README.md b/Docs/FRONTEND_README.md index 772ce3a..a06d218 100644 --- a/Docs/FRONTEND_README.md +++ b/Docs/FRONTEND_README.md @@ -6,6 +6,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。 ### 1. 视频生成 (`/`) - **素材管理**: 拖拽上传人物视频,实时预览。 +- **素材重命名**: 支持在列表中直接重命名素材。 - **文案配音**: 集成 EdgeTTS,支持多音色选择 (云溪 / 晓晓)。 - **AI 标题/标签**: 一键生成视频标题与标签 (Day 14)。 - **标题/字幕样式**: 样式选择 + 预览 + 字号调节 (Day 16)。 @@ -14,10 +15,11 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。 - **预览一致性**: 标题/字幕预览按素材分辨率缩放,效果更接近成片 (Day 17)。 - **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。 - **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。 +- **预览优化**: 预览视频 `metadata` 预取,首帧加载更快。 - **本地保存**: 文案/标题/偏好由 `useHomePersistence` 统一持久化,刷新后恢复 (Day 14/17)。 ### 2. 全自动发布 (`/publish`) [Day 7 新增] -- **多平台管理**: 统一管理 B站、抖音、小红书账号状态。 +- **多平台管理**: 统一管理抖音、微信视频号、B站、小红书账号状态。 - **扫码登录**: - 集成后端 Playwright 生成的 QR Code。 - 实时检测扫码状态 (Wait/Success)。 diff --git a/Docs/task_complete.md b/Docs/task_complete.md index ff0ea77..5b91f4a 100644 --- a/Docs/task_complete.md +++ b/Docs/task_complete.md @@ -1,8 +1,8 @@ # ViGent2 开发任务清单 (Task Log) **项目**: ViGent2 数字人口播视频生成系统 -**进度**: 100% (Day 17 - 前端重构与体验优化) -**更新时间**: 2026-02-04 +**进度**: 100% (Day 18 - 后端模块化与规范完善) +**更新时间**: 2026-02-05 --- @@ -10,7 +10,20 @@ > 这里记录了每一天的核心开发内容与 milestone。 -### Day 17: 前端重构与体验优化 (Current) 🚀 +### Day 18: 后端模块化与规范完善 (Current) 🚀 +- [x] **模块化迁移**: 路由透传 `modules/*`,业务逻辑集中到 service/workflow。 +- [x] **视频生成拆分**: 生成流程下沉 workflow,任务状态统一 TaskStore。 +- [x] **Redis 任务存储**: Redis 优先,不可用自动回退内存。 +- [x] **仓储层抽离**: Supabase 访问统一 `repositories/*`,deps/auth/admin 全面替换。 +- [x] **响应规范**: 统一 `success/message/data/code` + 全局异常处理。 +- [x] **素材重命名**: 新增重命名接口与 Storage `move_file`。 +- [x] **平台顺序调整**: 抖音/微信视频号/B站/小红书,移除快手。 +- [x] **后端开发规范**: 新增 `BACKEND_DEV.md`,README 同步模块化结构。 +- [x] **发布管理体验**: 首页预取路由 + 发布页骨架与缓存,进入更快。 +- [x] **素材加载优化**: 素材列表并发签名 URL,骨架数量动态。 +- [x] **预览加载优化**: `preload="metadata"` + hover 预取。 + +### Day 17: 前端重构与体验优化 - [x] **UI 组件拆分**: 首页拆分为独立组件,降低 `page.tsx` 复杂度。 - [x] **轻量 FSD 迁移**: `app` 页面轻量化,逻辑集中到 `features/*/model`,通用能力下沉 `shared/*`。 - [x] **Controller Hooks**: Home/Publish 页面逻辑集中到 Controller Hook,Page 仅组合渲染。 diff --git a/README.md b/README.md index f70354e..27a3174 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ - 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。 - 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。 -### 平台化功能 -- 📱 **全自动发布** - 支持 B站、抖音、小红书定时发布,扫码登录 + Cookie 持久化。 +### 平台化功能 +- 📱 **全自动发布** - 支持抖音/B站/小红书定时发布,微信视频号预留配置;扫码登录 + Cookie 持久化。 - 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。 - 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。 - 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。 @@ -58,8 +58,9 @@ - [LatentSync 部署指南](models/LatentSync/DEPLOY.md) - 唇形同步模型独立部署。 - [用户认证部署 (AUTH_DEPLOY.md)](Docs/AUTH_DEPLOY.md) - Supabase 与 Auth 系统配置。 -### 开发文档 -- [后端开发指南](Docs/BACKEND_README.md) - 接口规范与开发流程。 +### 开发文档 +- [后端开发指南](Docs/BACKEND_README.md) - 接口规范与开发流程。 +- [后端开发规范](Docs/BACKEND_DEV.md) - 分层约定与开发习惯。 - [前端开发指南](Docs/FRONTEND_DEV.md) - UI 组件与页面规范。 - [开发日志 (DevLogs)](Docs/DevLogs/) - 每日开发进度与技术决策记录。 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index e69de29..5ebf548 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -0,0 +1,10 @@ +from . import admin +from . import ai +from . import assets +from . import auth +from . import login_helper +from . import materials +from . import publish +from . import ref_audios +from . import tools +from . import videos diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 7a05b8a..8ddc9b3 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -1,185 +1 @@ -""" -管理员 API:用户管理 -""" -from fastapi import APIRouter, HTTPException, Depends, status -from pydantic import BaseModel -from typing import Optional, List -from datetime import datetime, timezone, timedelta -from app.core.supabase import get_supabase -from app.core.deps import get_current_admin -from loguru import logger - -router = APIRouter(prefix="/api/admin", tags=["管理"]) - - -class UserListItem(BaseModel): - id: str - phone: str - username: Optional[str] - role: str - is_active: bool - expires_at: Optional[str] - created_at: str - - -class ActivateRequest(BaseModel): - expires_days: Optional[int] = None # 授权天数,None 表示永久 - - -@router.get("/users", response_model=List[UserListItem]) -async def list_users(admin: dict = Depends(get_current_admin)): - """获取所有用户列表""" - try: - supabase = get_supabase() - result = supabase.table("users").select("*").order("created_at", desc=True).execute() - - return [ - UserListItem( - id=u["id"], - phone=u["phone"], - username=u.get("username"), - role=u["role"], - is_active=u["is_active"], - expires_at=u.get("expires_at"), - created_at=u["created_at"] - ) - for u in result.data - ] - except Exception as e: - logger.error(f"获取用户列表失败: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="获取用户列表失败" - ) - - -@router.post("/users/{user_id}/activate") -async def activate_user( - user_id: str, - request: ActivateRequest, - admin: dict = Depends(get_current_admin) -): - """ - 激活用户 - - Args: - user_id: 用户 ID - request.expires_days: 授权天数 (None 表示永久) - """ - try: - supabase = get_supabase() - - # 计算过期时间 - expires_at = None - if request.expires_days: - expires_at = (datetime.now(timezone.utc) + timedelta(days=request.expires_days)).isoformat() - - # 更新用户 - result = supabase.table("users").update({ - "is_active": True, - "role": "user", - "expires_at": expires_at - }).eq("id", user_id).execute() - - if not result.data: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="用户不存在" - ) - - logger.info(f"管理员 {admin['phone']} 激活用户 {user_id}, 有效期: {request.expires_days or '永久'} 天") - - return { - "success": True, - "message": f"用户已激活,有效期: {request.expires_days or '永久'} 天" - } - except HTTPException: - raise - except Exception as e: - logger.error(f"激活用户失败: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="激活用户失败" - ) - - -@router.post("/users/{user_id}/deactivate") -async def deactivate_user( - user_id: str, - admin: dict = Depends(get_current_admin) -): - """停用用户""" - try: - supabase = get_supabase() - - # 不能停用管理员 - user_result = supabase.table("users").select("role").eq("id", user_id).single().execute() - if user_result.data and user_result.data["role"] == "admin": - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="不能停用管理员账号" - ) - - # 更新用户 - result = supabase.table("users").update({ - "is_active": False - }).eq("id", user_id).execute() - - # 清除用户 session - supabase.table("user_sessions").delete().eq("user_id", user_id).execute() - - logger.info(f"管理员 {admin['phone']} 停用用户 {user_id}") - - return {"success": True, "message": "用户已停用"} - except HTTPException: - raise - except Exception as e: - logger.error(f"停用用户失败: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="停用用户失败" - ) - - -@router.post("/users/{user_id}/extend") -async def extend_user( - user_id: str, - request: ActivateRequest, - admin: dict = Depends(get_current_admin) -): - """延长用户授权期限""" - try: - supabase = get_supabase() - - if not request.expires_days: - # 设为永久 - expires_at = None - else: - # 获取当前过期时间 - user_result = supabase.table("users").select("expires_at").eq("id", user_id).single().execute() - user = user_result.data - - if user and user.get("expires_at"): - current_expires = datetime.fromisoformat(user["expires_at"].replace("Z", "+00:00")) - base_time = max(current_expires, datetime.now(timezone.utc)) - else: - base_time = datetime.now(timezone.utc) - - expires_at = (base_time + timedelta(days=request.expires_days)).isoformat() - - result = supabase.table("users").update({ - "expires_at": expires_at - }).eq("id", user_id).execute() - - logger.info(f"管理员 {admin['phone']} 延长用户 {user_id} 授权 {request.expires_days or '永久'} 天") - - return { - "success": True, - "message": f"授权已延长 {request.expires_days or '永久'} 天" - } - except Exception as e: - logger.error(f"延长授权失败: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="延长授权失败" - ) +from app.modules.admin.router import router diff --git a/backend/app/api/ai.py b/backend/app/api/ai.py index efbf83a..50256d9 100644 --- a/backend/app/api/ai.py +++ b/backend/app/api/ai.py @@ -1,45 +1 @@ -""" -AI 相关 API 路由 -""" - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel -from loguru import logger - -from app.services.glm_service import glm_service - - -router = APIRouter(prefix="/api/ai", tags=["AI"]) - - -class GenerateMetaRequest(BaseModel): - """生成标题标签请求""" - text: str - - -class GenerateMetaResponse(BaseModel): - """生成标题标签响应""" - title: str - tags: list[str] - - -@router.post("/generate-meta", response_model=GenerateMetaResponse) -async def generate_meta(req: GenerateMetaRequest): - """ - AI 生成视频标题和标签 - - 根据口播文案自动生成吸引人的标题和相关标签 - """ - if not req.text or not req.text.strip(): - raise HTTPException(status_code=400, detail="口播文案不能为空") - - try: - logger.info(f"Generating meta for text: {req.text[:50]}...") - result = await glm_service.generate_title_tags(req.text) - return GenerateMetaResponse( - title=result.get("title", ""), - tags=result.get("tags", []) - ) - except Exception as e: - logger.error(f"Generate meta failed: {e}") - raise HTTPException(status_code=500, detail=str(e)) +from app.modules.ai.router import router diff --git a/backend/app/api/assets.py b/backend/app/api/assets.py index e533cb9..69cb5ca 100644 --- a/backend/app/api/assets.py +++ b/backend/app/api/assets.py @@ -1,22 +1 @@ -from fastapi import APIRouter, Depends - -from app.core.deps import get_current_user -from app.services.assets_service import list_styles, list_bgm - - -router = APIRouter() - - -@router.get("/subtitle-styles") -async def list_subtitle_styles(current_user: dict = Depends(get_current_user)): - return {"styles": list_styles("subtitle")} - - -@router.get("/title-styles") -async def list_title_styles(current_user: dict = Depends(get_current_user)): - return {"styles": list_styles("title")} - - -@router.get("/bgm") -async def list_bgm_items(current_user: dict = Depends(get_current_user)): - return {"bgm": list_bgm()} +from app.modules.assets.router import router diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 6b66b6a..a373b03 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,338 +1 @@ -""" -认证 API:注册、登录、登出、修改密码 -""" -from fastapi import APIRouter, HTTPException, Response, status, Request -from pydantic import BaseModel, field_validator -from app.core.supabase import get_supabase -from app.core.security import ( - get_password_hash, - verify_password, - create_access_token, - generate_session_token, - set_auth_cookie, - clear_auth_cookie, - decode_access_token -) -from loguru import logger -from typing import Optional -import re - -router = APIRouter(prefix="/api/auth", tags=["认证"]) - - -class RegisterRequest(BaseModel): - phone: str - password: str - username: Optional[str] = None - - @field_validator('phone') - @classmethod - def validate_phone(cls, v): - if not re.match(r'^\d{11}$', v): - raise ValueError('手机号必须是11位数字') - return v - - -class LoginRequest(BaseModel): - phone: str - password: str - - @field_validator('phone') - @classmethod - def validate_phone(cls, v): - if not re.match(r'^\d{11}$', v): - raise ValueError('手机号必须是11位数字') - return v - - -class ChangePasswordRequest(BaseModel): - old_password: str - new_password: str - - @field_validator('new_password') - @classmethod - def validate_new_password(cls, v): - if len(v) < 6: - raise ValueError('新密码长度至少6位') - return v - - -class UserResponse(BaseModel): - id: str - phone: str - username: Optional[str] - role: str - is_active: bool - expires_at: Optional[str] = None - - -@router.post("/register") -async def register(request: RegisterRequest): - """ - 用户注册 - - 注册后状态为 pending,需要管理员激活 - """ - try: - supabase = get_supabase() - - # 检查手机号是否已存在 - existing = supabase.table("users").select("id").eq( - "phone", request.phone - ).execute() - - if existing.data: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="该手机号已注册" - ) - - # 创建用户 - password_hash = get_password_hash(request.password) - - result = supabase.table("users").insert({ - "phone": request.phone, - "password_hash": password_hash, - "username": request.username or f"用户{request.phone[-4:]}", - "role": "pending", - "is_active": False - }).execute() - - logger.info(f"新用户注册: {request.phone}") - - return { - "success": True, - "message": "注册成功,请等待管理员审核激活" - } - except HTTPException: - raise - except Exception as e: - logger.error(f"注册失败: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="注册失败,请稍后重试" - ) - - -@router.post("/login") -async def login(request: LoginRequest, response: Response): - """ - 用户登录 - - - 验证密码 - - 检查是否激活 - - 实现"后踢前"单设备登录 - """ - try: - supabase = get_supabase() - - # 查找用户 - user_result = supabase.table("users").select("*").eq( - "phone", request.phone - ).single().execute() - - user = user_result.data - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="手机号或密码错误" - ) - - # 验证密码 - if not verify_password(request.password, user["password_hash"]): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="手机号或密码错误" - ) - - # 检查是否激活 - if not user["is_active"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="账号未激活,请等待管理员审核" - ) - - # 检查授权是否过期 - if user.get("expires_at"): - from datetime import datetime, timezone - expires_at = datetime.fromisoformat(user["expires_at"].replace("Z", "+00:00")) - if datetime.now(timezone.utc) > expires_at: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="授权已过期,请联系管理员续期" - ) - - # 生成新的 session_token (后踢前) - session_token = generate_session_token() - - # 删除旧 session,插入新 session - supabase.table("user_sessions").delete().eq( - "user_id", user["id"] - ).execute() - - supabase.table("user_sessions").insert({ - "user_id": user["id"], - "session_token": session_token, - "device_info": None # 可以从 request headers 获取 - }).execute() - - # 生成 JWT Token - token = create_access_token(user["id"], session_token) - - # 设置 HttpOnly Cookie - set_auth_cookie(response, token) - - logger.info(f"用户登录: {request.phone}") - - return { - "success": True, - "message": "登录成功", - "user": UserResponse( - id=user["id"], - phone=user["phone"], - username=user.get("username"), - role=user["role"], - is_active=user["is_active"], - expires_at=user.get("expires_at") - ) - } - except HTTPException: - raise - except Exception as e: - logger.error(f"登录失败: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="登录失败,请稍后重试" - ) - - -@router.post("/logout") -async def logout(response: Response): - """用户登出""" - clear_auth_cookie(response) - return {"success": True, "message": "已登出"} - - -@router.post("/change-password") -async def change_password(request: ChangePasswordRequest, req: Request, response: Response): - """ - 修改密码 - - - 验证当前密码 - - 设置新密码 - - 重新生成 session token - """ - # 从 Cookie 获取用户 - token = req.cookies.get("access_token") - if not token: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="未登录" - ) - - token_data = decode_access_token(token) - if not token_data: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token 无效" - ) - - try: - supabase = get_supabase() - - # 获取用户信息 - user_result = supabase.table("users").select("*").eq( - "id", token_data.user_id - ).single().execute() - - user = user_result.data - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="用户不存在" - ) - - # 验证当前密码 - if not verify_password(request.old_password, user["password_hash"]): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="当前密码错误" - ) - - # 更新密码 - new_password_hash = get_password_hash(request.new_password) - supabase.table("users").update({ - "password_hash": new_password_hash - }).eq("id", user["id"]).execute() - - # 生成新的 session token,使旧 token 失效 - new_session_token = generate_session_token() - - supabase.table("user_sessions").delete().eq( - "user_id", user["id"] - ).execute() - - supabase.table("user_sessions").insert({ - "user_id": user["id"], - "session_token": new_session_token, - "device_info": None - }).execute() - - # 生成新的 JWT Token - new_token = create_access_token(user["id"], new_session_token) - set_auth_cookie(response, new_token) - - logger.info(f"用户修改密码: {user['phone']}") - - return { - "success": True, - "message": "密码修改成功" - } - except HTTPException: - raise - except Exception as e: - logger.error(f"修改密码失败: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="修改密码失败,请稍后重试" - ) - - -@router.get("/me") -async def get_me(request: Request): - """获取当前用户信息""" - # 从 Cookie 获取用户 - token = request.cookies.get("access_token") - if not token: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="未登录" - ) - - token_data = decode_access_token(token) - if not token_data: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token 无效" - ) - - supabase = get_supabase() - user_result = supabase.table("users").select("*").eq( - "id", token_data.user_id - ).single().execute() - - user = user_result.data - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="用户不存在" - ) - - return UserResponse( - id=user["id"], - phone=user["phone"], - username=user.get("username"), - role=user["role"], - is_active=user["is_active"], - expires_at=user.get("expires_at") - ) +from app.modules.auth.router import router diff --git a/backend/app/api/login_helper.py b/backend/app/api/login_helper.py index 4f05638..e12b9ff 100644 --- a/backend/app/api/login_helper.py +++ b/backend/app/api/login_helper.py @@ -1,221 +1 @@ -""" -前端一键扫码登录辅助页面 -客户在自己的浏览器中扫码,JavaScript自动提取Cookie并上传到服务器 -""" -from fastapi import APIRouter, Request -from fastapi.responses import HTMLResponse -from app.core.config import settings - -router = APIRouter() - -@router.get("/login-helper/{platform}", response_class=HTMLResponse) -async def login_helper_page(platform: str, request: Request): - """ - 提供一个HTML页面,让用户在自己的浏览器中登录平台 - 登录后JavaScript自动提取Cookie并POST回服务器 - """ - - platform_urls = { - "bilibili": "https://www.bilibili.com/", - "douyin": "https://creator.douyin.com/", - "xiaohongshu": "https://creator.xiaohongshu.com/" - } - - platform_names = { - "bilibili": "B站", - "douyin": "抖音", - "xiaohongshu": "小红书" - } - - if platform not in platform_urls: - return "

不支持的平台

" - - # 获取服务器地址(用于回传Cookie) - server_url = str(request.base_url).rstrip('/') - - html_content = f""" - - - - - - {platform_names[platform]} 一键登录 - - - -
-

🔐 {platform_names[platform]} 一键登录

- -
-
1
-
-
拖拽书签到书签栏
-
- 将下方的"保存{platform_names[platform]}登录"按钮拖拽到浏览器书签栏 -
(如果书签栏未显示,按 Ctrl+Shift+B 显示) -
-
-
- -
- - 🔖 保存{platform_names[platform]}登录 - -
- ⬆️ 拖拽此按钮到浏览器顶部书签栏 -
-
- -
-
2
-
-
登录 {platform_names[platform]}
-
- 点击下方按钮打开{platform_names[platform]}登录页,扫码登录 -
-
-
- - - -
-
3
-
-
一键保存登录
-
- 登录成功后,点击书签栏的"保存{platform_names[platform]}登录"书签 -
系统会自动提取并保存Cookie,完成! -
-
-
- -
- -
-

💡 提示:书签只需拖拽一次,下次登录直接点击书签即可

-

🔒 所有数据仅在您的浏览器和服务器之间传输,安全可靠

-
-
- - - """ - - return HTMLResponse(content=html_content) +from app.modules.login_helper.router import router diff --git a/backend/app/api/materials.py b/backend/app/api/materials.py index 339b06c..83e7fb9 100644 --- a/backend/app/api/materials.py +++ b/backend/app/api/materials.py @@ -1,338 +1 @@ -from fastapi import APIRouter, UploadFile, File, HTTPException, Request, BackgroundTasks, Depends -from app.core.config import settings -from app.core.deps import get_current_user -from app.services.storage import storage_service -import re -import time -import traceback -import os -import aiofiles -from pathlib import Path -from loguru import logger -from pydantic import BaseModel -from typing import Optional -import httpx - - -router = APIRouter() - -def sanitize_filename(filename: str) -> str: - safe_name = re.sub(r'[<>:"/\\|?*]', '_', filename) - if len(safe_name) > 100: - ext = Path(safe_name).suffix - safe_name = safe_name[:100 - len(ext)] + ext - return safe_name - -async def process_and_upload(temp_file_path: str, original_filename: str, content_type: str, user_id: str): - """Background task to strip multipart headers and upload to Supabase""" - try: - logger.info(f"Processing raw upload: {temp_file_path} for user {user_id}") - - # 1. Analyze file to find actual video content (strip multipart boundaries) - # This is a simplified manual parser for a SINGLE file upload. - # Structure: - # --boundary - # Content-Disposition: form-data; name="file"; filename="..." - # Content-Type: video/mp4 - # \r\n\r\n - # [DATA] - # \r\n--boundary-- - - # We need to read the first few KB to find the header end - start_offset = 0 - end_offset = 0 - boundary = b"" - - file_size = os.path.getsize(temp_file_path) - - with open(temp_file_path, 'rb') as f: - # Read first 4KB to find header - head = f.read(4096) - - # Find boundary - first_line_end = head.find(b'\r\n') - if first_line_end == -1: - raise Exception("Could not find boundary in multipart body") - - boundary = head[:first_line_end] # e.g. --boundary123 - logger.info(f"Detected boundary: {boundary}") - - # Find end of headers (\r\n\r\n) - header_end = head.find(b'\r\n\r\n') - if header_end == -1: - raise Exception("Could not find end of multipart headers") - - start_offset = header_end + 4 - logger.info(f"Video data starts at offset: {start_offset}") - - # Find end boundary (read from end of file) - # It should be \r\n + boundary + -- + \r\n - # We seek to end-200 bytes - f.seek(max(0, file_size - 200)) - tail = f.read() - - # The closing boundary is usually --boundary-- - # We look for the last occurrence of the boundary - last_boundary_pos = tail.rfind(boundary) - if last_boundary_pos != -1: - # The data ends before \r\n + boundary - # The tail buffer relative position needs to be converted to absolute - end_pos_in_tail = last_boundary_pos - # We also need to check for the preceding \r\n - if end_pos_in_tail >= 2 and tail[end_pos_in_tail-2:end_pos_in_tail] == b'\r\n': - end_pos_in_tail -= 2 - - # Absolute end offset - end_offset = (file_size - 200) + last_boundary_pos - # Correction for CRLF before boundary - # Actually, simply: read until (file_size - len(tail) + last_boundary_pos) - 2 - end_offset = (max(0, file_size - 200) + last_boundary_pos) - 2 - else: - logger.warning("Could not find closing boundary, assuming EOF") - end_offset = file_size - - logger.info(f"Video data ends at offset: {end_offset}. Total video size: {end_offset - start_offset}") - - # 2. Extract and Upload to Supabase - # Since we have the file on disk, we can just pass the file object (seeked) to upload_file? - # Or if upload_file expects bytes/path, checking storage.py... - # It takes `file_data` (bytes) or file-like? - # supabase-py's `upload` method handles parsing if we pass a file object. - # But we need to pass ONLY the video slice. - # So we create a generator or a sliced file object? - # Simpler: Read the slice into memory if < 1GB? Or copy to new temp file? - # Copying to new temp file is safer for memory. - - video_path = temp_file_path + "_video.mp4" - with open(temp_file_path, 'rb') as src, open(video_path, 'wb') as dst: - src.seek(start_offset) - # Copy in chunks - bytes_to_copy = end_offset - start_offset - copied = 0 - while copied < bytes_to_copy: - chunk_size = min(1024*1024*10, bytes_to_copy - copied) # 10MB chunks - chunk = src.read(chunk_size) - if not chunk: - break - dst.write(chunk) - copied += len(chunk) - - logger.info(f"Extracted video content to {video_path}") - - # 3. Upload to Supabase with user isolation - timestamp = int(time.time()) - safe_name = re.sub(r'[^a-zA-Z0-9._-]', '', original_filename) - # 使用 user_id 作为目录前缀实现隔离 - storage_path = f"{user_id}/{timestamp}_{safe_name}" - - # Use storage service (this calls Supabase which might do its own http request) - # We read the cleaned video file - with open(video_path, 'rb') as f: - file_content = f.read() # Still reading into memory for simple upload call, but server has 32GB RAM so ok for 500MB - await storage_service.upload_file( - bucket=storage_service.BUCKET_MATERIALS, - path=storage_path, - file_data=file_content, - content_type=content_type - ) - - logger.info(f"Upload to Supabase complete: {storage_path}") - - # Cleanup - os.remove(temp_file_path) - os.remove(video_path) - - return storage_path - - except Exception as e: - logger.error(f"Background upload processing failed: {e}\n{traceback.format_exc()}") - raise - - -@router.post("") -async def upload_material( - request: Request, - background_tasks: BackgroundTasks, - current_user: dict = Depends(get_current_user) -): - user_id = current_user["id"] - logger.info(f"ENTERED upload_material (Streaming Mode) for user {user_id}. Headers: {request.headers}") - - filename = "unknown_video.mp4" # Fallback - content_type = "video/mp4" - - # Try to parse filename from header if possible (unreliable in raw stream) - # We will rely on post-processing or client hint - # Frontend sends standard multipart. - - # Create temp file - timestamp = int(time.time()) - temp_filename = f"upload_{timestamp}.raw" - temp_path = os.path.join("/tmp", temp_filename) # Use /tmp on Linux - # Ensure /tmp exists (it does) but verify paths - if os.name == 'nt': # Local dev - temp_path = f"d:/tmp/{temp_filename}" - os.makedirs("d:/tmp", exist_ok=True) - - try: - total_size = 0 - last_log = 0 - - async with aiofiles.open(temp_path, 'wb') as f: - async for chunk in request.stream(): - await f.write(chunk) - total_size += len(chunk) - - # Log progress every 20MB - if total_size - last_log > 20 * 1024 * 1024: - logger.info(f"Receiving stream... Processed {total_size / (1024*1024):.2f} MB") - last_log = total_size - - logger.info(f"Stream reception complete. Total size: {total_size} bytes. Saved to {temp_path}") - - if total_size == 0: - raise HTTPException(400, "Received empty body") - - # Attempt to extract filename from the saved file's first bytes? - # Or just accept it as "uploaded_video.mp4" for now to prove it works. - # We can try to regex the header in the file content we just wrote. - # Implemented in background task to return success immediately. - - # Wait, if we return immediately, the user's UI might not show the file yet? - # The prompt says "Wait for upload". - # But to avoid User Waiting Timeout, maybe returning early is better? - # NO, user expects the file to be in the list. - # So we Must await the processing. - # But "Processing" (Strip + Upload to Supabase) takes time. - # Receiving took time. - # If we await Supabase upload, does it timeout? - # Supabase upload is outgoing. Usually faster/stable. - - # Let's await the processing to ensure "List Materials" shows it. - # We need to extract the filename for the list. - - # Quick extract filename from first 4kb - with open(temp_path, 'rb') as f: - head = f.read(4096).decode('utf-8', errors='ignore') - match = re.search(r'filename="([^"]+)"', head) - if match: - filename = match.group(1) - logger.info(f"Extracted filename from body: {filename}") - - # Run processing sync (in await) - storage_path = await process_and_upload(temp_path, filename, content_type, user_id) - - # Get signed URL (it exists now) - signed_url = await storage_service.get_signed_url( - bucket=storage_service.BUCKET_MATERIALS, - path=storage_path - ) - - size_mb = total_size / (1024 * 1024) # Approximate (includes headers) - - # 从 storage_path 提取显示名 - display_name = storage_path.split('/')[-1] # 去掉 user_id 前缀 - if '_' in display_name: - parts = display_name.split('_', 1) - if parts[0].isdigit(): - display_name = parts[1] - - return { - "id": storage_path, - "name": display_name, - "path": signed_url, - "size_mb": size_mb, - "type": "video" - } - - except Exception as e: - error_msg = f"Streaming upload failed: {str(e)}" - detail_msg = f"Exception: {repr(e)}\nArgs: {e.args}\n{traceback.format_exc()}" - logger.error(error_msg + "\n" + detail_msg) - - # Write to debug file - try: - with open("debug_upload.log", "a") as logf: - logf.write(f"\n--- Error at {time.ctime()} ---\n") - logf.write(detail_msg) - logf.write("\n-----------------------------\n") - except: - pass - - if os.path.exists(temp_path): - try: - os.remove(temp_path) - except: - pass - raise HTTPException(500, f"Upload failed. Check server logs. Error: {str(e)}") - - -@router.get("") -async def list_materials(current_user: dict = Depends(get_current_user)): - user_id = current_user["id"] - try: - # 只列出当前用户目录下的文件 - files_obj = await storage_service.list_files( - bucket=storage_service.BUCKET_MATERIALS, - path=user_id - ) - materials = [] - for f in files_obj: - name = f.get('name') - if not name or name == '.emptyFolderPlaceholder': - continue - display_name = name - if '_' in name: - parts = name.split('_', 1) - if parts[0].isdigit(): - display_name = parts[1] - # 完整路径包含 user_id - full_path = f"{user_id}/{name}" - signed_url = await storage_service.get_signed_url( - bucket=storage_service.BUCKET_MATERIALS, - path=full_path - ) - metadata = f.get('metadata', {}) - size = metadata.get('size', 0) - # created_at 在顶层,是 ISO 字符串 - created_at_str = f.get('created_at', '') - created_at = 0 - if created_at_str: - from datetime import datetime - try: - dt = datetime.fromisoformat(created_at_str.replace('Z', '+00:00')) - created_at = int(dt.timestamp()) - except: - pass - materials.append({ - "id": full_path, # ID 使用完整路径 - "name": display_name, - "path": signed_url, - "size_mb": size / (1024 * 1024), - "type": "video", - "created_at": created_at - }) - materials.sort(key=lambda x: x['id'], reverse=True) - return {"materials": materials} - except Exception as e: - logger.error(f"List materials failed: {e}") - return {"materials": []} - - -@router.delete("/{material_id:path}") -async def delete_material(material_id: str, current_user: dict = Depends(get_current_user)): - user_id = current_user["id"] - # 验证 material_id 属于当前用户 - if not material_id.startswith(f"{user_id}/"): - raise HTTPException(403, "无权删除此素材") - try: - await storage_service.delete_file( - bucket=storage_service.BUCKET_MATERIALS, - path=material_id - ) - return {"success": True, "message": "素材已删除"} - except Exception as e: - raise HTTPException(500, f"删除失败: {str(e)}") - - - +from app.modules.materials.router import router diff --git a/backend/app/api/publish.py b/backend/app/api/publish.py index 596df24..d0ae3b3 100644 --- a/backend/app/api/publish.py +++ b/backend/app/api/publish.py @@ -1,146 +1 @@ -""" -发布管理 API (支持用户认证) -""" -from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Request -from pydantic import BaseModel -from typing import List, Optional -from datetime import datetime -from loguru import logger -from app.services.publish_service import PublishService -from app.core.deps import get_current_user_optional - -router = APIRouter() -publish_service = PublishService() - -class PublishRequest(BaseModel): - """Video publish request model""" - video_path: str - platform: str - title: str - tags: List[str] = [] - description: str = "" - publish_time: Optional[datetime] = None - -class PublishResponse(BaseModel): - """Video publish response model""" - success: bool - message: str - platform: str - url: Optional[str] = None - -# Supported platforms for validation -SUPPORTED_PLATFORMS = {"bilibili", "douyin", "xiaohongshu"} - - -def _get_user_id(request: Request) -> Optional[str]: - """从请求中获取用户 ID (兼容未登录场景)""" - try: - from app.core.security import decode_access_token - token = request.cookies.get("access_token") - if token: - token_data = decode_access_token(token) - if token_data: - return token_data.user_id - except Exception: - pass - return None - - -@router.post("", response_model=PublishResponse) -async def publish_video(request: PublishRequest, req: Request, background_tasks: BackgroundTasks): - """发布视频到指定平台""" - # Validate platform - if request.platform not in SUPPORTED_PLATFORMS: - raise HTTPException( - status_code=400, - detail=f"不支持的平台: {request.platform}。支持的平台: {', '.join(SUPPORTED_PLATFORMS)}" - ) - - # 获取用户 ID (可选) - user_id = _get_user_id(req) - - try: - result = await publish_service.publish( - video_path=request.video_path, - platform=request.platform, - title=request.title, - tags=request.tags, - description=request.description, - publish_time=request.publish_time, - user_id=user_id - ) - return PublishResponse( - success=result.get("success", False), - message=result.get("message", ""), - platform=request.platform, - url=result.get("url") - ) - except Exception as e: - logger.error(f"发布失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/platforms") -async def list_platforms(): - return {"platforms": [{**pinfo, "id": pid} for pid, pinfo in publish_service.PLATFORMS.items()]} - -@router.get("/accounts") -async def list_accounts(req: Request): - user_id = _get_user_id(req) - return {"accounts": publish_service.get_accounts(user_id)} - -@router.post("/login/{platform}") -async def login_platform(platform: str, req: Request): - """触发平台QR码登录""" - if platform not in SUPPORTED_PLATFORMS: - raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}") - - user_id = _get_user_id(req) - result = await publish_service.login(platform, user_id) - - if result.get("success"): - return result - else: - raise HTTPException(status_code=400, detail=result.get("message")) - -@router.post("/logout/{platform}") -async def logout_platform(platform: str, req: Request): - """注销平台登录""" - if platform not in SUPPORTED_PLATFORMS: - raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}") - - user_id = _get_user_id(req) - result = publish_service.logout(platform, user_id) - return result - -@router.get("/login/status/{platform}") -async def get_login_status(platform: str, req: Request): - """检查登录状态 (优先检查活跃的扫码会话)""" - if platform not in SUPPORTED_PLATFORMS: - raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}") - - user_id = _get_user_id(req) - return publish_service.get_login_session_status(platform, user_id) - -@router.post("/cookies/save/{platform}") -async def save_platform_cookie(platform: str, cookie_data: dict, req: Request): - """ - 保存从客户端浏览器提取的Cookie - - Args: - platform: 平台ID - cookie_data: {"cookie_string": "document.cookie的内容"} - """ - if platform not in SUPPORTED_PLATFORMS: - raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}") - - cookie_string = cookie_data.get("cookie_string", "") - if not cookie_string: - raise HTTPException(status_code=400, detail="cookie_string 不能为空") - - user_id = _get_user_id(req) - result = await publish_service.save_cookie_string(platform, cookie_string, user_id) - - if result.get("success"): - return result - else: - raise HTTPException(status_code=400, detail=result.get("message")) +from app.modules.publish.router import router diff --git a/backend/app/api/ref_audios.py b/backend/app/api/ref_audios.py index 3b983ae..e3f45ab 100644 --- a/backend/app/api/ref_audios.py +++ b/backend/app/api/ref_audios.py @@ -1,411 +1 @@ -""" -参考音频管理 API -支持上传/列表/删除参考音频,用于 Qwen3-TTS 声音克隆 -""" -from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends -from pydantic import BaseModel -from typing import List, Optional -from pathlib import Path -from loguru import logger -import time -import json -import subprocess -import tempfile -import os -import re - -from app.core.deps import get_current_user -from app.services.storage import storage_service - -router = APIRouter() - -# 支持的音频格式 -ALLOWED_AUDIO_EXTENSIONS = {'.wav', '.mp3', '.m4a', '.webm', '.ogg', '.flac', '.aac'} - -# 参考音频 bucket -BUCKET_REF_AUDIOS = "ref-audios" - - -class RefAudioResponse(BaseModel): - id: str - name: str - path: str # signed URL for playback - ref_text: str - duration_sec: float - created_at: int - - -class RefAudioListResponse(BaseModel): - items: List[RefAudioResponse] - - -def sanitize_filename(filename: str) -> str: - """清理文件名,移除特殊字符""" - safe_name = re.sub(r'[<>:"/\\|?*\s]', '_', filename) - if len(safe_name) > 50: - ext = Path(safe_name).suffix - safe_name = safe_name[:50 - len(ext)] + ext - return safe_name - - -def get_audio_duration(file_path: str) -> float: - """获取音频时长 (秒)""" - try: - result = subprocess.run( - ['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration', - '-of', 'csv=p=0', file_path], - capture_output=True, text=True, timeout=10 - ) - return float(result.stdout.strip()) - except Exception as e: - logger.warning(f"获取音频时长失败: {e}") - return 0.0 - - -def convert_to_wav(input_path: str, output_path: str) -> bool: - """将音频转换为 WAV 格式 (16kHz, mono)""" - try: - subprocess.run([ - 'ffmpeg', '-y', '-i', input_path, - '-ar', '16000', # 16kHz 采样率 - '-ac', '1', # 单声道 - '-acodec', 'pcm_s16le', # 16-bit PCM - output_path - ], capture_output=True, timeout=60, check=True) - return True - except Exception as e: - logger.error(f"音频转换失败: {e}") - return False - - -@router.post("", response_model=RefAudioResponse) -async def upload_ref_audio( - file: UploadFile = File(...), - ref_text: str = Form(...), - user: dict = Depends(get_current_user) -): - """ - 上传参考音频 - - - file: 音频文件 (支持 wav, mp3, m4a, webm 等) - - ref_text: 参考音频的转写文字 (必填) - """ - user_id = user["id"] - - # 验证文件扩展名 - ext = Path(file.filename).suffix.lower() - if ext not in ALLOWED_AUDIO_EXTENSIONS: - raise HTTPException( - status_code=400, - detail=f"不支持的音频格式: {ext}。支持的格式: {', '.join(ALLOWED_AUDIO_EXTENSIONS)}" - ) - - # 验证 ref_text - if not ref_text or len(ref_text.strip()) < 2: - raise HTTPException(status_code=400, detail="参考文字不能为空") - - try: - # 创建临时文件 - with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp_input: - content = await file.read() - tmp_input.write(content) - tmp_input_path = tmp_input.name - - # 转换为 WAV 格式 - tmp_wav_path = tmp_input_path + ".wav" - if ext != '.wav': - if not convert_to_wav(tmp_input_path, tmp_wav_path): - raise HTTPException(status_code=500, detail="音频格式转换失败") - else: - # 即使是 wav 也要标准化格式 - convert_to_wav(tmp_input_path, tmp_wav_path) - - # 获取音频时长 - duration = get_audio_duration(tmp_wav_path) - if duration < 1.0: - raise HTTPException(status_code=400, detail="音频时长过短,至少需要 1 秒") - if duration > 60.0: - raise HTTPException(status_code=400, detail="音频时长过长,最多 60 秒") - - - # 3. 处理重名逻辑 (Friendly Display Name) - original_name = file.filename - - # 获取用户现有的所有参考音频列表 (为了检查文件名冲突) - # 注意: 这种列表方式在文件极多时性能一般,但考虑到单用户参考音频数量有限,目前可行 - existing_files = await storage_service.list_files(BUCKET_REF_AUDIOS, user_id) - existing_names = set() - - # 预加载所有现有的 display name - # 这里需要并发请求 metadata 可能会慢,优化: 仅检查 metadata 文件并解析 - # 简易方案: 仅在 metadata 中读取 original_filename - # 但 list_files 返回的是 name,我们需要 metadata - # 考虑到性能,这里使用一种妥协方案: - # 我们不做全量检查,而是简单的检查:如果用户上传 myvoice.wav - # 我们看看有没有 (timestamp)_myvoice.wav 这种其实并不能准确判断 display name 是否冲突 - # - # 正确做法: 应该有个数据库表存 metadata。但目前是无数据库设计。 - # - # 改用简单方案: - # 既然我们无法快速获取所有 display name, - # 我们暂时只处理 "在新上传时,original_filename 保持原样" - # 但用户希望 "如果在列表中看到重复的,自动加(1)" - # - # 鉴于无数据库架构的限制,要在上传时知道"已有的 display name" 成本太高(需遍历下载所有json)。 - # - # 💡 替代方案: - # 我们不检查旧的。我们只保证**存储**唯一。 - # 对于用户提到的 "新上传的文件名后加个数字" -> 这通常是指 "另存为" 的逻辑。 - # 既然用户现在的痛点是 "显示了时间戳太丑",而我已经去掉了时间戳显示。 - # 那么如果用户上传两个 "TEST.wav",列表里就会有两个 "TEST.wav" (但时间不同)。 - # 这其实是可以接受的。 - # - # 但如果用户强求 "自动重命名": - # 我们可以在这里做一个轻量级的 "同名检测": - # 检查有没有 *_{original_name} 的文件存在。 - # 如果 storage 里已经有 123_abc.wav, 456_abc.wav - # 我们可以认为 abc.wav 已经存在。 - - dup_count = 0 - search_suffix = f"_{original_name}" # 比如 _test.wav - - for f in existing_files: - fname = f.get('name', '') - if fname.endswith(search_suffix): - dup_count += 1 - - final_display_name = original_name - if dup_count > 0: - name_stem = Path(original_name).stem - name_ext = Path(original_name).suffix - final_display_name = f"{name_stem}({dup_count}){name_ext}" - - # 生成存储路径 (唯一ID) - timestamp = int(time.time()) - safe_name = sanitize_filename(Path(file.filename).stem) - storage_path = f"{user_id}/{timestamp}_{safe_name}.wav" - - # 上传 WAV 文件到 Supabase - with open(tmp_wav_path, 'rb') as f: - wav_data = f.read() - - await storage_service.upload_file( - bucket=BUCKET_REF_AUDIOS, - path=storage_path, - file_data=wav_data, - content_type="audio/wav" - ) - - # 上传元数据 JSON - metadata = { - "ref_text": ref_text.strip(), - "original_filename": final_display_name, # 这里的名字如果有重复会自动加(1) - "duration_sec": duration, - "created_at": timestamp - } - metadata_path = f"{user_id}/{timestamp}_{safe_name}.json" - await storage_service.upload_file( - bucket=BUCKET_REF_AUDIOS, - path=metadata_path, - file_data=json.dumps(metadata, ensure_ascii=False).encode('utf-8'), - content_type="application/json" - ) - - # 获取签名 URL - signed_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, storage_path) - - # 清理临时文件 - os.unlink(tmp_input_path) - if os.path.exists(tmp_wav_path): - os.unlink(tmp_wav_path) - - return RefAudioResponse( - id=storage_path, - name=file.filename, - path=signed_url, - ref_text=ref_text.strip(), - duration_sec=duration, - created_at=timestamp - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"上传参考音频失败: {e}") - raise HTTPException(status_code=500, detail=f"上传失败: {str(e)}") - - -@router.get("", response_model=RefAudioListResponse) -async def list_ref_audios(user: dict = Depends(get_current_user)): - """列出当前用户的所有参考音频""" - user_id = user["id"] - - try: - # 列出用户目录下的文件 - files = await storage_service.list_files(BUCKET_REF_AUDIOS, user_id) - - # 过滤出 .wav 文件并获取对应的 metadata - items = [] - for f in files: - name = f.get("name", "") - if not name.endswith(".wav"): - continue - - storage_path = f"{user_id}/{name}" - - # 尝试读取 metadata - metadata_name = name.replace(".wav", ".json") - metadata_path = f"{user_id}/{metadata_name}" - - ref_text = "" - duration_sec = 0.0 - created_at = 0 - original_filename = "" - - try: - # 获取 metadata 内容 - metadata_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, metadata_path) - import httpx - async with httpx.AsyncClient() as client: - resp = await client.get(metadata_url) - if resp.status_code == 200: - metadata = resp.json() - ref_text = metadata.get("ref_text", "") - duration_sec = metadata.get("duration_sec", 0.0) - created_at = metadata.get("created_at", 0) - original_filename = metadata.get("original_filename", "") - except Exception as e: - logger.warning(f"读取 metadata 失败: {e}") - # 从文件名提取时间戳 - try: - created_at = int(name.split("_")[0]) - except: - pass - - # 获取音频签名 URL - signed_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, storage_path) - - # 优先显示原始文件名 (去掉时间戳前缀) - display_name = original_filename if original_filename else name - # 如果原始文件名丢失,尝试从现有文件名中通过正则去掉时间戳 - if not display_name or display_name == name: - # 匹配 "1234567890_filename.wav" - match = re.match(r'^\d+_(.+)$', name) - if match: - display_name = match.group(1) - - items.append(RefAudioResponse( - id=storage_path, - name=display_name, - path=signed_url, - ref_text=ref_text, - duration_sec=duration_sec, - created_at=created_at - )) - - # 按创建时间倒序排列 - items.sort(key=lambda x: x.created_at, reverse=True) - - return RefAudioListResponse(items=items) - - except Exception as e: - logger.error(f"列出参考音频失败: {e}") - raise HTTPException(status_code=500, detail=f"获取列表失败: {str(e)}") - - -@router.delete("/{audio_id:path}") -async def delete_ref_audio(audio_id: str, user: dict = Depends(get_current_user)): - """删除参考音频""" - user_id = user["id"] - - # 安全检查:确保只能删除自己的文件 - if not audio_id.startswith(f"{user_id}/"): - raise HTTPException(status_code=403, detail="无权删除此文件") - - try: - # 删除 WAV 文件 - await storage_service.delete_file(BUCKET_REF_AUDIOS, audio_id) - - # 删除 metadata JSON - metadata_path = audio_id.replace(".wav", ".json") - try: - await storage_service.delete_file(BUCKET_REF_AUDIOS, metadata_path) - except: - pass # metadata 可能不存在 - - return {"success": True, "message": "删除成功"} - - except Exception as e: - logger.error(f"删除参考音频失败: {e}") - raise HTTPException(status_code=500, detail=f"删除失败: {str(e)}") - - -class RenameRequest(BaseModel): - new_name: str - - -@router.put("/{audio_id:path}") -async def rename_ref_audio( - audio_id: str, - request: RenameRequest, - user: dict = Depends(get_current_user) -): - """重命名参考音频 (修改 metadata 中的 display name)""" - user_id = user["id"] - - # 安全检查 - if not audio_id.startswith(f"{user_id}/"): - raise HTTPException(status_code=403, detail="无权修改此文件") - - new_name = request.new_name.strip() - if not new_name: - raise HTTPException(status_code=400, detail="新名称不能为空") - - # 确保新名称有后缀 (保留原后缀或添加 .wav) - if not Path(new_name).suffix: - new_name += ".wav" - - try: - # 1. 下载现有的 metadata - metadata_path = audio_id.replace(".wav", ".json") - try: - # 获取已有的 JSON - import httpx - metadata_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, metadata_path) - if not metadata_url: - # 如果 json 不存在,则需要新建一个基础的 - raise Exception("Metadata not found") - - async with httpx.AsyncClient() as client: - resp = await client.get(metadata_url) - if resp.status_code == 200: - metadata = resp.json() - else: - raise Exception(f"Failed to fetch metadata: {resp.status_code}") - - except Exception as e: - logger.warning(f"无法读取元数据: {e}, 将创建新的元数据") - # 兜底:如果读取失败,构建最小元数据 - metadata = { - "ref_text": "", # 可能丢失 - "duration_sec": 0.0, - "created_at": int(time.time()), - "original_filename": new_name - } - - # 2. 更新 original_filename - metadata["original_filename"] = new_name - - # 3. 覆盖上传 metadata - await storage_service.upload_file( - bucket=BUCKET_REF_AUDIOS, - path=metadata_path, - file_data=json.dumps(metadata, ensure_ascii=False).encode('utf-8'), - content_type="application/json" - ) - - return {"success": True, "name": new_name} - - except Exception as e: - logger.error(f"重命名失败: {e}") - raise HTTPException(status_code=500, detail=f"重命名失败: {str(e)}") +from app.modules.ref_audios.router import router diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py index b7cbe05..dca97c7 100644 --- a/backend/app/api/tools.py +++ b/backend/app/api/tools.py @@ -1,398 +1 @@ -from fastapi import APIRouter, UploadFile, File, Form, HTTPException -from typing import Optional -import shutil -import os -import time -from pathlib import Path -from loguru import logger -import traceback -import re -import json -import requests -from urllib.parse import unquote - -from app.services.whisper_service import whisper_service -from app.services.glm_service import glm_service - -router = APIRouter() - -@router.post("/extract-script") -async def extract_script_tool( - file: Optional[UploadFile] = File(None), - url: Optional[str] = Form(None), - rewrite: bool = Form(True) -): - """ - 独立文案提取工具 - 支持上传视频/音频 OR 输入视频链接 -> 提取文字 -> (可选) AI洗稿 - """ - if not file and not url: - raise HTTPException(400, "必须提供文件或视频链接") - - temp_path = None - try: - timestamp = int(time.time()) - temp_dir = Path("/tmp") - if os.name == 'nt': - temp_dir = Path("d:/tmp") - temp_dir.mkdir(parents=True, exist_ok=True) - - # 1. 获取/保存文件 - loop = asyncio.get_event_loop() - - if file: - safe_filename = Path(file.filename).name.replace(" ", "_") - temp_path = temp_dir / f"tool_extract_{timestamp}_{safe_filename}" - # 文件 I/O 放入线程池 - await loop.run_in_executor(None, lambda: shutil.copyfileobj(file.file, open(temp_path, "wb"))) - logger.info(f"Tool processing upload file: {temp_path}") - else: - # URL 下载逻辑 - # 自动提取文案中的链接 (支持 Douyin/Bilibili 等分享文案) - url_match = re.search(r'https?://[^\s]+', url) - if url_match: - extracted_url = url_match.group(0) - logger.info(f"Extracted URL from text: {extracted_url}") - url = extracted_url - - logger.info(f"Tool downloading URL: {url}") - - # 封装 yt-dlp 下载函数 (Blocking) - def _download_yt_dlp(): - import yt_dlp - logger.info("Attempting download with yt-dlp...") - - ydl_opts = { - 'format': 'bestaudio/best', - 'outtmpl': str(temp_dir / f"tool_download_{timestamp}_%(id)s.%(ext)s"), - 'quiet': True, - 'no_warnings': True, - 'http_headers': { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'Referer': 'https://www.douyin.com/', - } - } - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(url, download=True) - if 'requested_downloads' in info: - downloaded_file = info['requested_downloads'][0]['filepath'] - else: - ext = info.get('ext', 'mp4') - id = info.get('id') - downloaded_file = str(temp_dir / f"tool_download_{timestamp}_{id}.{ext}") - - return Path(downloaded_file) - - # 先尝试 yt-dlp (Run in Executor) - try: - temp_path = await loop.run_in_executor(None, _download_yt_dlp) - logger.info(f"yt-dlp downloaded to: {temp_path}") - - except Exception as e: - logger.warning(f"yt-dlp download failed: {e}. Trying manual Douyin fallback...") - - # 失败则尝试手动解析 (Douyin Fallback) - if "douyin" in url: - manual_path = await download_douyin_manual(url, temp_dir, timestamp) - if manual_path: - temp_path = manual_path - logger.info(f"Manual Douyin fallback successful: {temp_path}") - else: - raise HTTPException(400, f"视频下载失败。yt-dlp 报错: {str(e)}") - elif "bilibili" in url: - manual_path = await download_bilibili_manual(url, temp_dir, timestamp) - if manual_path: - temp_path = manual_path - logger.info(f"Manual Bilibili fallback successful: {temp_path}") - else: - raise HTTPException(400, f"视频下载失败。yt-dlp 报错: {str(e)}") - else: - raise HTTPException(400, f"视频下载失败: {str(e)}") - - if not temp_path or not temp_path.exists(): - raise HTTPException(400, "文件获取失败") - - # 1.5 安全转换: 强制转为 WAV (16k) - import subprocess - audio_path = temp_dir / f"extract_audio_{timestamp}.wav" - - def _convert_audio(): - try: - convert_cmd = [ - 'ffmpeg', - '-i', str(temp_path), - '-vn', # 忽略视频 - '-acodec', 'pcm_s16le', - '-ar', '16000', # Whisper 推荐采样率 - '-ac', '1', # 单声道 - '-y', # 覆盖 - str(audio_path) - ] - # 捕获 stderr - subprocess.run(convert_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - return True - except subprocess.CalledProcessError as e: - error_log = e.stderr.decode('utf-8', errors='ignore') if e.stderr else str(e) - logger.error(f"FFmpeg check/convert failed: {error_log}") - # 检查是否为 HTML - head = b"" - try: - with open(temp_path, 'rb') as f: - head = f.read(100) - except: pass - if b' 0: - logger.info("Rewriting script...") - rewritten = await glm_service.rewrite_script(script) - else: - logger.warning("No script extracted, skipping rewrite") - - return { - "success": True, - "original_script": script, - "rewritten_script": rewritten - } - - except HTTPException as he: - raise he - except Exception as e: - logger.error(f"Tool extract failed: {e}") - logger.error(traceback.format_exc()) - - # Friendly error message - msg = str(e) - if "Fresh cookies" in msg: - msg = "下载失败:目标平台开启了反爬验证,请过段时间重试或直接上传视频文件。" - - raise HTTPException(500, f"提取失败: {msg}") - finally: - # 清理临时文件 - if temp_path and temp_path.exists(): - try: - os.remove(temp_path) - logger.info(f"Cleaned up temp file: {temp_path}") - except Exception as e: - logger.warning(f"Failed to cleanup temp file {temp_path}: {e}") - - -async def download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Optional[Path]: - """ - 手动下载抖音视频 (Fallback logic - Ported from SuperIPAgent/douyinDownloader) - 使用特定的 User Profile URL 和硬编码 Cookie 绕过反爬 - """ - logger.info(f"[SuperIPAgent] Starting download for: {url}") - - try: - # 1. 提取 Modal ID (支持短链跳转) - headers = { - "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" - } - - # 如果是短链或重定向 - resp = requests.get(url, headers=headers, allow_redirects=True, timeout=10) - final_url = resp.url - logger.info(f"[SuperIPAgent] Final URL: {final_url}") - - modal_id = None - match = re.search(r'/video/(\d+)', final_url) - if match: - modal_id = match.group(1) - - if not modal_id: - logger.error("[SuperIPAgent] Could not extract modal_id") - return None - - logger.info(f"[SuperIPAgent] Extracted modal_id: {modal_id}") - - # 2. 构造特定请求 URL (Copy from SuperIPAgent) - # 使用特定用户的 Profile 页 + modal_id 参数,配合特定 Cookie - target_url = f"https://www.douyin.com/user/MS4wLjABAAAAN_s_hups7LD0N4qnrM3o2gI0vuG3pozNaEolz2_py3cHTTrpVr1Z4dukFD9SOlwY?from_tab_name=main&modal_id={modal_id}" - - # 3. 使用硬编码 Cookie (Copy from SuperIPAgent) - headers_with_cookie = { - "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", - "cookie": "douyin.com; device_web_cpu_core=10; device_web_memory_size=8; __ac_nonce=06760391f00b9b51264ae; __ac_signature=_02B4Z6wo00f019a5ceAAAIDAhEZR-X3jjWfWmXVAAJLXd4; ttwid=1%7C7MTKBSMsP4eOv9h5NAh8p0E-NYIud09ftNmB0mjLpWc%7C1734359327%7C8794abeabbd47447e1f56e5abc726be089f2a0344d6343b5f75f23e7b0f0028f; UIFID_TEMP=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff1396912bcb2af71efee56a14a2a9f37b74010d0a0413795262f6d4afe02a032ac7ab; s_v_web_id=verify_m4r4ribr_c7krmY1z_WoeI_43po_ATpO_I4o8U1bex2D7; hevc_supported=true; home_can_add_dy_2_desktop=%220%22; dy_swidth=2560; dy_sheight=1440; stream_recommend_feed_params=%22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A2560%2C%5C%22screen_height%5C%22%3A1440%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A10%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A10%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A50%7D%22; strategyABtestKey=%221734359328.577%22; csrf_session_id=2f53aed9aa6974e83aa9a1014180c3a4; fpk1=U2FsdGVkX1/IpBh0qdmlKAVhGyYHgur4/VtL9AReZoeSxadXn4juKvsakahRGqjxOPytHWspYoBogyhS/V6QSw==; fpk2=0845b309c7b9b957afd9ecf775a4c21f; passport_csrf_token=d80e0c5b2fa2328219856be5ba7e671e; passport_csrf_token_default=d80e0c5b2fa2328219856be5ba7e671e; odin_tt=3c891091d2eb0f4718c1d5645bc4a0017032d4d5aa989decb729e9da2ad570918cbe5e9133dc6b145fa8c758de98efe32ff1f81aa0d611e838cc73ab08ef7d3f6adf66ab4d10e8372ddd628f94f16b8e; volume_info=%7B%22isUserMute%22%3Afalse%2C%22isMute%22%3Afalse%2C%22volume%22%3A0.5%7D; bd_ticket_guard_client_web_domain=2; FORCE_LOGIN=%7B%22videoConsumedRemainSeconds%22%3A180%7D; UIFID=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff139655a3c2b735923234f371c699560c657923fd3d6c5b63ab7bb9b83423b6cb4787e2ce66a7fbc4ecb24c8570f520fe6de068bbb95115023c0c6c1b6ee31b49fb7e3996fb8349f43a3fd8b7a61cd9e18e8fe65eb6a7c13de4c0960d84e344b644725db3eb2fa6b7caf821de1b50527979f2; is_dash_user=1; biz_trace_id=b57a241f; bd_ticket_guard_client_data=eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCTEo2R0lDalVoWW1XcHpGOFdrN0Vrc0dXcCtaUzNKY1g4NGNGY2k0TTl1TEowNjdUb21mbFU5aDdvWVBGamhNRWNRQWtKdnN1MnM3RmpTWnlJQXpHMjA9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D; download_guide=%221%2F20241216%2F0%22; sdk_source_info=7e276470716a68645a606960273f276364697660272927676c715a6d6069756077273f276364697660272927666d776a68605a607d71606b766c6a6b5a7666776c7571273f275e58272927666a6b766a69605a696c6061273f27636469766027292762696a6764695a7364776c6467696076273f275e5827292771273f273d33323131333c3036313632342778; bit_env=RiOY4jzzpxZoVCl6zdVSVhVRjdwHRTxqcqWdqMBZLPGjMdB4Tax1kAELHNTVAAh72KuhumewE4Lq6f0-VJ2UpJrkrhSxoPw9LUb3zQrq1OSwbeSPHkRlRgRQvO89sItdGUyq1oFr0XyRCnMYG87KSeWyc4x0czGR0o50hTDoDLG5rJVoRcdQOLvjiAegsqyytKF59sPX_QM9qffK2SqYsg0hCggURc_AI6kguDDE5DvG0bnyz1utw4z1eEnIoLrkGDqzqBZj4dOAr0BVU6ofbsS-pOQ2u2PM1dLP9FlBVBlVaqYVgHJeSLsR5k76BRTddUjTb4zEilVIEwAMJWGN4I1BxVt6fC9B5tBQpuT0lj3n3eKXCKXZsd8FrEs5_pbfDsxV-e_WMiXI2ff4qxiTC0U73sfo9OpicKICtZjdq8qsHxJuu6wVR36zvXeL2Wch5C6MzprNvkivv0l8nbh2mSgy1nabZr3dmU6NcR-Bg3Q3xTWUlR9aAUmpopC-cNuXjgLpT-Lw1AYGilSUnCvosth1Gfypq-b0MpgmdSDgTrQ%3D; gulu_source_res=eyJwX2luIjoiMDhjOGQ3ZTJiODQyNjZkZWI5Y2VkMGJiODNlNmY1ZWY0ZjMyNTE2ZmYyZjAzNDMzZjI0OWU1Y2Q1NTczNTk5NyJ9; passport_auth_mix_state=hp9bc3dgb1tm5wd8p82zawus27g0e3ue; IsDouyinActive=false", - "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - } - - logger.info(f"[SuperIPAgent] Requesting page with Cookie...") - # 必须 verify=False 否则有些环境会报错 - response = requests.get(target_url, headers=headers_with_cookie, timeout=10) - - # 4. 解析 RENDER_DATA - content_match = re.findall(r'', response.text) - if not content_match: - # 尝试解码后再查找?或者结构变了 - # 再尝试找 SSR_HYDRATED_DATA - if "SSR_HYDRATED_DATA" in response.text: - content_match = re.findall(r'', response.text) - - if not content_match: - logger.error(f"[SuperIPAgent] Could not find RENDER_DATA in page (len={len(response.text)})") - return None - - content = unquote(content_match[0]) - try: - data = json.loads(content) - except: - logger.error("[SuperIPAgent] JSON decode failed") - return None - - # 5. 提取视频流 - video_url = None - try: - # 路径通常是: app -> videoDetail -> video -> bitRateList -> playAddr -> src - if "app" in data and "videoDetail" in data["app"]: - info = data["app"]["videoDetail"]["video"] - if "bitRateList" in info and info["bitRateList"]: - video_url = info["bitRateList"][0]["playAddr"][0]["src"] - elif "playAddr" in info and info["playAddr"]: - video_url = info["playAddr"][0]["src"] - except Exception as e: - logger.error(f"[SuperIPAgent] Path extraction failed: {e}") - - if not video_url: - logger.error("[SuperIPAgent] No video_url found") - return None - - if video_url.startswith("//"): - video_url = "https:" + video_url - - logger.info(f"[SuperIPAgent] Found video URL: {video_url[:50]}...") - - # 6. 下载 (带 Header) - temp_path = temp_dir / f"douyin_manual_{timestamp}.mp4" - download_headers = { - 'Referer': 'https://www.douyin.com/', - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', - } - - dl_resp = requests.get(video_url, headers=download_headers, stream=True, timeout=60) - if dl_resp.status_code == 200: - with open(temp_path, 'wb') as f: - for chunk in dl_resp.iter_content(chunk_size=1024): - f.write(chunk) - - logger.info(f"[SuperIPAgent] Downloaded successfully: {temp_path}") - return temp_path - else: - logger.error(f"[SuperIPAgent] Download failed: {dl_resp.status_code}") - return None - - except Exception as e: - logger.error(f"[SuperIPAgent] Logic failed: {e}") - return None - -async def download_bilibili_manual(url: str, temp_dir: Path, timestamp: int) -> Optional[Path]: - """ - 手动下载 Bilibili 视频 (Fallback logic - Playwright Version) - B站通常音视频分离,这里只提取音频即可(因为只需要文案) - """ - from playwright.async_api import async_playwright - - logger.info(f"[Playwright] Starting Bilibili download for: {url}") - - playwright = None - browser = None - try: - playwright = await async_playwright().start() - # Launch browser (ensure chromium is installed: playwright install chromium) - browser = await playwright.chromium.launch(headless=True, args=['--no-sandbox', '--disable-setuid-sandbox']) - - # Mobile User Agent often gives single stream? - # But Bilibili mobile web is tricky. Desktop is fine. - context = await browser.new_context( - user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - ) - - page = await context.new_page() - - # Intercept audio responses? - # Bilibili streams are usually .m4s - # But finding the initial state is easier. - - logger.info("[Playwright] Navigating to Bilibili...") - await page.goto(url, timeout=45000) - - # Wait for video element (triggers loading) - try: - await page.wait_for_selector('video', timeout=15000) - except: - logger.warning("[Playwright] Video selector timeout") - - # 1. Try extracting from __playinfo__ - # window.__playinfo__ contains dash streams - playinfo = await page.evaluate("window.__playinfo__") - - audio_url = None - - if playinfo and "data" in playinfo and "dash" in playinfo["data"]: - dash = playinfo["data"]["dash"] - if "audio" in dash and dash["audio"]: - audio_url = dash["audio"][0]["baseUrl"] - logger.info(f"[Playwright] Found audio stream in __playinfo__: {audio_url[:50]}...") - - # 2. If playinfo fails, try extracting video src (sometimes it's a blob, which we can't fetch easily without interception) - # But interception is complex. Let's try requests with Referer if we have URL. - - if not audio_url: - logger.warning("[Playwright] Could not find audio in __playinfo__") - return None - - # Download the audio stream - temp_path = temp_dir / f"bilibili_audio_{timestamp}.m4s" # usually m4s - - try: - api_request = context.request - headers = { - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Referer": "https://www.bilibili.com/" - } - - logger.info(f"[Playwright] Downloading audio stream...") - response = await api_request.get(audio_url, headers=headers) - - if response.status == 200: - body = await response.body() - with open(temp_path, 'wb') as f: - f.write(body) - - logger.info(f"[Playwright] Downloaded successfully: {temp_path}") - return temp_path - else: - logger.error(f"[Playwright] API Request failed: {response.status}") - return None - - except Exception as e: - logger.error(f"[Playwright] Download logic error: {e}") - return None - - except Exception as e: - logger.error(f"[Playwright] Bilibili download failed: {e}") - return None - finally: - if browser: - await browser.close() - if playwright: - await playwright.stop() +from app.modules.tools.router import router diff --git a/backend/app/api/videos.py b/backend/app/api/videos.py index 2f9eba2..853a278 100644 --- a/backend/app/api/videos.py +++ b/backend/app/api/videos.py @@ -1,478 +1 @@ -from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Request -from pydantic import BaseModel -from typing import Optional -from pathlib import Path -from loguru import logger -import uuid -import traceback -import time -import httpx -import os -from app.services.tts_service import TTSService -from app.services.video_service import VideoService -from app.services.lipsync_service import LipSyncService -from app.services.voice_clone_service import voice_clone_service -from app.services.assets_service import ( - get_style, - get_default_style, - resolve_bgm_path, - prepare_style_for_remotion, -) -from app.services.storage import storage_service -from app.services.whisper_service import whisper_service -from app.services.remotion_service import remotion_service -from app.core.config import settings -from app.core.deps import get_current_user - -router = APIRouter() - -class GenerateRequest(BaseModel): - text: str - voice: str = "zh-CN-YunxiNeural" - material_path: str - # 声音克隆模式新增字段 - tts_mode: str = "edgetts" # "edgetts" | "voiceclone" - ref_audio_id: Optional[str] = None # 参考音频 storage path - ref_text: Optional[str] = None # 参考音频的转写文字 - # 字幕和标题功能 - title: Optional[str] = None # 视频标题(片头显示) - enable_subtitles: bool = True # 是否启用逐字高亮字幕 - subtitle_style_id: Optional[str] = None # 字幕样式 ID - title_style_id: Optional[str] = None # 标题样式 ID - subtitle_font_size: Optional[int] = None # 字幕字号(覆盖样式) - title_font_size: Optional[int] = None # 标题字号(覆盖样式) - bgm_id: Optional[str] = None # 背景音乐 ID - bgm_volume: Optional[float] = 0.2 # 背景音乐音量 (0-1) - -tasks = {} # In-memory task store - -# 缓存 LipSync 服务实例和健康状态 -_lipsync_service: Optional[LipSyncService] = None -_lipsync_ready: Optional[bool] = None -_lipsync_last_check: float = 0 - -def _get_lipsync_service() -> LipSyncService: - """获取或创建 LipSync 服务实例(单例模式,避免重复初始化)""" - global _lipsync_service - if _lipsync_service is None: - _lipsync_service = LipSyncService() - return _lipsync_service - -async def _check_lipsync_ready(force: bool = False) -> bool: - """检查 LipSync 是否就绪(带缓存,5分钟内不重复检查)""" - global _lipsync_ready, _lipsync_last_check - - now = time.time() - # 5分钟缓存 - if not force and _lipsync_ready is not None and (now - _lipsync_last_check) < 300: - return bool(_lipsync_ready) - - lipsync = _get_lipsync_service() - health = await lipsync.check_health() - _lipsync_ready = health.get("ready", False) - _lipsync_last_check = now - print(f"[LipSync] Health check: ready={_lipsync_ready}") - return bool(_lipsync_ready) - -async def _download_material(path_or_url: str, temp_path: Path): - """下载素材到临时文件 (流式下载,节省内存)""" - if path_or_url.startswith("http"): - # Download from URL - timeout = httpx.Timeout(None) # Disable timeout for large files - async with httpx.AsyncClient(timeout=timeout) as client: - async with client.stream("GET", path_or_url) as resp: - resp.raise_for_status() - with open(temp_path, "wb") as f: - async for chunk in resp.aiter_bytes(): - f.write(chunk) - else: - # Local file (legacy or absolute path) - src = Path(path_or_url) - if not src.is_absolute(): - src = settings.BASE_DIR.parent / path_or_url - - if src.exists(): - import shutil - shutil.copy(src, temp_path) - else: - raise FileNotFoundError(f"Material not found: {path_or_url}") - -async def _process_video_generation(task_id: str, req: GenerateRequest, user_id: str): - temp_files = [] # Track files to clean up - try: - start_time = time.time() - - tasks[task_id]["status"] = "processing" - tasks[task_id]["progress"] = 5 - tasks[task_id]["message"] = "正在下载素材..." - - # Prepare temp dir - temp_dir = settings.UPLOAD_DIR / "temp" - temp_dir.mkdir(parents=True, exist_ok=True) - - # 0. Download Material - input_material_path = temp_dir / f"{task_id}_input.mp4" - temp_files.append(input_material_path) - - await _download_material(req.material_path, input_material_path) - - # 1. TTS - 进度 5% -> 25% - tasks[task_id]["message"] = "正在生成语音..." - tasks[task_id]["progress"] = 10 - - audio_path = temp_dir / f"{task_id}_audio.wav" - temp_files.append(audio_path) - - if req.tts_mode == "voiceclone": - # 声音克隆模式 - if not req.ref_audio_id or not req.ref_text: - raise ValueError("声音克隆模式需要提供参考音频和参考文字") - - tasks[task_id]["message"] = "正在下载参考音频..." - - # 从 Supabase 下载参考音频 - ref_audio_local = temp_dir / f"{task_id}_ref.wav" - temp_files.append(ref_audio_local) - - ref_audio_url = await storage_service.get_signed_url( - bucket="ref-audios", - path=req.ref_audio_id - ) - await _download_material(ref_audio_url, ref_audio_local) - - tasks[task_id]["message"] = "正在克隆声音 (Qwen3-TTS)..." - await voice_clone_service.generate_audio( - text=req.text, - ref_audio_path=str(ref_audio_local), - ref_text=req.ref_text, - output_path=str(audio_path), - language="Chinese" - ) - else: - # EdgeTTS 模式 (默认) - tasks[task_id]["message"] = "正在生成语音 (EdgeTTS)..." - tts = TTSService() - await tts.generate_audio(req.text, req.voice, str(audio_path)) - - tts_time = time.time() - start_time - print(f"[Pipeline] TTS completed in {tts_time:.1f}s") - tasks[task_id]["progress"] = 25 - - # 2. LipSync - 进度 25% -> 85% - tasks[task_id]["message"] = "正在合成唇形 (LatentSync)..." - tasks[task_id]["progress"] = 30 - - lipsync = _get_lipsync_service() - lipsync_video_path = temp_dir / f"{task_id}_lipsync.mp4" - temp_files.append(lipsync_video_path) - - # 使用缓存的健康检查结果 - lipsync_start = time.time() - is_ready = await _check_lipsync_ready() - - if is_ready: - print(f"[LipSync] Starting LatentSync inference...") - tasks[task_id]["progress"] = 35 - tasks[task_id]["message"] = "正在运行 LatentSync 推理..." - await lipsync.generate(str(input_material_path), str(audio_path), str(lipsync_video_path)) - else: - # Skip lipsync if not available - print(f"[LipSync] LatentSync not ready, copying original video") - tasks[task_id]["message"] = "唇形同步不可用,使用原始视频..." - import shutil - shutil.copy(str(input_material_path), lipsync_video_path) - - lipsync_time = time.time() - lipsync_start - print(f"[Pipeline] LipSync completed in {lipsync_time:.1f}s") - tasks[task_id]["progress"] = 80 - - # 3. WhisperX 字幕对齐 - 进度 80% -> 85% - captions_path = None - if req.enable_subtitles: - tasks[task_id]["message"] = "正在生成字幕 (Whisper)..." - tasks[task_id]["progress"] = 82 - - captions_path = temp_dir / f"{task_id}_captions.json" - temp_files.append(captions_path) - - try: - await whisper_service.align( - audio_path=str(audio_path), - text=req.text, - output_path=str(captions_path) - ) - print(f"[Pipeline] Whisper alignment completed") - except Exception as e: - logger.warning(f"Whisper alignment failed, skipping subtitles: {e}") - captions_path = None - - tasks[task_id]["progress"] = 85 - - # 3.5 背景音乐混音(不影响唇形与字幕对齐) - video = VideoService() - final_audio_path = audio_path - if req.bgm_id: - tasks[task_id]["message"] = "正在合成背景音乐..." - tasks[task_id]["progress"] = 86 - - bgm_path = resolve_bgm_path(req.bgm_id) - if bgm_path: - mix_output_path = temp_dir / f"{task_id}_audio_mix.wav" - temp_files.append(mix_output_path) - volume = req.bgm_volume if req.bgm_volume is not None else 0.2 - volume = max(0.0, min(float(volume), 1.0)) - try: - video.mix_audio( - voice_path=str(audio_path), - bgm_path=str(bgm_path), - output_path=str(mix_output_path), - bgm_volume=volume - ) - final_audio_path = mix_output_path - except Exception as e: - logger.warning(f"BGM mix failed, fallback to voice only: {e}") - else: - logger.warning(f"BGM not found: {req.bgm_id}") - - # 4. Remotion 视频合成(字幕 + 标题)- 进度 85% -> 95% - # 判断是否需要使用 Remotion(有字幕或标题时使用) - use_remotion = (captions_path and captions_path.exists()) or req.title - - subtitle_style = None - title_style = None - if req.enable_subtitles: - subtitle_style = get_style("subtitle", req.subtitle_style_id) or get_default_style("subtitle") - if req.title: - title_style = get_style("title", req.title_style_id) or get_default_style("title") - - if req.subtitle_font_size and req.enable_subtitles: - if subtitle_style is None: - subtitle_style = {} - subtitle_style["font_size"] = int(req.subtitle_font_size) - - if req.title_font_size and req.title: - if title_style is None: - title_style = {} - title_style["font_size"] = int(req.title_font_size) - - if use_remotion: - subtitle_style = prepare_style_for_remotion( - subtitle_style, - temp_dir, - f"{task_id}_subtitle_font" - ) - title_style = prepare_style_for_remotion( - title_style, - temp_dir, - f"{task_id}_title_font" - ) - - final_output_local_path = temp_dir / f"{task_id}_output.mp4" - temp_files.append(final_output_local_path) - - if use_remotion: - tasks[task_id]["message"] = "正在合成视频 (Remotion)..." - tasks[task_id]["progress"] = 87 - - # 先用 FFmpeg 合成音视频(Remotion 需要带音频的视频) - composed_video_path = temp_dir / f"{task_id}_composed.mp4" - temp_files.append(composed_video_path) - - await video.compose(str(lipsync_video_path), str(final_audio_path), str(composed_video_path)) - - # 检查 Remotion 是否可用 - remotion_health = await remotion_service.check_health() - if remotion_health.get("ready"): - try: - def on_remotion_progress(percent): - # 映射 Remotion 进度到 87-95% - mapped = 87 + int(percent * 0.08) - tasks[task_id]["progress"] = mapped - - await remotion_service.render( - video_path=str(composed_video_path), - output_path=str(final_output_local_path), - captions_path=str(captions_path) if captions_path else None, - title=req.title, - title_duration=3.0, - fps=25, - enable_subtitles=req.enable_subtitles, - subtitle_style=subtitle_style, - title_style=title_style, - on_progress=on_remotion_progress - ) - print(f"[Pipeline] Remotion render completed") - except Exception as e: - logger.warning(f"Remotion render failed, using FFmpeg fallback: {e}") - # 回退到 FFmpeg 合成 - import shutil - shutil.copy(str(composed_video_path), final_output_local_path) - else: - logger.warning(f"Remotion not ready: {remotion_health.get('error')}, using FFmpeg") - import shutil - shutil.copy(str(composed_video_path), final_output_local_path) - else: - # 不需要字幕和标题,直接用 FFmpeg 合成 - tasks[task_id]["message"] = "正在合成最终视频..." - tasks[task_id]["progress"] = 90 - - await video.compose(str(lipsync_video_path), str(final_audio_path), str(final_output_local_path)) - - total_time = time.time() - start_time - - # 4. Upload to Supabase with user isolation - tasks[task_id]["message"] = "正在上传结果..." - tasks[task_id]["progress"] = 95 - - # 使用 user_id 作为目录前缀实现隔离 - storage_path = f"{user_id}/{task_id}_output.mp4" - with open(final_output_local_path, "rb") as f: - file_data = f.read() - await storage_service.upload_file( - bucket=storage_service.BUCKET_OUTPUTS, - path=storage_path, - file_data=file_data, - content_type="video/mp4" - ) - - # Get Signed URL - signed_url = await storage_service.get_signed_url( - bucket=storage_service.BUCKET_OUTPUTS, - path=storage_path - ) - - print(f"[Pipeline] Total generation time: {total_time:.1f}s") - - tasks[task_id]["status"] = "completed" - tasks[task_id]["progress"] = 100 - tasks[task_id]["message"] = f"生成完成!耗时 {total_time:.0f} 秒" - tasks[task_id]["output"] = storage_path - tasks[task_id]["download_url"] = signed_url - - except Exception as e: - tasks[task_id]["status"] = "failed" - tasks[task_id]["message"] = f"错误: {str(e)}" - tasks[task_id]["error"] = traceback.format_exc() - logger.error(f"Generate video failed: {e}") - finally: - # Cleanup temp files - for f in temp_files: - try: - if f.exists(): - f.unlink() - except Exception as e: - print(f"Error cleaning up {f}: {e}") - -@router.post("/generate") -async def generate_video( - req: GenerateRequest, - background_tasks: BackgroundTasks, - current_user: dict = Depends(get_current_user) -): - user_id = current_user["id"] - task_id = str(uuid.uuid4()) - tasks[task_id] = {"status": "pending", "task_id": task_id, "progress": 0, "user_id": user_id} - background_tasks.add_task(_process_video_generation, task_id, req, user_id) - return {"task_id": task_id} - -@router.get("/tasks/{task_id}") -async def get_task(task_id: str): - return tasks.get(task_id, {"status": "not_found"}) - -@router.get("/tasks") -async def list_tasks(): - return {"tasks": list(tasks.values())} - -@router.get("/lipsync/health") -async def lipsync_health(): - """获取 LipSync 服务健康状态""" - lipsync = _get_lipsync_service() - return await lipsync.check_health() - - -@router.get("/voiceclone/health") -async def voiceclone_health(): - """获取声音克隆服务健康状态""" - return await voice_clone_service.check_health() - - -@router.get("/generated") -async def list_generated_videos(current_user: dict = Depends(get_current_user)): - """从 Storage 读取当前用户生成的视频列表""" - user_id = current_user["id"] - try: - # 只列出当前用户目录下的文件 - files_obj = await storage_service.list_files( - bucket=storage_service.BUCKET_OUTPUTS, - path=user_id - ) - - videos = [] - for f in files_obj: - name = f.get('name') - if not name or name == '.emptyFolderPlaceholder': - continue - - # 过滤非 output.mp4 文件 - if not name.endswith("_output.mp4"): - continue - - # 获取 ID (即文件名去除后缀) - video_id = Path(name).stem - - # 完整路径包含 user_id - full_path = f"{user_id}/{name}" - - # 获取签名链接 - signed_url = await storage_service.get_signed_url( - bucket=storage_service.BUCKET_OUTPUTS, - path=full_path - ) - - metadata = f.get('metadata', {}) - size = metadata.get('size', 0) - # created_at 在顶层,是 ISO 字符串,转换为 Unix 时间戳 - created_at_str = f.get('created_at', '') - created_at = 0 - if created_at_str: - from datetime import datetime - try: - dt = datetime.fromisoformat(created_at_str.replace('Z', '+00:00')) - created_at = int(dt.timestamp()) - except: - pass - - videos.append({ - "id": video_id, - "name": name, - "path": signed_url, # Direct playable URL - "size_mb": size / (1024 * 1024), - "created_at": created_at - }) - - # Sort by created_at desc (newest first) - # Supabase API usually returns ISO string, simpler string sort works for ISO - videos.sort(key=lambda x: x.get("created_at", ""), reverse=True) - return {"videos": videos} - - except Exception as e: - logger.error(f"List generated videos failed: {e}") - return {"videos": []} - - -@router.delete("/generated/{video_id}") -async def delete_generated_video(video_id: str, current_user: dict = Depends(get_current_user)): - """删除生成的视频""" - user_id = current_user["id"] - try: - # video_id 通常是 uuid_output,完整路径需要加上 user_id - storage_path = f"{user_id}/{video_id}.mp4" - - await storage_service.delete_file( - bucket=storage_service.BUCKET_OUTPUTS, - path=storage_path - ) - return {"success": True, "message": "视频已删除"} - except Exception as e: - raise HTTPException(500, f"删除失败: {str(e)}") - +from app.modules.videos.router import router diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index fbcaccc..3fb17cf 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -1,10 +1,11 @@ """ 依赖注入模块:认证和用户获取 """ -from typing import Optional +from typing import Optional, Any, Dict, cast from fastapi import Request, HTTPException, Depends, status from app.core.security import decode_access_token, TokenData -from app.core.supabase import get_supabase +from app.repositories.sessions import get_session +from app.repositories.users import get_user_by_id from loguru import logger @@ -13,9 +14,9 @@ async def get_token_from_cookie(request: Request) -> Optional[str]: return request.cookies.get("access_token") -async def get_current_user_optional( - request: Request -) -> Optional[dict]: +async def get_current_user_optional( + request: Request +) -> Optional[Dict[str, Any]]: """ 获取当前用户 (可选,未登录返回 None) """ @@ -28,32 +29,22 @@ async def get_current_user_optional( return None # 验证 session_token 是否有效 (单设备登录检查) - try: - supabase = get_supabase() - result = supabase.table("user_sessions").select("*").eq( - "user_id", token_data.user_id - ).eq( - "session_token", token_data.session_token - ).execute() - - if not result.data: - logger.warning(f"Session token 无效: user_id={token_data.user_id}") - return None - - # 获取用户信息 - user_result = supabase.table("users").select("*").eq( - "id", token_data.user_id - ).single().execute() - - return user_result.data - except Exception as e: - logger.error(f"获取用户信息失败: {e}") - return None + try: + session = get_session(token_data.user_id, token_data.session_token) + if not session: + logger.warning(f"Session token 无效: user_id={token_data.user_id}") + return None + + user = get_user_by_id(token_data.user_id) + return cast(Optional[Dict[str, Any]], user) + except Exception as e: + logger.error(f"获取用户信息失败: {e}") + return None -async def get_current_user( - request: Request -) -> dict: +async def get_current_user( + request: Request +) -> Dict[str, Any]: """ 获取当前用户 (必须登录) @@ -75,53 +66,40 @@ async def get_current_user( detail="Token 无效或已过期" ) - try: - supabase = get_supabase() - - # 验证 session_token (单设备登录) - session_result = supabase.table("user_sessions").select("*").eq( - "user_id", token_data.user_id - ).eq( - "session_token", token_data.session_token - ).execute() - - if not session_result.data: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="会话已失效,请重新登录(可能已在其他设备登录)" - ) - - # 获取用户信息 - user_result = supabase.table("users").select("*").eq( - "id", token_data.user_id - ).single().execute() - - user = user_result.data - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="用户不存在" - ) - - # 检查授权是否过期 - if user.get("expires_at"): - from datetime import datetime, timezone - expires_at = datetime.fromisoformat(user["expires_at"].replace("Z", "+00:00")) - if datetime.now(timezone.utc) > expires_at: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="授权已过期,请联系管理员续期" - ) - - return user - except HTTPException: - raise - except Exception as e: - logger.error(f"获取用户信息失败: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="服务器错误" - ) + try: + session = get_session(token_data.user_id, token_data.session_token) + if not session: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="会话已失效,请重新登录(可能已在其他设备登录)" + ) + + user = get_user_by_id(token_data.user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户不存在" + ) + user = cast(Dict[str, Any], user) + + if user.get("expires_at"): + from datetime import datetime, timezone + expires_at = datetime.fromisoformat(user["expires_at"].replace("Z", "+00:00")) + if datetime.now(timezone.utc) > expires_at: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="授权已过期,请联系管理员续期" + ) + + return user + except HTTPException: + raise + except Exception as e: + logger.error(f"获取用户信息失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="服务器错误" + ) async def get_current_admin( diff --git a/backend/app/core/response.py b/backend/app/core/response.py new file mode 100644 index 0000000..78e2577 --- /dev/null +++ b/backend/app/core/response.py @@ -0,0 +1,26 @@ +from typing import Any, Dict, Optional + + +def success_response( + data: Any = None, + message: str = "ok", + code: int = 0, + success: bool = True, +) -> Dict[str, Any]: + return { + "success": success, + "message": message, + "data": data, + "code": code, + } + + +def error_response(message: str, code: int, data: Optional[Any] = None) -> Dict[str, Any]: + payload = { + "success": False, + "message": message, + "code": code, + } + if data is not None: + payload["data"] = data + return payload diff --git a/backend/app/main.py b/backend/app/main.py index f30fd3b..8644f89 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,9 @@ -from fastapi import FastAPI -from fastapi.staticfiles import StaticFiles -from fastapi.middleware.cors import CORSMiddleware -from app.core import config +from fastapi import FastAPI, HTTPException +from fastapi.staticfiles import StaticFiles +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from app.core import config +from app.core.response import error_response from app.api import materials, videos, publish, login_helper, auth, admin, ref_audios, ai, tools, assets from loguru import logger import os @@ -10,7 +12,8 @@ settings = config.settings app = FastAPI(title="ViGent TalkingHead Agent") -from fastapi import Request +from fastapi import Request +from fastapi.exceptions import RequestValidationError from starlette.middleware.base import BaseHTTPMiddleware import time import traceback @@ -30,7 +33,35 @@ class LoggingMiddleware(BaseHTTPMiddleware): logger.error(f"EXCEPTION during request {request.method} {request.url}: {str(e)}\n{traceback.format_exc()}") raise e -app.add_middleware(LoggingMiddleware) +app.add_middleware(LoggingMiddleware) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + return JSONResponse( + status_code=422, + content=error_response("参数校验失败", 422, data=exc.errors()), + ) + + +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException): + detail = exc.detail + message = detail if isinstance(detail, str) else "请求失败" + data = detail if not isinstance(detail, str) else None + return JSONResponse( + status_code=exc.status_code, + content=error_response(message, exc.status_code, data=data), + headers=exc.headers, + ) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception): + return JSONResponse( + status_code=500, + content=error_response("服务器内部错误", 500), + ) app.add_middleware( CORSMiddleware, @@ -76,27 +107,21 @@ async def init_admin(): return try: - from app.core.supabase import get_supabase - from app.core.security import get_password_hash - - supabase = get_supabase() - - # 检查是否已存在 - existing = supabase.table("users").select("id").eq("phone", admin_phone).execute() - - if existing.data: - logger.info(f"管理员账号已存在: {admin_phone}") - return - - # 创建管理员 - supabase.table("users").insert({ - "phone": admin_phone, - "password_hash": get_password_hash(admin_password), - "username": "Admin", - "role": "admin", - "is_active": True, - "expires_at": None # 永不过期 - }).execute() + from app.core.security import get_password_hash + from app.repositories.users import create_user, user_exists_by_phone + + if user_exists_by_phone(admin_phone): + logger.info(f"管理员账号已存在: {admin_phone}") + return + + create_user({ + "phone": admin_phone, + "password_hash": get_password_hash(admin_password), + "username": "Admin", + "role": "admin", + "is_active": True, + "expires_at": None # 永不过期 + }) logger.success(f"管理员账号已创建: {admin_phone}") except Exception as e: diff --git a/backend/app/modules/__init__.py b/backend/app/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/admin/__init__.py b/backend/app/modules/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/admin/router.py b/backend/app/modules/admin/router.py new file mode 100644 index 0000000..b16cde4 --- /dev/null +++ b/backend/app/modules/admin/router.py @@ -0,0 +1,164 @@ +""" +管理员 API:用户管理 +""" +from fastapi import APIRouter, HTTPException, Depends, status +from pydantic import BaseModel +from typing import Optional, List, Any, cast +from datetime import datetime, timezone, timedelta +from app.core.deps import get_current_admin +from app.core.response import success_response +from app.repositories.sessions import delete_sessions +from app.repositories.users import get_user_by_id, list_users as list_users_repo, update_user +from loguru import logger + +router = APIRouter(prefix="/api/admin", tags=["管理"]) + + +class UserListItem(BaseModel): + id: str + phone: str + username: Optional[str] + role: str + is_active: bool + expires_at: Optional[str] + created_at: str + + +class ActivateRequest(BaseModel): + expires_days: Optional[int] = None # 授权天数,None 表示永久 + + +@router.get("/users") +async def list_users(admin: dict = Depends(get_current_admin)): + """获取所有用户列表""" + try: + data = list_users_repo() + return success_response([ + UserListItem( + id=u["id"], + phone=u["phone"], + username=u.get("username"), + role=u["role"], + is_active=u["is_active"], + expires_at=u.get("expires_at"), + created_at=u["created_at"] + ).model_dump() + for u in data + ]) + except Exception as e: + logger.error(f"获取用户列表失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="获取用户列表失败" + ) + + +@router.post("/users/{user_id}/activate") +async def activate_user( + user_id: str, + request: ActivateRequest, + admin: dict = Depends(get_current_admin) +): + """ + 激活用户 + + Args: + user_id: 用户 ID + request.expires_days: 授权天数 (None 表示永久) + """ + try: + # 计算过期时间 + expires_at = None + if request.expires_days: + expires_at = (datetime.now(timezone.utc) + timedelta(days=request.expires_days)).isoformat() + + result = update_user(user_id, { + "is_active": True, + "role": "user", + "expires_at": expires_at + }) + + if not result: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="用户不存在" + ) + + logger.info(f"管理员 {admin['phone']} 激活用户 {user_id}, 有效期: {request.expires_days or '永久'} 天") + + return success_response(message=f"用户已激活,有效期: {request.expires_days or '永久'} 天") + except HTTPException: + raise + except Exception as e: + logger.error(f"激活用户失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="激活用户失败" + ) + + +@router.post("/users/{user_id}/deactivate") +async def deactivate_user( + user_id: str, + admin: dict = Depends(get_current_admin) +): + """停用用户""" + try: + # 不能停用管理员 + user = cast(dict[str, Any], get_user_by_id(user_id) or {}) + if user.get("role") == "admin": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="不能停用管理员账号" + ) + + update_user(user_id, {"is_active": False}) + delete_sessions(user_id) + + logger.info(f"管理员 {admin['phone']} 停用用户 {user_id}") + + return success_response(message="用户已停用") + except HTTPException: + raise + except Exception as e: + logger.error(f"停用用户失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="停用用户失败" + ) + + +@router.post("/users/{user_id}/extend") +async def extend_user( + user_id: str, + request: ActivateRequest, + admin: dict = Depends(get_current_admin) +): + """延长用户授权期限""" + try: + if not request.expires_days: + # 设为永久 + expires_at = None + else: + # 获取当前过期时间 + user = cast(dict[str, Any], get_user_by_id(user_id) or {}) + + if user and user.get("expires_at"): + current_expires = datetime.fromisoformat(user["expires_at"].replace("Z", "+00:00")) + base_time = max(current_expires, datetime.now(timezone.utc)) + else: + base_time = datetime.now(timezone.utc) + + expires_at = (base_time + timedelta(days=request.expires_days)).isoformat() + + update_user(user_id, {"expires_at": expires_at}) + + logger.info(f"管理员 {admin['phone']} 延长用户 {user_id} 授权 {request.expires_days or '永久'} 天") + + return success_response(message=f"授权已延长 {request.expires_days or '永久'} 天") + except Exception as e: + logger.error(f"延长授权失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="延长授权失败" + ) diff --git a/backend/app/modules/ai/__init__.py b/backend/app/modules/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/ai/router.py b/backend/app/modules/ai/router.py new file mode 100644 index 0000000..6e075dd --- /dev/null +++ b/backend/app/modules/ai/router.py @@ -0,0 +1,46 @@ +""" +AI 相关 API 路由 +""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from loguru import logger + +from app.services.glm_service import glm_service +from app.core.response import success_response + + +router = APIRouter(prefix="/api/ai", tags=["AI"]) + + +class GenerateMetaRequest(BaseModel): + """生成标题标签请求""" + text: str + + +class GenerateMetaResponse(BaseModel): + """生成标题标签响应""" + title: str + tags: list[str] + + +@router.post("/generate-meta") +async def generate_meta(req: GenerateMetaRequest): + """ + AI 生成视频标题和标签 + + 根据口播文案自动生成吸引人的标题和相关标签 + """ + if not req.text or not req.text.strip(): + raise HTTPException(status_code=400, detail="口播文案不能为空") + + try: + logger.info(f"Generating meta for text: {req.text[:50]}...") + result = await glm_service.generate_title_tags(req.text) + return success_response(GenerateMetaResponse( + title=result.get("title", ""), + tags=result.get("tags", []) + ).model_dump()) + except Exception as e: + logger.error(f"Generate meta failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/modules/assets/__init__.py b/backend/app/modules/assets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/assets/router.py b/backend/app/modules/assets/router.py new file mode 100644 index 0000000..b7f3aa7 --- /dev/null +++ b/backend/app/modules/assets/router.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter, Depends + +from app.core.deps import get_current_user +from app.services.assets_service import list_styles, list_bgm +from app.core.response import success_response + + +router = APIRouter() + + +@router.get("/subtitle-styles") +async def list_subtitle_styles(current_user: dict = Depends(get_current_user)): + return success_response({"styles": list_styles("subtitle")}) + + +@router.get("/title-styles") +async def list_title_styles(current_user: dict = Depends(get_current_user)): + return success_response({"styles": list_styles("title")}) + + +@router.get("/bgm") +async def list_bgm_items(current_user: dict = Depends(get_current_user)): + return success_response({"bgm": list_bgm()}) diff --git a/backend/app/modules/auth/__init__.py b/backend/app/modules/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/auth/router.py b/backend/app/modules/auth/router.py new file mode 100644 index 0000000..a12856a --- /dev/null +++ b/backend/app/modules/auth/router.py @@ -0,0 +1,293 @@ +""" +认证 API:注册、登录、登出、修改密码 +""" +from fastapi import APIRouter, HTTPException, Response, status, Request +from pydantic import BaseModel, field_validator +from app.core.security import ( + get_password_hash, + verify_password, + create_access_token, + generate_session_token, + set_auth_cookie, + clear_auth_cookie, + decode_access_token +) +from app.repositories.sessions import create_session, delete_sessions +from app.repositories.users import create_user, get_user_by_id, get_user_by_phone, user_exists_by_phone, update_user +from app.core.response import success_response +from loguru import logger +from typing import Optional, Any, cast +import re + +router = APIRouter(prefix="/api/auth", tags=["认证"]) + + +class RegisterRequest(BaseModel): + phone: str + password: str + username: Optional[str] = None + + @field_validator('phone') + @classmethod + def validate_phone(cls, v): + if not re.match(r'^\d{11}$', v): + raise ValueError('手机号必须是11位数字') + return v + + +class LoginRequest(BaseModel): + phone: str + password: str + + @field_validator('phone') + @classmethod + def validate_phone(cls, v): + if not re.match(r'^\d{11}$', v): + raise ValueError('手机号必须是11位数字') + return v + + +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str + + @field_validator('new_password') + @classmethod + def validate_new_password(cls, v): + if len(v) < 6: + raise ValueError('新密码长度至少6位') + return v + + +class UserResponse(BaseModel): + id: str + phone: str + username: Optional[str] + role: str + is_active: bool + expires_at: Optional[str] = None + + +@router.post("/register") +async def register(request: RegisterRequest): + """ + 用户注册 + + 注册后状态为 pending,需要管理员激活 + """ + try: + if user_exists_by_phone(request.phone): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="该手机号已注册" + ) + + # 创建用户 + password_hash = get_password_hash(request.password) + + create_user({ + "phone": request.phone, + "password_hash": password_hash, + "username": request.username or f"用户{request.phone[-4:]}", + "role": "pending", + "is_active": False + }) + + logger.info(f"新用户注册: {request.phone}") + + return success_response(message="注册成功,请等待管理员审核激活") + except HTTPException: + raise + except Exception as e: + logger.error(f"注册失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="注册失败,请稍后重试" + ) + + +@router.post("/login") +async def login(request: LoginRequest, response: Response): + """ + 用户登录 + + - 验证密码 + - 检查是否激活 + - 实现"后踢前"单设备登录 + """ + try: + user = cast(dict[str, Any], get_user_by_phone(request.phone) or {}) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="手机号或密码错误" + ) + + # 验证密码 + if not verify_password(request.password, user["password_hash"]): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="手机号或密码错误" + ) + + # 检查是否激活 + if not user["is_active"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="账号未激活,请等待管理员审核" + ) + + # 检查授权是否过期 + if user.get("expires_at"): + from datetime import datetime, timezone + expires_at = datetime.fromisoformat(user["expires_at"].replace("Z", "+00:00")) + if datetime.now(timezone.utc) > expires_at: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="授权已过期,请联系管理员续期" + ) + + # 生成新的 session_token (后踢前) + session_token = generate_session_token() + + # 删除旧 session,插入新 session + delete_sessions(user["id"]) + create_session(user["id"], session_token, None) + + # 生成 JWT Token + token = create_access_token(user["id"], session_token) + + # 设置 HttpOnly Cookie + set_auth_cookie(response, token) + + logger.info(f"用户登录: {request.phone}") + + return success_response( + data={ + "user": UserResponse( + id=user["id"], + phone=user["phone"], + username=user.get("username"), + role=user["role"], + is_active=user["is_active"], + expires_at=user.get("expires_at") + ).model_dump() + }, + message="登录成功", + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"登录失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="登录失败,请稍后重试" + ) + + +@router.post("/logout") +async def logout(response: Response): + """用户登出""" + clear_auth_cookie(response) + return success_response(message="已登出") + + +@router.post("/change-password") +async def change_password(request: ChangePasswordRequest, req: Request, response: Response): + """ + 修改密码 + + - 验证当前密码 + - 设置新密码 + - 重新生成 session token + """ + # 从 Cookie 获取用户 + token = req.cookies.get("access_token") + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="未登录" + ) + + token_data = decode_access_token(token) + if not token_data: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token 无效" + ) + + try: + user = cast(dict[str, Any], get_user_by_id(token_data.user_id) or {}) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户不存在" + ) + + # 验证当前密码 + if not verify_password(request.old_password, user["password_hash"]): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="当前密码错误" + ) + + # 更新密码 + new_password_hash = get_password_hash(request.new_password) + update_user(user["id"], {"password_hash": new_password_hash}) + + # 生成新的 session token,使旧 token 失效 + new_session_token = generate_session_token() + + delete_sessions(user["id"]) + create_session(user["id"], new_session_token, None) + + # 生成新的 JWT Token + new_token = create_access_token(user["id"], new_session_token) + set_auth_cookie(response, new_token) + + logger.info(f"用户修改密码: {user['phone']}") + + return success_response(message="密码修改成功") + except HTTPException: + raise + except Exception as e: + logger.error(f"修改密码失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="修改密码失败,请稍后重试" + ) + + +@router.get("/me") +async def get_me(request: Request): + """获取当前用户信息""" + # 从 Cookie 获取用户 + token = request.cookies.get("access_token") + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="未登录" + ) + + token_data = decode_access_token(token) + if not token_data: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token 无效" + ) + + user = cast(dict[str, Any], get_user_by_id(token_data.user_id) or {}) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户不存在" + ) + + return success_response(UserResponse( + id=user["id"], + phone=user["phone"], + username=user.get("username"), + role=user["role"], + is_active=user["is_active"], + expires_at=user.get("expires_at") + ).model_dump()) diff --git a/backend/app/modules/login_helper/__init__.py b/backend/app/modules/login_helper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/login_helper/router.py b/backend/app/modules/login_helper/router.py new file mode 100644 index 0000000..4f05638 --- /dev/null +++ b/backend/app/modules/login_helper/router.py @@ -0,0 +1,221 @@ +""" +前端一键扫码登录辅助页面 +客户在自己的浏览器中扫码,JavaScript自动提取Cookie并上传到服务器 +""" +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from app.core.config import settings + +router = APIRouter() + +@router.get("/login-helper/{platform}", response_class=HTMLResponse) +async def login_helper_page(platform: str, request: Request): + """ + 提供一个HTML页面,让用户在自己的浏览器中登录平台 + 登录后JavaScript自动提取Cookie并POST回服务器 + """ + + platform_urls = { + "bilibili": "https://www.bilibili.com/", + "douyin": "https://creator.douyin.com/", + "xiaohongshu": "https://creator.xiaohongshu.com/" + } + + platform_names = { + "bilibili": "B站", + "douyin": "抖音", + "xiaohongshu": "小红书" + } + + if platform not in platform_urls: + return "

不支持的平台

" + + # 获取服务器地址(用于回传Cookie) + server_url = str(request.base_url).rstrip('/') + + html_content = f""" + + + + + + {platform_names[platform]} 一键登录 + + + +
+

🔐 {platform_names[platform]} 一键登录

+ +
+
1
+
+
拖拽书签到书签栏
+
+ 将下方的"保存{platform_names[platform]}登录"按钮拖拽到浏览器书签栏 +
(如果书签栏未显示,按 Ctrl+Shift+B 显示) +
+
+
+ +
+ + 🔖 保存{platform_names[platform]}登录 + +
+ ⬆️ 拖拽此按钮到浏览器顶部书签栏 +
+
+ +
+
2
+
+
登录 {platform_names[platform]}
+
+ 点击下方按钮打开{platform_names[platform]}登录页,扫码登录 +
+
+
+ + + +
+
3
+
+
一键保存登录
+
+ 登录成功后,点击书签栏的"保存{platform_names[platform]}登录"书签 +
系统会自动提取并保存Cookie,完成! +
+
+
+ +
+ +
+

💡 提示:书签只需拖拽一次,下次登录直接点击书签即可

+

🔒 所有数据仅在您的浏览器和服务器之间传输,安全可靠

+
+
+ + + """ + + return HTMLResponse(content=html_content) diff --git a/backend/app/modules/materials/__init__.py b/backend/app/modules/materials/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/materials/router.py b/backend/app/modules/materials/router.py new file mode 100644 index 0000000..08fdda5 --- /dev/null +++ b/backend/app/modules/materials/router.py @@ -0,0 +1,416 @@ +from fastapi import APIRouter, UploadFile, File, HTTPException, Request, BackgroundTasks, Depends +from app.core.config import settings +from app.core.deps import get_current_user +from app.core.response import success_response +from app.services.storage import storage_service +import re +import time +import traceback +import os +import aiofiles +from pathlib import Path +from loguru import logger +import asyncio +from pydantic import BaseModel +from typing import Optional +import httpx + + +router = APIRouter() + + +class RenameMaterialRequest(BaseModel): + new_name: str + +def sanitize_filename(filename: str) -> str: + safe_name = re.sub(r'[<>:"/\\|?*]', '_', filename) + if len(safe_name) > 100: + ext = Path(safe_name).suffix + safe_name = safe_name[:100 - len(ext)] + ext + return safe_name + +async def process_and_upload(temp_file_path: str, original_filename: str, content_type: str, user_id: str): + """Background task to strip multipart headers and upload to Supabase""" + try: + logger.info(f"Processing raw upload: {temp_file_path} for user {user_id}") + + # 1. Analyze file to find actual video content (strip multipart boundaries) + # This is a simplified manual parser for a SINGLE file upload. + # Structure: + # --boundary + # Content-Disposition: form-data; name="file"; filename="..." + # Content-Type: video/mp4 + # \r\n\r\n + # [DATA] + # \r\n--boundary-- + + # We need to read the first few KB to find the header end + start_offset = 0 + end_offset = 0 + boundary = b"" + + file_size = os.path.getsize(temp_file_path) + + with open(temp_file_path, 'rb') as f: + # Read first 4KB to find header + head = f.read(4096) + + # Find boundary + first_line_end = head.find(b'\r\n') + if first_line_end == -1: + raise Exception("Could not find boundary in multipart body") + + boundary = head[:first_line_end] # e.g. --boundary123 + logger.info(f"Detected boundary: {boundary}") + + # Find end of headers (\r\n\r\n) + header_end = head.find(b'\r\n\r\n') + if header_end == -1: + raise Exception("Could not find end of multipart headers") + + start_offset = header_end + 4 + logger.info(f"Video data starts at offset: {start_offset}") + + # Find end boundary (read from end of file) + # It should be \r\n + boundary + -- + \r\n + # We seek to end-200 bytes + f.seek(max(0, file_size - 200)) + tail = f.read() + + # The closing boundary is usually --boundary-- + # We look for the last occurrence of the boundary + last_boundary_pos = tail.rfind(boundary) + if last_boundary_pos != -1: + # The data ends before \r\n + boundary + # The tail buffer relative position needs to be converted to absolute + end_pos_in_tail = last_boundary_pos + # We also need to check for the preceding \r\n + if end_pos_in_tail >= 2 and tail[end_pos_in_tail-2:end_pos_in_tail] == b'\r\n': + end_pos_in_tail -= 2 + + # Absolute end offset + end_offset = (file_size - 200) + last_boundary_pos + # Correction for CRLF before boundary + # Actually, simply: read until (file_size - len(tail) + last_boundary_pos) - 2 + end_offset = (max(0, file_size - 200) + last_boundary_pos) - 2 + else: + logger.warning("Could not find closing boundary, assuming EOF") + end_offset = file_size + + logger.info(f"Video data ends at offset: {end_offset}. Total video size: {end_offset - start_offset}") + + # 2. Extract and Upload to Supabase + # Since we have the file on disk, we can just pass the file object (seeked) to upload_file? + # Or if upload_file expects bytes/path, checking storage.py... + # It takes `file_data` (bytes) or file-like? + # supabase-py's `upload` method handles parsing if we pass a file object. + # But we need to pass ONLY the video slice. + # So we create a generator or a sliced file object? + # Simpler: Read the slice into memory if < 1GB? Or copy to new temp file? + # Copying to new temp file is safer for memory. + + video_path = temp_file_path + "_video.mp4" + with open(temp_file_path, 'rb') as src, open(video_path, 'wb') as dst: + src.seek(start_offset) + # Copy in chunks + bytes_to_copy = end_offset - start_offset + copied = 0 + while copied < bytes_to_copy: + chunk_size = min(1024*1024*10, bytes_to_copy - copied) # 10MB chunks + chunk = src.read(chunk_size) + if not chunk: + break + dst.write(chunk) + copied += len(chunk) + + logger.info(f"Extracted video content to {video_path}") + + # 3. Upload to Supabase with user isolation + timestamp = int(time.time()) + safe_name = re.sub(r'[^a-zA-Z0-9._-]', '', original_filename) + # 使用 user_id 作为目录前缀实现隔离 + storage_path = f"{user_id}/{timestamp}_{safe_name}" + + # Use storage service (this calls Supabase which might do its own http request) + # We read the cleaned video file + with open(video_path, 'rb') as f: + file_content = f.read() # Still reading into memory for simple upload call, but server has 32GB RAM so ok for 500MB + await storage_service.upload_file( + bucket=storage_service.BUCKET_MATERIALS, + path=storage_path, + file_data=file_content, + content_type=content_type + ) + + logger.info(f"Upload to Supabase complete: {storage_path}") + + # Cleanup + os.remove(temp_file_path) + os.remove(video_path) + + return storage_path + + except Exception as e: + logger.error(f"Background upload processing failed: {e}\n{traceback.format_exc()}") + raise + + +@router.post("") +async def upload_material( + request: Request, + background_tasks: BackgroundTasks, + current_user: dict = Depends(get_current_user) +): + user_id = current_user["id"] + logger.info(f"ENTERED upload_material (Streaming Mode) for user {user_id}. Headers: {request.headers}") + + filename = "unknown_video.mp4" # Fallback + content_type = "video/mp4" + + # Try to parse filename from header if possible (unreliable in raw stream) + # We will rely on post-processing or client hint + # Frontend sends standard multipart. + + # Create temp file + timestamp = int(time.time()) + temp_filename = f"upload_{timestamp}.raw" + temp_path = os.path.join("/tmp", temp_filename) # Use /tmp on Linux + # Ensure /tmp exists (it does) but verify paths + if os.name == 'nt': # Local dev + temp_path = f"d:/tmp/{temp_filename}" + os.makedirs("d:/tmp", exist_ok=True) + + try: + total_size = 0 + last_log = 0 + + async with aiofiles.open(temp_path, 'wb') as f: + async for chunk in request.stream(): + await f.write(chunk) + total_size += len(chunk) + + # Log progress every 20MB + if total_size - last_log > 20 * 1024 * 1024: + logger.info(f"Receiving stream... Processed {total_size / (1024*1024):.2f} MB") + last_log = total_size + + logger.info(f"Stream reception complete. Total size: {total_size} bytes. Saved to {temp_path}") + + if total_size == 0: + raise HTTPException(400, "Received empty body") + + # Attempt to extract filename from the saved file's first bytes? + # Or just accept it as "uploaded_video.mp4" for now to prove it works. + # We can try to regex the header in the file content we just wrote. + # Implemented in background task to return success immediately. + + # Wait, if we return immediately, the user's UI might not show the file yet? + # The prompt says "Wait for upload". + # But to avoid User Waiting Timeout, maybe returning early is better? + # NO, user expects the file to be in the list. + # So we Must await the processing. + # But "Processing" (Strip + Upload to Supabase) takes time. + # Receiving took time. + # If we await Supabase upload, does it timeout? + # Supabase upload is outgoing. Usually faster/stable. + + # Let's await the processing to ensure "List Materials" shows it. + # We need to extract the filename for the list. + + # Quick extract filename from first 4kb + with open(temp_path, 'rb') as f: + head = f.read(4096).decode('utf-8', errors='ignore') + match = re.search(r'filename="([^"]+)"', head) + if match: + filename = match.group(1) + logger.info(f"Extracted filename from body: {filename}") + + # Run processing sync (in await) + storage_path = await process_and_upload(temp_path, filename, content_type, user_id) + + # Get signed URL (it exists now) + signed_url = await storage_service.get_signed_url( + bucket=storage_service.BUCKET_MATERIALS, + path=storage_path + ) + + size_mb = total_size / (1024 * 1024) # Approximate (includes headers) + + # 从 storage_path 提取显示名 + display_name = storage_path.split('/')[-1] # 去掉 user_id 前缀 + if '_' in display_name: + parts = display_name.split('_', 1) + if parts[0].isdigit(): + display_name = parts[1] + + return success_response({ + "id": storage_path, + "name": display_name, + "path": signed_url, + "size_mb": size_mb, + "type": "video" + }) + + except Exception as e: + error_msg = f"Streaming upload failed: {str(e)}" + detail_msg = f"Exception: {repr(e)}\nArgs: {e.args}\n{traceback.format_exc()}" + logger.error(error_msg + "\n" + detail_msg) + + # Write to debug file + try: + with open("debug_upload.log", "a") as logf: + logf.write(f"\n--- Error at {time.ctime()} ---\n") + logf.write(detail_msg) + logf.write("\n-----------------------------\n") + except: + pass + + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except: + pass + raise HTTPException(500, f"Upload failed. Check server logs. Error: {str(e)}") + + +@router.get("") +async def list_materials(current_user: dict = Depends(get_current_user)): + user_id = current_user["id"] + try: + # 只列出当前用户目录下的文件 + files_obj = await storage_service.list_files( + bucket=storage_service.BUCKET_MATERIALS, + path=user_id + ) + semaphore = asyncio.Semaphore(8) + + async def build_item(f): + name = f.get('name') + if not name or name == '.emptyFolderPlaceholder': + return None + display_name = name + if '_' in name: + parts = name.split('_', 1) + if parts[0].isdigit(): + display_name = parts[1] + full_path = f"{user_id}/{name}" + async with semaphore: + signed_url = await storage_service.get_signed_url( + bucket=storage_service.BUCKET_MATERIALS, + path=full_path + ) + metadata = f.get('metadata', {}) + size = metadata.get('size', 0) + created_at_str = f.get('created_at', '') + created_at = 0 + if created_at_str: + from datetime import datetime + try: + dt = datetime.fromisoformat(created_at_str.replace('Z', '+00:00')) + created_at = int(dt.timestamp()) + except Exception: + pass + return { + "id": full_path, + "name": display_name, + "path": signed_url, + "size_mb": size / (1024 * 1024), + "type": "video", + "created_at": created_at + } + + tasks = [build_item(f) for f in files_obj] + results = await asyncio.gather(*tasks, return_exceptions=True) + + materials = [] + for item in results: + if not item: + continue + if isinstance(item, Exception): + logger.warning(f"Material signed url build failed: {item}") + continue + materials.append(item) + materials.sort(key=lambda x: x['id'], reverse=True) + return success_response({"materials": materials}) + except Exception as e: + logger.error(f"List materials failed: {e}") + return success_response({"materials": []}, message="获取素材失败") + + +@router.delete("/{material_id:path}") +async def delete_material(material_id: str, current_user: dict = Depends(get_current_user)): + user_id = current_user["id"] + # 验证 material_id 属于当前用户 + if not material_id.startswith(f"{user_id}/"): + raise HTTPException(403, "无权删除此素材") + try: + await storage_service.delete_file( + bucket=storage_service.BUCKET_MATERIALS, + path=material_id + ) + return success_response(message="素材已删除") + except Exception as e: + raise HTTPException(500, f"删除失败: {str(e)}") + + +@router.put("/{material_id:path}") +async def rename_material( + material_id: str, + payload: RenameMaterialRequest, + current_user: dict = Depends(get_current_user) +): + user_id = current_user["id"] + if not material_id.startswith(f"{user_id}/"): + raise HTTPException(403, "无权重命名此素材") + + new_name_raw = payload.new_name.strip() if payload.new_name else "" + if not new_name_raw: + raise HTTPException(400, "新名称不能为空") + + old_name = material_id.split("/", 1)[1] + old_ext = Path(old_name).suffix + base_name = Path(new_name_raw).stem if Path(new_name_raw).suffix else new_name_raw + safe_base = sanitize_filename(base_name).strip() + if not safe_base: + raise HTTPException(400, "新名称无效") + + new_filename = f"{safe_base}{old_ext}" + + prefix = None + if "_" in old_name: + maybe_prefix, _ = old_name.split("_", 1) + if maybe_prefix.isdigit(): + prefix = maybe_prefix + if prefix: + new_filename = f"{prefix}_{new_filename}" + + new_path = f"{user_id}/{new_filename}" + try: + if new_path != material_id: + await storage_service.move_file( + bucket=storage_service.BUCKET_MATERIALS, + from_path=material_id, + to_path=new_path + ) + + signed_url = await storage_service.get_signed_url( + bucket=storage_service.BUCKET_MATERIALS, + path=new_path + ) + + display_name = new_filename + if "_" in new_filename: + parts = new_filename.split("_", 1) + if parts[0].isdigit(): + display_name = parts[1] + + return success_response({ + "id": new_path, + "name": display_name, + "path": signed_url, + }, message="重命名成功") + except Exception as e: + raise HTTPException(500, f"重命名失败: {str(e)}") + + + diff --git a/backend/app/modules/publish/__init__.py b/backend/app/modules/publish/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/publish/router.py b/backend/app/modules/publish/router.py new file mode 100644 index 0000000..d433872 --- /dev/null +++ b/backend/app/modules/publish/router.py @@ -0,0 +1,141 @@ +""" +发布管理 API (支持用户认证) +""" +from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Request +from pydantic import BaseModel +from typing import List, Optional +from datetime import datetime +from loguru import logger +from app.services.publish_service import PublishService +from app.core.response import success_response + +router = APIRouter() +publish_service = PublishService() + +class PublishRequest(BaseModel): + """Video publish request model""" + video_path: str + platform: str + title: str + tags: List[str] = [] + description: str = "" + publish_time: Optional[datetime] = None + +class PublishResponse(BaseModel): + """Video publish response model""" + success: bool + message: str + platform: str + url: Optional[str] = None + +# Supported platforms for validation +SUPPORTED_PLATFORMS = {"bilibili", "douyin", "xiaohongshu"} + + +def _get_user_id(request: Request) -> Optional[str]: + """从请求中获取用户 ID (兼容未登录场景)""" + try: + from app.core.security import decode_access_token + token = request.cookies.get("access_token") + if token: + token_data = decode_access_token(token) + if token_data: + return token_data.user_id + except Exception: + pass + return None + + +@router.post("") +async def publish_video(request: PublishRequest, req: Request, background_tasks: BackgroundTasks): + """发布视频到指定平台""" + # Validate platform + if request.platform not in SUPPORTED_PLATFORMS: + raise HTTPException( + status_code=400, + detail=f"不支持的平台: {request.platform}。支持的平台: {', '.join(SUPPORTED_PLATFORMS)}" + ) + + # 获取用户 ID (可选) + user_id = _get_user_id(req) + + try: + result = await publish_service.publish( + video_path=request.video_path, + platform=request.platform, + title=request.title, + tags=request.tags, + description=request.description, + publish_time=request.publish_time, + user_id=user_id + ) + message = result.get("message", "") + return success_response(result, message=message) + except Exception as e: + logger.error(f"发布失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/platforms") +async def list_platforms(): + return success_response({"platforms": [{**pinfo, "id": pid} for pid, pinfo in publish_service.PLATFORMS.items()]}) + +@router.get("/accounts") +async def list_accounts(req: Request): + user_id = _get_user_id(req) + return success_response({"accounts": publish_service.get_accounts(user_id)}) + +@router.post("/login/{platform}") +async def login_platform(platform: str, req: Request): + """触发平台QR码登录""" + if platform not in SUPPORTED_PLATFORMS: + raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}") + + user_id = _get_user_id(req) + result = await publish_service.login(platform, user_id) + + message = result.get("message", "") + return success_response(result, message=message) + +@router.post("/logout/{platform}") +async def logout_platform(platform: str, req: Request): + """注销平台登录""" + if platform not in SUPPORTED_PLATFORMS: + raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}") + + user_id = _get_user_id(req) + result = publish_service.logout(platform, user_id) + message = result.get("message", "") + return success_response(result, message=message) + +@router.get("/login/status/{platform}") +async def get_login_status(platform: str, req: Request): + """检查登录状态 (优先检查活跃的扫码会话)""" + if platform not in SUPPORTED_PLATFORMS: + raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}") + + user_id = _get_user_id(req) + result = publish_service.get_login_session_status(platform, user_id) + message = result.get("message", "") + return success_response(result, message=message) + +@router.post("/cookies/save/{platform}") +async def save_platform_cookie(platform: str, cookie_data: dict, req: Request): + """ + 保存从客户端浏览器提取的Cookie + + Args: + platform: 平台ID + cookie_data: {"cookie_string": "document.cookie的内容"} + """ + if platform not in SUPPORTED_PLATFORMS: + raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}") + + cookie_string = cookie_data.get("cookie_string", "") + if not cookie_string: + raise HTTPException(status_code=400, detail="cookie_string 不能为空") + + user_id = _get_user_id(req) + result = await publish_service.save_cookie_string(platform, cookie_string, user_id) + + message = result.get("message", "") + return success_response(result, message=message) diff --git a/backend/app/modules/ref_audios/__init__.py b/backend/app/modules/ref_audios/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/ref_audios/router.py b/backend/app/modules/ref_audios/router.py new file mode 100644 index 0000000..790c597 --- /dev/null +++ b/backend/app/modules/ref_audios/router.py @@ -0,0 +1,416 @@ +""" +参考音频管理 API +支持上传/列表/删除参考音频,用于 Qwen3-TTS 声音克隆 +""" +from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends +from pydantic import BaseModel +from typing import List, Optional +from pathlib import Path +from loguru import logger +import time +import json +import subprocess +import tempfile +import os +import re + +from app.core.deps import get_current_user +from app.services.storage import storage_service +from app.core.response import success_response + +router = APIRouter() + +# 支持的音频格式 +ALLOWED_AUDIO_EXTENSIONS = {'.wav', '.mp3', '.m4a', '.webm', '.ogg', '.flac', '.aac'} + +# 参考音频 bucket +BUCKET_REF_AUDIOS = "ref-audios" + + +class RefAudioResponse(BaseModel): + id: str + name: str + path: str # signed URL for playback + ref_text: str + duration_sec: float + created_at: int + + +class RefAudioListResponse(BaseModel): + items: List[RefAudioResponse] + + +def sanitize_filename(filename: str) -> str: + """清理文件名,移除特殊字符""" + safe_name = re.sub(r'[<>:"/\\|?*\s]', '_', filename) + if len(safe_name) > 50: + ext = Path(safe_name).suffix + safe_name = safe_name[:50 - len(ext)] + ext + return safe_name + + +def get_audio_duration(file_path: str) -> float: + """获取音频时长 (秒)""" + try: + result = subprocess.run( + ['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration', + '-of', 'csv=p=0', file_path], + capture_output=True, text=True, timeout=10 + ) + return float(result.stdout.strip()) + except Exception as e: + logger.warning(f"获取音频时长失败: {e}") + return 0.0 + + +def convert_to_wav(input_path: str, output_path: str) -> bool: + """将音频转换为 WAV 格式 (16kHz, mono)""" + try: + subprocess.run([ + 'ffmpeg', '-y', '-i', input_path, + '-ar', '16000', # 16kHz 采样率 + '-ac', '1', # 单声道 + '-acodec', 'pcm_s16le', # 16-bit PCM + output_path + ], capture_output=True, timeout=60, check=True) + return True + except Exception as e: + logger.error(f"音频转换失败: {e}") + return False + + +@router.post("") +async def upload_ref_audio( + file: UploadFile = File(...), + ref_text: str = Form(...), + user: dict = Depends(get_current_user) +): + """ + 上传参考音频 + + - file: 音频文件 (支持 wav, mp3, m4a, webm 等) + - ref_text: 参考音频的转写文字 (必填) + """ + user_id = user["id"] + + if not file.filename: + raise HTTPException(status_code=400, detail="文件名无效") + filename = file.filename + + # 验证文件扩展名 + ext = Path(filename).suffix.lower() + if ext not in ALLOWED_AUDIO_EXTENSIONS: + raise HTTPException( + status_code=400, + detail=f"不支持的音频格式: {ext}。支持的格式: {', '.join(ALLOWED_AUDIO_EXTENSIONS)}" + ) + + # 验证 ref_text + if not ref_text or len(ref_text.strip()) < 2: + raise HTTPException(status_code=400, detail="参考文字不能为空") + + try: + # 创建临时文件 + with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp_input: + content = await file.read() + tmp_input.write(content) + tmp_input_path = tmp_input.name + + # 转换为 WAV 格式 + tmp_wav_path = tmp_input_path + ".wav" + if ext != '.wav': + if not convert_to_wav(tmp_input_path, tmp_wav_path): + raise HTTPException(status_code=500, detail="音频格式转换失败") + else: + # 即使是 wav 也要标准化格式 + convert_to_wav(tmp_input_path, tmp_wav_path) + + # 获取音频时长 + duration = get_audio_duration(tmp_wav_path) + if duration < 1.0: + raise HTTPException(status_code=400, detail="音频时长过短,至少需要 1 秒") + if duration > 60.0: + raise HTTPException(status_code=400, detail="音频时长过长,最多 60 秒") + + + # 3. 处理重名逻辑 (Friendly Display Name) + original_name = filename + + # 获取用户现有的所有参考音频列表 (为了检查文件名冲突) + # 注意: 这种列表方式在文件极多时性能一般,但考虑到单用户参考音频数量有限,目前可行 + existing_files = await storage_service.list_files(BUCKET_REF_AUDIOS, user_id) + existing_names = set() + + # 预加载所有现有的 display name + # 这里需要并发请求 metadata 可能会慢,优化: 仅检查 metadata 文件并解析 + # 简易方案: 仅在 metadata 中读取 original_filename + # 但 list_files 返回的是 name,我们需要 metadata + # 考虑到性能,这里使用一种妥协方案: + # 我们不做全量检查,而是简单的检查:如果用户上传 myvoice.wav + # 我们看看有没有 (timestamp)_myvoice.wav 这种其实并不能准确判断 display name 是否冲突 + # + # 正确做法: 应该有个数据库表存 metadata。但目前是无数据库设计。 + # + # 改用简单方案: + # 既然我们无法快速获取所有 display name, + # 我们暂时只处理 "在新上传时,original_filename 保持原样" + # 但用户希望 "如果在列表中看到重复的,自动加(1)" + # + # 鉴于无数据库架构的限制,要在上传时知道"已有的 display name" 成本太高(需遍历下载所有json)。 + # + # 💡 替代方案: + # 我们不检查旧的。我们只保证**存储**唯一。 + # 对于用户提到的 "新上传的文件名后加个数字" -> 这通常是指 "另存为" 的逻辑。 + # 既然用户现在的痛点是 "显示了时间戳太丑",而我已经去掉了时间戳显示。 + # 那么如果用户上传两个 "TEST.wav",列表里就会有两个 "TEST.wav" (但时间不同)。 + # 这其实是可以接受的。 + # + # 但如果用户强求 "自动重命名": + # 我们可以在这里做一个轻量级的 "同名检测": + # 检查有没有 *_{original_name} 的文件存在。 + # 如果 storage 里已经有 123_abc.wav, 456_abc.wav + # 我们可以认为 abc.wav 已经存在。 + + dup_count = 0 + search_suffix = f"_{original_name}" # 比如 _test.wav + + for f in existing_files: + fname = f.get('name', '') + if fname.endswith(search_suffix): + dup_count += 1 + + final_display_name = original_name + if dup_count > 0: + name_stem = Path(original_name).stem + name_ext = Path(original_name).suffix + final_display_name = f"{name_stem}({dup_count}){name_ext}" + + # 生成存储路径 (唯一ID) + timestamp = int(time.time()) + safe_name = sanitize_filename(Path(filename).stem) + storage_path = f"{user_id}/{timestamp}_{safe_name}.wav" + + # 上传 WAV 文件到 Supabase + with open(tmp_wav_path, 'rb') as f: + wav_data = f.read() + + await storage_service.upload_file( + bucket=BUCKET_REF_AUDIOS, + path=storage_path, + file_data=wav_data, + content_type="audio/wav" + ) + + # 上传元数据 JSON + metadata = { + "ref_text": ref_text.strip(), + "original_filename": final_display_name, # 这里的名字如果有重复会自动加(1) + "duration_sec": duration, + "created_at": timestamp + } + metadata_path = f"{user_id}/{timestamp}_{safe_name}.json" + await storage_service.upload_file( + bucket=BUCKET_REF_AUDIOS, + path=metadata_path, + file_data=json.dumps(metadata, ensure_ascii=False).encode('utf-8'), + content_type="application/json" + ) + + # 获取签名 URL + signed_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, storage_path) + + # 清理临时文件 + os.unlink(tmp_input_path) + if os.path.exists(tmp_wav_path): + os.unlink(tmp_wav_path) + + return success_response(RefAudioResponse( + id=storage_path, + name=filename, + path=signed_url, + ref_text=ref_text.strip(), + duration_sec=duration, + created_at=timestamp + ).model_dump()) + + except HTTPException: + raise + except Exception as e: + logger.error(f"上传参考音频失败: {e}") + raise HTTPException(status_code=500, detail=f"上传失败: {str(e)}") + + +@router.get("") +async def list_ref_audios(user: dict = Depends(get_current_user)): + """列出当前用户的所有参考音频""" + user_id = user["id"] + + try: + # 列出用户目录下的文件 + files = await storage_service.list_files(BUCKET_REF_AUDIOS, user_id) + + # 过滤出 .wav 文件并获取对应的 metadata + items = [] + for f in files: + name = f.get("name", "") + if not name.endswith(".wav"): + continue + + storage_path = f"{user_id}/{name}" + + # 尝试读取 metadata + metadata_name = name.replace(".wav", ".json") + metadata_path = f"{user_id}/{metadata_name}" + + ref_text = "" + duration_sec = 0.0 + created_at = 0 + original_filename = "" + + try: + # 获取 metadata 内容 + metadata_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, metadata_path) + import httpx + async with httpx.AsyncClient() as client: + resp = await client.get(metadata_url) + if resp.status_code == 200: + metadata = resp.json() + ref_text = metadata.get("ref_text", "") + duration_sec = metadata.get("duration_sec", 0.0) + created_at = metadata.get("created_at", 0) + original_filename = metadata.get("original_filename", "") + except Exception as e: + logger.warning(f"读取 metadata 失败: {e}") + # 从文件名提取时间戳 + try: + created_at = int(name.split("_")[0]) + except: + pass + + # 获取音频签名 URL + signed_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, storage_path) + + # 优先显示原始文件名 (去掉时间戳前缀) + display_name = original_filename if original_filename else name + # 如果原始文件名丢失,尝试从现有文件名中通过正则去掉时间戳 + if not display_name or display_name == name: + # 匹配 "1234567890_filename.wav" + match = re.match(r'^\d+_(.+)$', name) + if match: + display_name = match.group(1) + + items.append(RefAudioResponse( + id=storage_path, + name=display_name, + path=signed_url, + ref_text=ref_text, + duration_sec=duration_sec, + created_at=created_at + )) + + # 按创建时间倒序排列 + items.sort(key=lambda x: x.created_at, reverse=True) + + return success_response(RefAudioListResponse(items=items).model_dump()) + + except Exception as e: + logger.error(f"列出参考音频失败: {e}") + raise HTTPException(status_code=500, detail=f"获取列表失败: {str(e)}") + + +@router.delete("/{audio_id:path}") +async def delete_ref_audio(audio_id: str, user: dict = Depends(get_current_user)): + """删除参考音频""" + user_id = user["id"] + + # 安全检查:确保只能删除自己的文件 + if not audio_id.startswith(f"{user_id}/"): + raise HTTPException(status_code=403, detail="无权删除此文件") + + try: + # 删除 WAV 文件 + await storage_service.delete_file(BUCKET_REF_AUDIOS, audio_id) + + # 删除 metadata JSON + metadata_path = audio_id.replace(".wav", ".json") + try: + await storage_service.delete_file(BUCKET_REF_AUDIOS, metadata_path) + except: + pass # metadata 可能不存在 + + return success_response(message="删除成功") + + except Exception as e: + logger.error(f"删除参考音频失败: {e}") + raise HTTPException(status_code=500, detail=f"删除失败: {str(e)}") + + +class RenameRequest(BaseModel): + new_name: str + + +@router.put("/{audio_id:path}") +async def rename_ref_audio( + audio_id: str, + request: RenameRequest, + user: dict = Depends(get_current_user) +): + """重命名参考音频 (修改 metadata 中的 display name)""" + user_id = user["id"] + + # 安全检查 + if not audio_id.startswith(f"{user_id}/"): + raise HTTPException(status_code=403, detail="无权修改此文件") + + new_name = request.new_name.strip() + if not new_name: + raise HTTPException(status_code=400, detail="新名称不能为空") + + # 确保新名称有后缀 (保留原后缀或添加 .wav) + if not Path(new_name).suffix: + new_name += ".wav" + + try: + # 1. 下载现有的 metadata + metadata_path = audio_id.replace(".wav", ".json") + try: + # 获取已有的 JSON + import httpx + metadata_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, metadata_path) + if not metadata_url: + # 如果 json 不存在,则需要新建一个基础的 + raise Exception("Metadata not found") + + async with httpx.AsyncClient() as client: + resp = await client.get(metadata_url) + if resp.status_code == 200: + metadata = resp.json() + else: + raise Exception(f"Failed to fetch metadata: {resp.status_code}") + + except Exception as e: + logger.warning(f"无法读取元数据: {e}, 将创建新的元数据") + # 兜底:如果读取失败,构建最小元数据 + metadata = { + "ref_text": "", # 可能丢失 + "duration_sec": 0.0, + "created_at": int(time.time()), + "original_filename": new_name + } + + # 2. 更新 original_filename + metadata["original_filename"] = new_name + + # 3. 覆盖上传 metadata + await storage_service.upload_file( + bucket=BUCKET_REF_AUDIOS, + path=metadata_path, + file_data=json.dumps(metadata, ensure_ascii=False).encode('utf-8'), + content_type="application/json" + ) + + return success_response({"name": new_name}, message="重命名成功") + + except Exception as e: + logger.error(f"重命名失败: {e}") + raise HTTPException(status_code=500, detail=f"重命名失败: {str(e)}") diff --git a/backend/app/modules/tools/__init__.py b/backend/app/modules/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/tools/router.py b/backend/app/modules/tools/router.py new file mode 100644 index 0000000..5b6d478 --- /dev/null +++ b/backend/app/modules/tools/router.py @@ -0,0 +1,407 @@ +from fastapi import APIRouter, UploadFile, File, Form, HTTPException +from typing import Optional, Any, cast +import asyncio +import shutil +import os +import time +from pathlib import Path +from loguru import logger +import traceback +import re +import json +import requests +from urllib.parse import unquote + +from app.services.whisper_service import whisper_service +from app.services.glm_service import glm_service +from app.core.response import success_response + +router = APIRouter() + +@router.post("/extract-script") +async def extract_script_tool( + file: Optional[UploadFile] = File(None), + url: Optional[str] = Form(None), + rewrite: bool = Form(True) +): + """ + 独立文案提取工具 + 支持上传视频/音频 OR 输入视频链接 -> 提取文字 -> (可选) AI洗稿 + """ + if not file and not url: + raise HTTPException(400, "必须提供文件或视频链接") + + temp_path = None + try: + timestamp = int(time.time()) + temp_dir = Path("/tmp") + if os.name == 'nt': + temp_dir = Path("d:/tmp") + temp_dir.mkdir(parents=True, exist_ok=True) + + # 1. 获取/保存文件 + loop = asyncio.get_event_loop() + + if file: + filename = file.filename + if not filename: + raise HTTPException(400, "文件名无效") + safe_filename = Path(filename).name.replace(" ", "_") + temp_path = temp_dir / f"tool_extract_{timestamp}_{safe_filename}" + # 文件 I/O 放入线程池 + await loop.run_in_executor(None, lambda: shutil.copyfileobj(file.file, open(temp_path, "wb"))) + logger.info(f"Tool processing upload file: {temp_path}") + else: + if not url: + raise HTTPException(400, "必须提供视频链接") + url_value: str = url + # URL 下载逻辑 + # 自动提取文案中的链接 (支持 Douyin/Bilibili 等分享文案) + url_match = re.search(r'https?://[^\s]+', url_value) + if url_match: + extracted_url = url_match.group(0) + logger.info(f"Extracted URL from text: {extracted_url}") + url_value = extracted_url + + logger.info(f"Tool downloading URL: {url_value}") + + # 封装 yt-dlp 下载函数 (Blocking) + def _download_yt_dlp(): + import yt_dlp + logger.info("Attempting download with yt-dlp...") + + ydl_opts = { + 'format': 'bestaudio/best', + 'outtmpl': str(temp_dir / f"tool_download_{timestamp}_%(id)s.%(ext)s"), + 'quiet': True, + 'no_warnings': True, + 'http_headers': { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Referer': 'https://www.douyin.com/', + } + } + + with yt_dlp.YoutubeDL() as ydl_raw: + ydl: Any = ydl_raw + ydl.params.update(ydl_opts) + info = ydl.extract_info(url_value, download=True) + if 'requested_downloads' in info: + downloaded_file = info['requested_downloads'][0]['filepath'] + else: + ext = info.get('ext', 'mp4') + id = info.get('id') + downloaded_file = str(temp_dir / f"tool_download_{timestamp}_{id}.{ext}") + + return Path(downloaded_file) + + # 先尝试 yt-dlp (Run in Executor) + try: + temp_path = await loop.run_in_executor(None, _download_yt_dlp) + logger.info(f"yt-dlp downloaded to: {temp_path}") + + except Exception as e: + logger.warning(f"yt-dlp download failed: {e}. Trying manual Douyin fallback...") + + # 失败则尝试手动解析 (Douyin Fallback) + if "douyin" in url_value: + manual_path = await download_douyin_manual(url_value, temp_dir, timestamp) + if manual_path: + temp_path = manual_path + logger.info(f"Manual Douyin fallback successful: {temp_path}") + else: + raise HTTPException(400, f"视频下载失败。yt-dlp 报错: {str(e)}") + elif "bilibili" in url_value: + manual_path = await download_bilibili_manual(url_value, temp_dir, timestamp) + if manual_path: + temp_path = manual_path + logger.info(f"Manual Bilibili fallback successful: {temp_path}") + else: + raise HTTPException(400, f"视频下载失败。yt-dlp 报错: {str(e)}") + else: + raise HTTPException(400, f"视频下载失败: {str(e)}") + + if not temp_path or not temp_path.exists(): + raise HTTPException(400, "文件获取失败") + + # 1.5 安全转换: 强制转为 WAV (16k) + import subprocess + audio_path = temp_dir / f"extract_audio_{timestamp}.wav" + + def _convert_audio(): + try: + convert_cmd = [ + 'ffmpeg', + '-i', str(temp_path), + '-vn', # 忽略视频 + '-acodec', 'pcm_s16le', + '-ar', '16000', # Whisper 推荐采样率 + '-ac', '1', # 单声道 + '-y', # 覆盖 + str(audio_path) + ] + # 捕获 stderr + subprocess.run(convert_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return True + except subprocess.CalledProcessError as e: + error_log = e.stderr.decode('utf-8', errors='ignore') if e.stderr else str(e) + logger.error(f"FFmpeg check/convert failed: {error_log}") + # 检查是否为 HTML + head = b"" + try: + with open(temp_path, 'rb') as f: + head = f.read(100) + except: pass + if b' 0: + logger.info("Rewriting script...") + rewritten = await glm_service.rewrite_script(script) + else: + logger.warning("No script extracted, skipping rewrite") + + return success_response({ + "original_script": script, + "rewritten_script": rewritten + }) + + except HTTPException as he: + raise he + except Exception as e: + logger.error(f"Tool extract failed: {e}") + logger.error(traceback.format_exc()) + + # Friendly error message + msg = str(e) + if "Fresh cookies" in msg: + msg = "下载失败:目标平台开启了反爬验证,请过段时间重试或直接上传视频文件。" + + raise HTTPException(500, f"提取失败: {msg}") + finally: + # 清理临时文件 + if temp_path and temp_path.exists(): + try: + os.remove(temp_path) + logger.info(f"Cleaned up temp file: {temp_path}") + except Exception as e: + logger.warning(f"Failed to cleanup temp file {temp_path}: {e}") + + +async def download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Optional[Path]: + """ + 手动下载抖音视频 (Fallback logic - Ported from SuperIPAgent/douyinDownloader) + 使用特定的 User Profile URL 和硬编码 Cookie 绕过反爬 + """ + logger.info(f"[SuperIPAgent] Starting download for: {url}") + + try: + # 1. 提取 Modal ID (支持短链跳转) + headers = { + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + } + + # 如果是短链或重定向 + resp = requests.get(url, headers=headers, allow_redirects=True, timeout=10) + final_url = resp.url + logger.info(f"[SuperIPAgent] Final URL: {final_url}") + + modal_id = None + match = re.search(r'/video/(\d+)', final_url) + if match: + modal_id = match.group(1) + + if not modal_id: + logger.error("[SuperIPAgent] Could not extract modal_id") + return None + + logger.info(f"[SuperIPAgent] Extracted modal_id: {modal_id}") + + # 2. 构造特定请求 URL (Copy from SuperIPAgent) + # 使用特定用户的 Profile 页 + modal_id 参数,配合特定 Cookie + target_url = f"https://www.douyin.com/user/MS4wLjABAAAAN_s_hups7LD0N4qnrM3o2gI0vuG3pozNaEolz2_py3cHTTrpVr1Z4dukFD9SOlwY?from_tab_name=main&modal_id={modal_id}" + + # 3. 使用硬编码 Cookie (Copy from SuperIPAgent) + headers_with_cookie = { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "cookie": "douyin.com; device_web_cpu_core=10; device_web_memory_size=8; __ac_nonce=06760391f00b9b51264ae; __ac_signature=_02B4Z6wo00f019a5ceAAAIDAhEZR-X3jjWfWmXVAAJLXd4; ttwid=1%7C7MTKBSMsP4eOv9h5NAh8p0E-NYIud09ftNmB0mjLpWc%7C1734359327%7C8794abeabbd47447e1f56e5abc726be089f2a0344d6343b5f75f23e7b0f0028f; UIFID_TEMP=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff1396912bcb2af71efee56a14a2a9f37b74010d0a0413795262f6d4afe02a032ac7ab; s_v_web_id=verify_m4r4ribr_c7krmY1z_WoeI_43po_ATpO_I4o8U1bex2D7; hevc_supported=true; home_can_add_dy_2_desktop=%220%22; dy_swidth=2560; dy_sheight=1440; stream_recommend_feed_params=%22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A2560%2C%5C%22screen_height%5C%22%3A1440%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A10%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A10%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A50%7D%22; strategyABtestKey=%221734359328.577%22; csrf_session_id=2f53aed9aa6974e83aa9a1014180c3a4; fpk1=U2FsdGVkX1/IpBh0qdmlKAVhGyYHgur4/VtL9AReZoeSxadXn4juKvsakahRGqjxOPytHWspYoBogyhS/V6QSw==; fpk2=0845b309c7b9b957afd9ecf775a4c21f; passport_csrf_token=d80e0c5b2fa2328219856be5ba7e671e; passport_csrf_token_default=d80e0c5b2fa2328219856be5ba7e671e; odin_tt=3c891091d2eb0f4718c1d5645bc4a0017032d4d5aa989decb729e9da2ad570918cbe5e9133dc6b145fa8c758de98efe32ff1f81aa0d611e838cc73ab08ef7d3f6adf66ab4d10e8372ddd628f94f16b8e; volume_info=%7B%22isUserMute%22%3Afalse%2C%22isMute%22%3Afalse%2C%22volume%22%3A0.5%7D; bd_ticket_guard_client_web_domain=2; FORCE_LOGIN=%7B%22videoConsumedRemainSeconds%22%3A180%7D; UIFID=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff139655a3c2b735923234f371c699560c657923fd3d6c5b63ab7bb9b83423b6cb4787e2ce66a7fbc4ecb24c8570f520fe6de068bbb95115023c0c6c1b6ee31b49fb7e3996fb8349f43a3fd8b7a61cd9e18e8fe65eb6a7c13de4c0960d84e344b644725db3eb2fa6b7caf821de1b50527979f2; is_dash_user=1; biz_trace_id=b57a241f; bd_ticket_guard_client_data=eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCTEo2R0lDalVoWW1XcHpGOFdrN0Vrc0dXcCtaUzNKY1g4NGNGY2k0TTl1TEowNjdUb21mbFU5aDdvWVBGamhNRWNRQWtKdnN1MnM3RmpTWnlJQXpHMjA9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D; download_guide=%221%2F20241216%2F0%22; sdk_source_info=7e276470716a68645a606960273f276364697660272927676c715a6d6069756077273f276364697660272927666d776a68605a607d71606b766c6a6b5a7666776c7571273f275e58272927666a6b766a69605a696c6061273f27636469766027292762696a6764695a7364776c6467696076273f275e5827292771273f273d33323131333c3036313632342778; bit_env=RiOY4jzzpxZoVCl6zdVSVhVRjdwHRTxqcqWdqMBZLPGjMdB4Tax1kAELHNTVAAh72KuhumewE4Lq6f0-VJ2UpJrkrhSxoPw9LUb3zQrq1OSwbeSPHkRlRgRQvO89sItdGUyq1oFr0XyRCnMYG87KSeWyc4x0czGR0o50hTDoDLG5rJVoRcdQOLvjiAegsqyytKF59sPX_QM9qffK2SqYsg0hCggURc_AI6kguDDE5DvG0bnyz1utw4z1eEnIoLrkGDqzqBZj4dOAr0BVU6ofbsS-pOQ2u2PM1dLP9FlBVBlVaqYVgHJeSLsR5k76BRTddUjTb4zEilVIEwAMJWGN4I1BxVt6fC9B5tBQpuT0lj3n3eKXCKXZsd8FrEs5_pbfDsxV-e_WMiXI2ff4qxiTC0U73sfo9OpicKICtZjdq8qsHxJuu6wVR36zvXeL2Wch5C6MzprNvkivv0l8nbh2mSgy1nabZr3dmU6NcR-Bg3Q3xTWUlR9aAUmpopC-cNuXjgLpT-Lw1AYGilSUnCvosth1Gfypq-b0MpgmdSDgTrQ%3D; gulu_source_res=eyJwX2luIjoiMDhjOGQ3ZTJiODQyNjZkZWI5Y2VkMGJiODNlNmY1ZWY0ZjMyNTE2ZmYyZjAzNDMzZjI0OWU1Y2Q1NTczNTk5NyJ9; passport_auth_mix_state=hp9bc3dgb1tm5wd8p82zawus27g0e3ue; IsDouyinActive=false", + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + } + + logger.info(f"[SuperIPAgent] Requesting page with Cookie...") + # 必须 verify=False 否则有些环境会报错 + response = requests.get(target_url, headers=headers_with_cookie, timeout=10) + + # 4. 解析 RENDER_DATA + content_match = re.findall(r'', response.text) + if not content_match: + # 尝试解码后再查找?或者结构变了 + # 再尝试找 SSR_HYDRATED_DATA + if "SSR_HYDRATED_DATA" in response.text: + content_match = re.findall(r'', response.text) + + if not content_match: + logger.error(f"[SuperIPAgent] Could not find RENDER_DATA in page (len={len(response.text)})") + return None + + content = unquote(content_match[0]) + try: + data = json.loads(content) + except: + logger.error("[SuperIPAgent] JSON decode failed") + return None + + # 5. 提取视频流 + video_url = None + try: + # 路径通常是: app -> videoDetail -> video -> bitRateList -> playAddr -> src + if "app" in data and "videoDetail" in data["app"]: + info = data["app"]["videoDetail"]["video"] + if "bitRateList" in info and info["bitRateList"]: + video_url = info["bitRateList"][0]["playAddr"][0]["src"] + elif "playAddr" in info and info["playAddr"]: + video_url = info["playAddr"][0]["src"] + except Exception as e: + logger.error(f"[SuperIPAgent] Path extraction failed: {e}") + + if not video_url: + logger.error("[SuperIPAgent] No video_url found") + return None + + if video_url.startswith("//"): + video_url = "https:" + video_url + + logger.info(f"[SuperIPAgent] Found video URL: {video_url[:50]}...") + + # 6. 下载 (带 Header) + temp_path = temp_dir / f"douyin_manual_{timestamp}.mp4" + download_headers = { + 'Referer': 'https://www.douyin.com/', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + } + + dl_resp = requests.get(video_url, headers=download_headers, stream=True, timeout=60) + if dl_resp.status_code == 200: + with open(temp_path, 'wb') as f: + for chunk in dl_resp.iter_content(chunk_size=1024): + f.write(chunk) + + logger.info(f"[SuperIPAgent] Downloaded successfully: {temp_path}") + return temp_path + else: + logger.error(f"[SuperIPAgent] Download failed: {dl_resp.status_code}") + return None + + except Exception as e: + logger.error(f"[SuperIPAgent] Logic failed: {e}") + return None + +async def download_bilibili_manual(url: str, temp_dir: Path, timestamp: int) -> Optional[Path]: + """ + 手动下载 Bilibili 视频 (Fallback logic - Playwright Version) + B站通常音视频分离,这里只提取音频即可(因为只需要文案) + """ + from playwright.async_api import async_playwright + + logger.info(f"[Playwright] Starting Bilibili download for: {url}") + + playwright = None + browser = None + try: + playwright = await async_playwright().start() + # Launch browser (ensure chromium is installed: playwright install chromium) + browser = await playwright.chromium.launch(headless=True, args=['--no-sandbox', '--disable-setuid-sandbox']) + + # Mobile User Agent often gives single stream? + # But Bilibili mobile web is tricky. Desktop is fine. + context = await browser.new_context( + user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + ) + + page = await context.new_page() + + # Intercept audio responses? + # Bilibili streams are usually .m4s + # But finding the initial state is easier. + + logger.info("[Playwright] Navigating to Bilibili...") + await page.goto(url, timeout=45000) + + # Wait for video element (triggers loading) + try: + await page.wait_for_selector('video', timeout=15000) + except: + logger.warning("[Playwright] Video selector timeout") + + # 1. Try extracting from __playinfo__ + # window.__playinfo__ contains dash streams + playinfo = await page.evaluate("window.__playinfo__") + + audio_url = None + + if playinfo and "data" in playinfo and "dash" in playinfo["data"]: + dash = playinfo["data"]["dash"] + if "audio" in dash and dash["audio"]: + audio_url = dash["audio"][0]["baseUrl"] + logger.info(f"[Playwright] Found audio stream in __playinfo__: {audio_url[:50]}...") + + # 2. If playinfo fails, try extracting video src (sometimes it's a blob, which we can't fetch easily without interception) + # But interception is complex. Let's try requests with Referer if we have URL. + + if not audio_url: + logger.warning("[Playwright] Could not find audio in __playinfo__") + return None + + # Download the audio stream + temp_path = temp_dir / f"bilibili_audio_{timestamp}.m4s" # usually m4s + + try: + api_request = context.request + headers = { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Referer": "https://www.bilibili.com/" + } + + logger.info(f"[Playwright] Downloading audio stream...") + response = await api_request.get(audio_url, headers=headers) + + if response.status == 200: + body = await response.body() + with open(temp_path, 'wb') as f: + f.write(body) + + logger.info(f"[Playwright] Downloaded successfully: {temp_path}") + return temp_path + else: + logger.error(f"[Playwright] API Request failed: {response.status}") + return None + + except Exception as e: + logger.error(f"[Playwright] Download logic error: {e}") + return None + + except Exception as e: + logger.error(f"[Playwright] Bilibili download failed: {e}") + return None + finally: + if browser: + await browser.close() + if playwright: + await playwright.stop() diff --git a/backend/app/modules/videos/__init__.py b/backend/app/modules/videos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/videos/router.py b/backend/app/modules/videos/router.py new file mode 100644 index 0000000..3901b75 --- /dev/null +++ b/backend/app/modules/videos/router.py @@ -0,0 +1,57 @@ +from fastapi import APIRouter, BackgroundTasks, Depends +import uuid + +from app.core.deps import get_current_user +from app.core.response import success_response + +from .schemas import GenerateRequest +from .task_store import create_task, get_task, list_tasks +from .workflow import process_video_generation, get_lipsync_health, get_voiceclone_health +from .service import list_generated_videos, delete_generated_video + + +router = APIRouter() + + +@router.post("/generate") +async def generate_video( + req: GenerateRequest, + background_tasks: BackgroundTasks, + current_user: dict = Depends(get_current_user) +): + user_id = current_user["id"] + task_id = str(uuid.uuid4()) + create_task(task_id, user_id) + background_tasks.add_task(process_video_generation, task_id, req, user_id) + return success_response({"task_id": task_id}) + + +@router.get("/tasks/{task_id}") +async def get_task_status(task_id: str): + return success_response(get_task(task_id)) + + +@router.get("/tasks") +async def list_tasks_view(): + return success_response({"tasks": list_tasks()}) + + +@router.get("/lipsync/health") +async def lipsync_health(): + return success_response(await get_lipsync_health()) + + +@router.get("/voiceclone/health") +async def voiceclone_health(): + return success_response(await get_voiceclone_health()) + + +@router.get("/generated") +async def list_generated(current_user: dict = Depends(get_current_user)): + return success_response(await list_generated_videos(current_user["id"])) + + +@router.delete("/generated/{video_id}") +async def delete_generated(video_id: str, current_user: dict = Depends(get_current_user)): + result = await delete_generated_video(current_user["id"], video_id) + return success_response(result, message="视频已删除") diff --git a/backend/app/modules/videos/schemas.py b/backend/app/modules/videos/schemas.py new file mode 100644 index 0000000..5ddd76d --- /dev/null +++ b/backend/app/modules/videos/schemas.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel +from typing import Optional + + +class GenerateRequest(BaseModel): + text: str + voice: str = "zh-CN-YunxiNeural" + material_path: str + tts_mode: str = "edgetts" + ref_audio_id: Optional[str] = None + ref_text: Optional[str] = None + title: Optional[str] = None + enable_subtitles: bool = True + subtitle_style_id: Optional[str] = None + title_style_id: Optional[str] = None + subtitle_font_size: Optional[int] = None + title_font_size: Optional[int] = None + bgm_id: Optional[str] = None + bgm_volume: Optional[float] = 0.2 diff --git a/backend/app/modules/videos/service.py b/backend/app/modules/videos/service.py new file mode 100644 index 0000000..31de99b --- /dev/null +++ b/backend/app/modules/videos/service.py @@ -0,0 +1,87 @@ +from fastapi import HTTPException +import asyncio +from pathlib import Path +from loguru import logger + +from app.services.storage import storage_service + + +async def list_generated_videos(user_id: str) -> dict: + """从 Storage 读取当前用户生成的视频列表""" + try: + files_obj = await storage_service.list_files( + bucket=storage_service.BUCKET_OUTPUTS, + path=user_id + ) + + semaphore = asyncio.Semaphore(8) + + async def build_item(f): + name = f.get("name") + if not name or name == ".emptyFolderPlaceholder": + return None + + if not name.endswith("_output.mp4"): + return None + + video_id = Path(name).stem + full_path = f"{user_id}/{name}" + + async with semaphore: + signed_url = await storage_service.get_signed_url( + bucket=storage_service.BUCKET_OUTPUTS, + path=full_path + ) + + metadata = f.get("metadata", {}) + size = metadata.get("size", 0) + created_at_str = f.get("created_at", "") + created_at = 0 + if created_at_str: + from datetime import datetime + try: + dt = datetime.fromisoformat(created_at_str.replace("Z", "+00:00")) + created_at = int(dt.timestamp()) + except Exception: + pass + + return { + "id": video_id, + "name": name, + "path": signed_url, + "size_mb": size / (1024 * 1024), + "created_at": created_at + } + + tasks = [build_item(f) for f in files_obj] + results = await asyncio.gather(*tasks, return_exceptions=True) + + videos = [] + for item in results: + if not item: + continue + if isinstance(item, Exception): + logger.warning(f"Signed url build failed: {item}") + continue + videos.append(item) + + videos.sort(key=lambda x: x.get("created_at", ""), reverse=True) + return {"videos": videos} + + except Exception as e: + logger.error(f"List generated videos failed: {e}") + return {"videos": []} + + +async def delete_generated_video(user_id: str, video_id: str) -> dict: + """删除生成的视频""" + try: + storage_path = f"{user_id}/{video_id}.mp4" + + await storage_service.delete_file( + bucket=storage_service.BUCKET_OUTPUTS, + path=storage_path + ) + return {"video_id": video_id} + except Exception as e: + raise HTTPException(500, f"删除失败: {str(e)}") diff --git a/backend/app/modules/videos/task_store.py b/backend/app/modules/videos/task_store.py new file mode 100644 index 0000000..4bfde97 --- /dev/null +++ b/backend/app/modules/videos/task_store.py @@ -0,0 +1,118 @@ +from typing import Any, Dict, List +import json + +from loguru import logger +from app.core.config import settings + +try: + import redis +except Exception: # pragma: no cover - optional dependency + redis = None + + +class InMemoryTaskStore: + def __init__(self) -> None: + self._tasks: Dict[str, Dict[str, Any]] = {} + + def create(self, task_id: str, user_id: str) -> Dict[str, Any]: + task = { + "status": "pending", + "task_id": task_id, + "progress": 0, + "user_id": user_id, + } + self._tasks[task_id] = task + return task + + def get(self, task_id: str) -> Dict[str, Any]: + return self._tasks.get(task_id, {"status": "not_found"}) + + def list(self) -> List[Dict[str, Any]]: + return list(self._tasks.values()) + + def update(self, task_id: str, updates: Dict[str, Any]) -> Dict[str, Any]: + task = self._tasks.get(task_id) + if not task: + task = {"status": "pending", "task_id": task_id} + self._tasks[task_id] = task + task.update(updates) + return task + + +class RedisTaskStore: + def __init__(self, client: "redis.Redis") -> None: + self._client = client + self._index_key = "vigent:tasks:index" + + def _key(self, task_id: str) -> str: + return f"vigent:tasks:{task_id}" + + def create(self, task_id: str, user_id: str) -> Dict[str, Any]: + task = { + "status": "pending", + "task_id": task_id, + "progress": 0, + "user_id": user_id, + } + self._client.set(self._key(task_id), json.dumps(task, ensure_ascii=False)) + self._client.sadd(self._index_key, task_id) + return task + + def get(self, task_id: str) -> Dict[str, Any]: + raw = self._client.get(self._key(task_id)) + if not raw: + return {"status": "not_found"} + return json.loads(raw) + + def list(self) -> List[Dict[str, Any]]: + task_ids = list(self._client.smembers(self._index_key) or []) + if not task_ids: + return [] + keys = [self._key(task_id) for task_id in task_ids] + raw_items = self._client.mget(keys) + tasks = [] + for raw in raw_items: + if raw: + try: + tasks.append(json.loads(raw)) + except Exception: + continue + return tasks + + def update(self, task_id: str, updates: Dict[str, Any]) -> Dict[str, Any]: + task = self.get(task_id) + if task.get("status") == "not_found": + task = {"status": "pending", "task_id": task_id} + task.update(updates) + self._client.set(self._key(task_id), json.dumps(task, ensure_ascii=False)) + self._client.sadd(self._index_key, task_id) + return task + + +def _build_task_store(): + if redis is None: + logger.warning("Redis not available, using in-memory task store") + return InMemoryTaskStore() + try: + client = redis.Redis.from_url(settings.REDIS_URL, decode_responses=True) + client.ping() + logger.info("Using Redis task store") + return RedisTaskStore(client) + except Exception as e: + logger.warning(f"Redis connection failed, using in-memory task store: {e}") + return InMemoryTaskStore() + + +task_store = _build_task_store() + + +def create_task(task_id: str, user_id: str) -> Dict[str, Any]: + return task_store.create(task_id, user_id) + + +def get_task(task_id: str) -> Dict[str, Any]: + return task_store.get(task_id) + + +def list_tasks() -> List[Dict[str, Any]]: + return task_store.list() diff --git a/backend/app/modules/videos/workflow.py b/backend/app/modules/videos/workflow.py new file mode 100644 index 0000000..8b67620 --- /dev/null +++ b/backend/app/modules/videos/workflow.py @@ -0,0 +1,328 @@ +from typing import Optional, Any +from pathlib import Path +import time +import traceback +import httpx +from loguru import logger + +from app.core.config import settings +from app.services.tts_service import TTSService +from app.services.video_service import VideoService +from app.services.lipsync_service import LipSyncService +from app.services.voice_clone_service import voice_clone_service +from app.services.assets_service import ( + get_style, + get_default_style, + resolve_bgm_path, + prepare_style_for_remotion, +) +from app.services.storage import storage_service +from app.services.whisper_service import whisper_service +from app.services.remotion_service import remotion_service + +from .schemas import GenerateRequest +from .task_store import task_store + + +_lipsync_service: Optional[LipSyncService] = None +_lipsync_ready: Optional[bool] = None +_lipsync_last_check: float = 0 + + +def _get_lipsync_service() -> LipSyncService: + """获取或创建 LipSync 服务实例(单例模式,避免重复初始化)""" + global _lipsync_service + if _lipsync_service is None: + _lipsync_service = LipSyncService() + return _lipsync_service + + +async def _check_lipsync_ready(force: bool = False) -> bool: + """检查 LipSync 是否就绪(带缓存,5分钟内不重复检查)""" + global _lipsync_ready, _lipsync_last_check + + now = time.time() + if not force and _lipsync_ready is not None and (now - _lipsync_last_check) < 300: + return bool(_lipsync_ready) + + lipsync = _get_lipsync_service() + health = await lipsync.check_health() + _lipsync_ready = health.get("ready", False) + _lipsync_last_check = now + print(f"[LipSync] Health check: ready={_lipsync_ready}") + return bool(_lipsync_ready) + + +async def _download_material(path_or_url: str, temp_path: Path): + """下载素材到临时文件 (流式下载,节省内存)""" + if path_or_url.startswith("http"): + timeout = httpx.Timeout(None) + async with httpx.AsyncClient(timeout=timeout) as client: + async with client.stream("GET", path_or_url) as resp: + resp.raise_for_status() + with open(temp_path, "wb") as f: + async for chunk in resp.aiter_bytes(): + f.write(chunk) + else: + src = Path(path_or_url) + if not src.is_absolute(): + src = settings.BASE_DIR.parent / path_or_url + + if src.exists(): + import shutil + shutil.copy(src, temp_path) + else: + raise FileNotFoundError(f"Material not found: {path_or_url}") + + +def _update_task(task_id: str, **updates: Any) -> None: + task_store.update(task_id, updates) + + +async def process_video_generation(task_id: str, req: GenerateRequest, user_id: str): + temp_files = [] + try: + start_time = time.time() + _update_task(task_id, status="processing", progress=5, message="正在下载素材...") + + temp_dir = settings.UPLOAD_DIR / "temp" + temp_dir.mkdir(parents=True, exist_ok=True) + + input_material_path = temp_dir / f"{task_id}_input.mp4" + temp_files.append(input_material_path) + + await _download_material(req.material_path, input_material_path) + + _update_task(task_id, message="正在生成语音...", progress=10) + + audio_path = temp_dir / f"{task_id}_audio.wav" + temp_files.append(audio_path) + + if req.tts_mode == "voiceclone": + if not req.ref_audio_id or not req.ref_text: + raise ValueError("声音克隆模式需要提供参考音频和参考文字") + + _update_task(task_id, message="正在下载参考音频...") + + ref_audio_local = temp_dir / f"{task_id}_ref.wav" + temp_files.append(ref_audio_local) + + ref_audio_url = await storage_service.get_signed_url( + bucket="ref-audios", + path=req.ref_audio_id + ) + await _download_material(ref_audio_url, ref_audio_local) + + _update_task(task_id, message="正在克隆声音 (Qwen3-TTS)...") + await voice_clone_service.generate_audio( + text=req.text, + ref_audio_path=str(ref_audio_local), + ref_text=req.ref_text, + output_path=str(audio_path), + language="Chinese" + ) + else: + _update_task(task_id, message="正在生成语音 (EdgeTTS)...") + tts = TTSService() + await tts.generate_audio(req.text, req.voice, str(audio_path)) + + tts_time = time.time() - start_time + print(f"[Pipeline] TTS completed in {tts_time:.1f}s") + _update_task(task_id, progress=25) + + _update_task(task_id, message="正在合成唇形 (LatentSync)...", progress=30) + + lipsync = _get_lipsync_service() + lipsync_video_path = temp_dir / f"{task_id}_lipsync.mp4" + temp_files.append(lipsync_video_path) + + lipsync_start = time.time() + is_ready = await _check_lipsync_ready() + + if is_ready: + print(f"[LipSync] Starting LatentSync inference...") + _update_task(task_id, progress=35, message="正在运行 LatentSync 推理...") + await lipsync.generate(str(input_material_path), str(audio_path), str(lipsync_video_path)) + else: + print(f"[LipSync] LatentSync not ready, copying original video") + _update_task(task_id, message="唇形同步不可用,使用原始视频...") + import shutil + shutil.copy(str(input_material_path), lipsync_video_path) + + lipsync_time = time.time() - lipsync_start + print(f"[Pipeline] LipSync completed in {lipsync_time:.1f}s") + _update_task(task_id, progress=80) + + captions_path = None + if req.enable_subtitles: + _update_task(task_id, message="正在生成字幕 (Whisper)...", progress=82) + + captions_path = temp_dir / f"{task_id}_captions.json" + temp_files.append(captions_path) + + try: + await whisper_service.align( + audio_path=str(audio_path), + text=req.text, + output_path=str(captions_path) + ) + print(f"[Pipeline] Whisper alignment completed") + except Exception as e: + logger.warning(f"Whisper alignment failed, skipping subtitles: {e}") + captions_path = None + + _update_task(task_id, progress=85) + + video = VideoService() + final_audio_path = audio_path + if req.bgm_id: + _update_task(task_id, message="正在合成背景音乐...", progress=86) + + bgm_path = resolve_bgm_path(req.bgm_id) + if bgm_path: + mix_output_path = temp_dir / f"{task_id}_audio_mix.wav" + temp_files.append(mix_output_path) + volume = req.bgm_volume if req.bgm_volume is not None else 0.2 + volume = max(0.0, min(float(volume), 1.0)) + try: + video.mix_audio( + voice_path=str(audio_path), + bgm_path=str(bgm_path), + output_path=str(mix_output_path), + bgm_volume=volume + ) + final_audio_path = mix_output_path + except Exception as e: + logger.warning(f"BGM mix failed, fallback to voice only: {e}") + else: + logger.warning(f"BGM not found: {req.bgm_id}") + + use_remotion = (captions_path and captions_path.exists()) or req.title + + subtitle_style = None + title_style = None + if req.enable_subtitles: + subtitle_style = get_style("subtitle", req.subtitle_style_id) or get_default_style("subtitle") + if req.title: + title_style = get_style("title", req.title_style_id) or get_default_style("title") + + if req.subtitle_font_size and req.enable_subtitles: + if subtitle_style is None: + subtitle_style = {} + subtitle_style["font_size"] = int(req.subtitle_font_size) + + if req.title_font_size and req.title: + if title_style is None: + title_style = {} + title_style["font_size"] = int(req.title_font_size) + + if use_remotion: + subtitle_style = prepare_style_for_remotion( + subtitle_style, + temp_dir, + f"{task_id}_subtitle_font" + ) + title_style = prepare_style_for_remotion( + title_style, + temp_dir, + f"{task_id}_title_font" + ) + + final_output_local_path = temp_dir / f"{task_id}_output.mp4" + temp_files.append(final_output_local_path) + + if use_remotion: + _update_task(task_id, message="正在合成视频 (Remotion)...", progress=87) + + composed_video_path = temp_dir / f"{task_id}_composed.mp4" + temp_files.append(composed_video_path) + + await video.compose(str(lipsync_video_path), str(final_audio_path), str(composed_video_path)) + + remotion_health = await remotion_service.check_health() + if remotion_health.get("ready"): + try: + def on_remotion_progress(percent): + mapped = 87 + int(percent * 0.08) + _update_task(task_id, progress=mapped) + + await remotion_service.render( + video_path=str(composed_video_path), + output_path=str(final_output_local_path), + captions_path=str(captions_path) if captions_path else None, + title=req.title, + title_duration=3.0, + fps=25, + enable_subtitles=req.enable_subtitles, + subtitle_style=subtitle_style, + title_style=title_style, + on_progress=on_remotion_progress + ) + print(f"[Pipeline] Remotion render completed") + except Exception as e: + logger.warning(f"Remotion render failed, using FFmpeg fallback: {e}") + import shutil + shutil.copy(str(composed_video_path), final_output_local_path) + else: + logger.warning(f"Remotion not ready: {remotion_health.get('error')}, using FFmpeg") + import shutil + shutil.copy(str(composed_video_path), final_output_local_path) + else: + _update_task(task_id, message="正在合成最终视频...", progress=90) + + await video.compose(str(lipsync_video_path), str(final_audio_path), str(final_output_local_path)) + + total_time = time.time() - start_time + + _update_task(task_id, message="正在上传结果...", progress=95) + + storage_path = f"{user_id}/{task_id}_output.mp4" + with open(final_output_local_path, "rb") as f: + file_data = f.read() + await storage_service.upload_file( + bucket=storage_service.BUCKET_OUTPUTS, + path=storage_path, + file_data=file_data, + content_type="video/mp4" + ) + + signed_url = await storage_service.get_signed_url( + bucket=storage_service.BUCKET_OUTPUTS, + path=storage_path + ) + + print(f"[Pipeline] Total generation time: {total_time:.1f}s") + + _update_task( + task_id, + status="completed", + progress=100, + message=f"生成完成!耗时 {total_time:.0f} 秒", + output=storage_path, + download_url=signed_url, + ) + + except Exception as e: + _update_task( + task_id, + status="failed", + message=f"错误: {str(e)}", + error=traceback.format_exc(), + ) + logger.error(f"Generate video failed: {e}") + finally: + for f in temp_files: + try: + if f.exists(): + f.unlink() + except Exception as e: + print(f"Error cleaning up {f}: {e}") + + +async def get_lipsync_health(): + lipsync = _get_lipsync_service() + return await lipsync.check_health() + + +async def get_voiceclone_health(): + return await voice_clone_service.check_health() diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/repositories/sessions.py b/backend/app/repositories/sessions.py new file mode 100644 index 0000000..7d0bba2 --- /dev/null +++ b/backend/app/repositories/sessions.py @@ -0,0 +1,31 @@ +from typing import Any, Dict, List, Optional, cast + +from app.core.supabase import get_supabase + + +def get_session(user_id: str, session_token: str) -> Optional[Dict[str, Any]]: + supabase = get_supabase() + result = ( + supabase.table("user_sessions") + .select("*") + .eq("user_id", user_id) + .eq("session_token", session_token) + .execute() + ) + data = cast(List[Dict[str, Any]], result.data or []) + return data[0] if data else None + + +def delete_sessions(user_id: str) -> None: + supabase = get_supabase() + supabase.table("user_sessions").delete().eq("user_id", user_id).execute() + + +def create_session(user_id: str, session_token: str, device_info: Optional[str] = None) -> List[Dict[str, Any]]: + supabase = get_supabase() + result = supabase.table("user_sessions").insert({ + "user_id": user_id, + "session_token": session_token, + "device_info": device_info, + }).execute() + return cast(List[Dict[str, Any]], result.data or []) diff --git a/backend/app/repositories/users.py b/backend/app/repositories/users.py new file mode 100644 index 0000000..022f19d --- /dev/null +++ b/backend/app/repositories/users.py @@ -0,0 +1,39 @@ +from typing import Any, Dict, List, Optional, cast + +from app.core.supabase import get_supabase + + +def get_user_by_phone(phone: str) -> Optional[Dict[str, Any]]: + supabase = get_supabase() + result = supabase.table("users").select("*").eq("phone", phone).single().execute() + return cast(Optional[Dict[str, Any]], result.data or None) + + +def get_user_by_id(user_id: str) -> Optional[Dict[str, Any]]: + supabase = get_supabase() + result = supabase.table("users").select("*").eq("id", user_id).single().execute() + return cast(Optional[Dict[str, Any]], result.data or None) + + +def user_exists_by_phone(phone: str) -> bool: + supabase = get_supabase() + result = supabase.table("users").select("id").eq("phone", phone).execute() + return bool(result.data) + + +def create_user(payload: Dict[str, Any]) -> List[Dict[str, Any]]: + supabase = get_supabase() + result = supabase.table("users").insert(payload).execute() + return cast(List[Dict[str, Any]], result.data or []) + + +def list_users() -> List[Dict[str, Any]]: + supabase = get_supabase() + result = supabase.table("users").select("*").order("created_at", desc=True).execute() + return cast(List[Dict[str, Any]], result.data or []) + + +def update_user(user_id: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]: + supabase = get_supabase() + result = supabase.table("users").update(payload).eq("id", user_id).execute() + return cast(List[Dict[str, Any]], result.data or []) diff --git a/backend/app/services/publish_service.py b/backend/app/services/publish_service.py index 9a9dfd6..416a167 100644 --- a/backend/app/services/publish_service.py +++ b/backend/app/services/publish_service.py @@ -24,13 +24,12 @@ class PublishService: """Social media publishing service (with user isolation)""" # 支持的平台配置 - PLATFORMS: Dict[str, Dict[str, Any]] = { - "bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame", "enabled": True}, - "douyin": {"name": "抖音", "url": "https://creator.douyin.com/", "enabled": True}, - "xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/", "enabled": True}, - "weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": False}, - "kuaishou": {"name": "快手", "url": "https://cp.kuaishou.com/", "enabled": False}, - } + PLATFORMS: Dict[str, Dict[str, Any]] = { + "douyin": {"name": "抖音", "url": "https://creator.douyin.com/", "enabled": True}, + "weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": False}, + "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: # 存储活跃的登录会话,用于跟踪登录状态 diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index 519754b..54c214c 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -139,8 +139,8 @@ class StorageService: logger.error(f"Get public URL failed: {e}") return "" - async def delete_file(self, bucket: str, path: str): - """异步删除文件""" + async def delete_file(self, bucket: str, path: str): + """异步删除文件""" try: loop = asyncio.get_running_loop() await loop.run_in_executor( @@ -149,8 +149,21 @@ class StorageService: ) logger.info(f"Deleted file: {bucket}/{path}") except Exception as e: - logger.error(f"Delete file failed: {e}") - pass + logger.error(f"Delete file failed: {e}") + pass + + async def move_file(self, bucket: str, from_path: str, to_path: str): + """异步移动/重命名文件""" + try: + loop = asyncio.get_running_loop() + await loop.run_in_executor( + None, + lambda: self.supabase.storage.from_(bucket).move(from_path, to_path) + ) + logger.info(f"Moved file: {bucket}/{from_path} -> {to_path}") + except Exception as e: + logger.error(f"Move file failed: {e}") + raise e async def list_files(self, bucket: str, path: str) -> List[Any]: """异步列出文件""" diff --git a/frontend/public/platforms/bilibili.svg b/frontend/public/platforms/bilibili.svg new file mode 100644 index 0000000..f722801 --- /dev/null +++ b/frontend/public/platforms/bilibili.svg @@ -0,0 +1 @@ +Bilibili diff --git a/frontend/public/platforms/douyin.svg b/frontend/public/platforms/douyin.svg new file mode 100644 index 0000000..a28e32d --- /dev/null +++ b/frontend/public/platforms/douyin.svg @@ -0,0 +1 @@ +TikTok diff --git a/frontend/public/platforms/wechat.svg b/frontend/public/platforms/wechat.svg new file mode 100644 index 0000000..101bdf0 --- /dev/null +++ b/frontend/public/platforms/wechat.svg @@ -0,0 +1 @@ +WeChat diff --git a/frontend/public/platforms/xiaohongshu.svg b/frontend/public/platforms/xiaohongshu.svg new file mode 100644 index 0000000..fd6ff4d --- /dev/null +++ b/frontend/public/platforms/xiaohongshu.svg @@ -0,0 +1 @@ +Xiaohongshu diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 376c70b..965643f 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1,9 +1,10 @@ 'use client'; import { useState, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; +import { useRouter } from 'next/navigation'; import { getCurrentUser, User } from "@/shared/lib/auth"; import api from "@/shared/api/axios"; +import { ApiResponse, unwrap } from "@/shared/api/types"; interface UserListItem { id: string; @@ -40,8 +41,8 @@ export default function AdminPage() { const fetchUsers = async () => { try { - const { data } = await api.get('/api/admin/users'); - setUsers(data); + const { data: res } = await api.get>('/api/admin/users'); + setUsers(unwrap(res)); } catch (err) { setError('获取用户列表失败'); } finally { diff --git a/frontend/src/components/AccountSettingsDropdown.tsx b/frontend/src/components/AccountSettingsDropdown.tsx index bbe1cd6..c44c6c8 100644 --- a/frontend/src/components/AccountSettingsDropdown.tsx +++ b/frontend/src/components/AccountSettingsDropdown.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from "react"; import { useAuth } from "@/contexts/AuthContext"; import api from "@/shared/api/axios"; +import { ApiResponse } from "@/shared/api/types"; // 账户设置下拉菜单组件 export default function AccountSettingsDropdown() { @@ -65,12 +66,12 @@ export default function AccountSettingsDropdown() { setLoading(true); try { - const res = await api.post('/api/auth/change-password', { + const { data: res } = await api.post>('/api/auth/change-password', { old_password: oldPassword, new_password: newPassword }); - if (res.data.success) { - setSuccess('密码修改成功,正在跳转登录页...'); + if (res.success) { + setSuccess(res.message || '密码修改成功,正在跳转登录页...'); // 清除登录状态并跳转 setTimeout(async () => { try { @@ -79,10 +80,10 @@ export default function AccountSettingsDropdown() { window.location.href = '/login'; }, 1500); } else { - setError(res.data.message || '修改失败'); + setError(res.message || '修改失败'); } } catch (err: any) { - setError(err.response?.data?.detail || '修改失败,请重试'); + setError(err.response?.data?.message || '修改失败,请重试'); } finally { setLoading(false); } diff --git a/frontend/src/components/ScriptExtractionModal.tsx b/frontend/src/components/ScriptExtractionModal.tsx index 4324524..6ac8abb 100644 --- a/frontend/src/components/ScriptExtractionModal.tsx +++ b/frontend/src/components/ScriptExtractionModal.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import api from "@/shared/api/axios"; +import { ApiResponse, unwrap } from "@/shared/api/types"; interface ScriptExtractionModalProps { isOpen: boolean; @@ -100,22 +101,21 @@ export default function ScriptExtractionModal({ } formData.append('rewrite', doRewrite ? 'true' : 'false'); - const { data } = await api.post('/api/tools/extract-script', formData, { + const { data: res } = await api.post>( + '/api/tools/extract-script', + formData, + { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 180000 // 3 minutes timeout }); - if (data.success) { - setScript(data.original_script); - setRewrittenScript(data.rewritten_script || ""); - setStep('result'); - } else { - setError("提取失败:未知错误"); - setStep('config'); - } + const payload = unwrap(res); + setScript(payload.original_script); + setRewrittenScript(payload.rewritten_script || ""); + setStep('result'); } catch (err: any) { console.error(err); - const msg = err.response?.data?.detail || err.message || "请求失败"; + const msg = err.response?.data?.message || err.message || "请求失败"; setError(msg); setStep('config'); } finally { diff --git a/frontend/src/components/VideoPreviewModal.tsx b/frontend/src/components/VideoPreviewModal.tsx index 793815f..ed8c5cc 100644 --- a/frontend/src/components/VideoPreviewModal.tsx +++ b/frontend/src/components/VideoPreviewModal.tsx @@ -71,6 +71,7 @@ export default function VideoPreviewModal({ src={videoUrl} controls autoPlay + preload="metadata" className="w-full h-full max-h-[80vh] object-contain" /> diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index eff65f5..0c34730 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useState, useEffect, ReactNode } from "react"; import api from "@/shared/api/axios"; +import { ApiResponse, unwrap } from "@/shared/api/types"; interface User { id: string; @@ -37,11 +38,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { const fetchUser = async () => { console.log("[AuthContext] 开始获取用户信息..."); try { - const { data } = await api.get('/api/auth/me'); - console.log("[AuthContext] 获取用户信息成功:", data); - if (data && data.id) { - setUser(data); - console.log("[AuthContext] 设置 user:", data); + const { data: res } = await api.get>('/api/auth/me'); + const payload = unwrap(res); + console.log("[AuthContext] 获取用户信息成功:", payload); + if (payload && payload.id) { + setUser(payload); + console.log("[AuthContext] 设置 user:", payload); } else { console.warn("[AuthContext] 响应中没有用户数据"); } diff --git a/frontend/src/contexts/TaskContext.tsx b/frontend/src/contexts/TaskContext.tsx index 9598095..ae2180b 100644 --- a/frontend/src/contexts/TaskContext.tsx +++ b/frontend/src/contexts/TaskContext.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useState, useEffect, ReactNode } from "react"; import api from "@/shared/api/axios"; +import { ApiResponse, unwrap } from "@/shared/api/types"; interface Task { task_id: string; @@ -31,11 +32,12 @@ export function TaskProvider({ children }: { children: ReactNode }) { const pollTask = async () => { try { - const { data } = await api.get(`/api/videos/tasks/${taskId}`); - setCurrentTask(data); + const { data: res } = await api.get>(`/api/videos/tasks/${taskId}`); + const task = unwrap(res); + setCurrentTask(task); // 处理任务完成、失败或不存在的情况 - if (data.status === "completed" || data.status === "failed" || data.status === "not_found") { + if (task.status === "completed" || task.status === "failed" || task.status === "not_found") { setIsGenerating(false); setTaskId(null); // 清除 localStorage diff --git a/frontend/src/features/home/model/useBgm.ts b/frontend/src/features/home/model/useBgm.ts index 9ebab54..e3e4c6b 100644 --- a/frontend/src/features/home/model/useBgm.ts +++ b/frontend/src/features/home/model/useBgm.ts @@ -1,5 +1,6 @@ import { useCallback, useState } from "react"; import api from "@/shared/api/axios"; +import { ApiResponse, unwrap } from "@/shared/api/types"; export interface BgmItem { id: string; @@ -26,8 +27,9 @@ export const useBgm = ({ setBgmLoading(true); setBgmError(""); try { - const { data } = await api.get('/api/assets/bgm'); - const items: BgmItem[] = Array.isArray(data.bgm) ? data.bgm : []; + const { data: res } = await api.get>('/api/assets/bgm'); + const payload = unwrap(res); + const items: BgmItem[] = Array.isArray(payload.bgm) ? payload.bgm : []; setBgmList(items); const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`); @@ -37,7 +39,7 @@ export const useBgm = ({ return items[0]?.id || ""; }); } catch (error: any) { - const message = error?.response?.data?.detail || error?.message || '加载失败'; + const message = error?.response?.data?.message || error?.message || '加载失败'; setBgmError(message); setBgmList([]); console.error("获取背景音乐失败:", error); diff --git a/frontend/src/features/home/model/useGeneratedVideos.ts b/frontend/src/features/home/model/useGeneratedVideos.ts index 52a5161..b8fb409 100644 --- a/frontend/src/features/home/model/useGeneratedVideos.ts +++ b/frontend/src/features/home/model/useGeneratedVideos.ts @@ -1,5 +1,6 @@ import { useCallback, useState } from "react"; import api from "@/shared/api/axios"; +import { ApiResponse, unwrap } from "@/shared/api/types"; interface GeneratedVideo { id: string; @@ -28,8 +29,11 @@ export const useGeneratedVideos = ({ const fetchGeneratedVideos = useCallback(async (preferVideoId?: string) => { try { - const { data } = await api.get('/api/videos/generated'); - const videos: GeneratedVideo[] = data.videos || []; + const { data: res } = await api.get>( + '/api/videos/generated' + ); + const payload = unwrap(res); + const videos: GeneratedVideo[] = payload.videos || []; setGeneratedVideos(videos); const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`); diff --git a/frontend/src/features/home/model/useHomeController.ts b/frontend/src/features/home/model/useHomeController.ts index ebcba36..ee965b1 100644 --- a/frontend/src/features/home/model/useHomeController.ts +++ b/frontend/src/features/home/model/useHomeController.ts @@ -20,6 +20,7 @@ import { useMaterials } from "@/features/home/model/useMaterials"; import { useMediaPlayers } from "@/features/home/model/useMediaPlayers"; import { useRefAudios } from "@/features/home/model/useRefAudios"; import { useTitleSubtitleStyles } from "@/features/home/model/useTitleSubtitleStyles"; +import { ApiResponse, unwrap } from "@/shared/api/types"; const VOICES = [ { id: "zh-CN-YunxiNeural", name: "云溪 (男声-年轻)" }, @@ -29,6 +30,27 @@ const VOICES = [ { id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" }, ]; +const PUBLISH_PREFETCH_KEY = "vigent_publish_prefetch_v1"; +const PUBLISH_PREFETCH_TTL = 2 * 60 * 1000; + +interface PublishAccount { + platform: string; + name: string; + logged_in: boolean; + enabled: boolean; +} + +interface PublishVideo { + name: string; + path: string; +} + +interface PublishPrefetchCache { + ts: number; + accounts?: PublishAccount[]; + videos?: PublishVideo[]; +} + const FIXED_REF_TEXT = "其实生活中有许多美好的瞬间,比如清晨的阳光,或者一杯温热的清茶。希望这次生成的音色能够自然、流畅,完美还原出我最真实的声音状态。"; @@ -64,6 +86,14 @@ interface RefAudio { created_at: number; } +interface Material { + id: string; + name: string; + path: string; + size_mb: number; + scene?: string; +} + export const useHomeController = () => { const apiBase = getApiBaseUrl(); @@ -105,6 +135,8 @@ export const useHomeController = () => { // 音频预览与重命名状态 const [editingAudioId, setEditingAudioId] = useState(null); const [editName, setEditName] = useState(""); + const [editingMaterialId, setEditingMaterialId] = useState(null); + const [editMaterialName, setEditMaterialName] = useState(""); const bgmItemRefs = useRef>({}); const bgmListContainerRef = useRef(null); const titlePreviewContainerRef = useRef(null); @@ -139,6 +171,41 @@ export const useHomeController = () => { } }; + const startMaterialEditing = (material: Material, e: React.MouseEvent) => { + e.stopPropagation(); + setEditingMaterialId(material.id); + const nameWithoutExt = material.name.substring(0, material.name.lastIndexOf(".")); + setEditMaterialName(nameWithoutExt || material.name); + }; + + const cancelMaterialEditing = (e: React.MouseEvent) => { + e.stopPropagation(); + setEditingMaterialId(null); + setEditMaterialName(""); + }; + + const saveMaterialEditing = async (materialId: string, e: React.MouseEvent) => { + e.stopPropagation(); + if (!editMaterialName.trim()) return; + + try { + const { data: res } = await api.put>( + `/api/materials/${encodeURIComponent(materialId)}`, + { new_name: editMaterialName.trim() } + ); + const payload = unwrap(res); + if (selectedMaterial === materialId && payload?.id) { + setSelectedMaterial(payload.id); + } + setEditingMaterialId(null); + setEditMaterialName(""); + fetchMaterials(); + } catch (err: any) { + const errorMsg = err.response?.data?.message || err.message || String(err); + alert(`重命名失败: ${errorMsg}`); + } + }; + // AI 生成标题标签 const [isGeneratingMeta, setIsGeneratingMeta] = useState(false); @@ -158,9 +225,32 @@ export const useHomeController = () => { // 获取存储 key 的前缀(登录用户使用 userId,未登录使用 guest) const storageKey = userId || "guest"; + const readPublishPrefetch = () => { + if (typeof window === "undefined") return null; + const raw = sessionStorage.getItem(PUBLISH_PREFETCH_KEY); + if (!raw) return null; + try { + const cache = JSON.parse(raw) as PublishPrefetchCache; + if (!cache?.ts) return null; + if (Date.now() - cache.ts > PUBLISH_PREFETCH_TTL) return null; + return cache; + } catch { + return null; + } + }; + + const updatePublishPrefetch = (patch: Partial) => { + if (typeof window === "undefined") return; + const existing = readPublishPrefetch() || { ts: Date.now() }; + const next = { ...existing, ...patch, ts: Date.now() }; + sessionStorage.setItem(PUBLISH_PREFETCH_KEY, JSON.stringify(next)); + }; + const { materials, fetchError, + isFetching, + lastMaterialCount, isUploading, uploadProgress, uploadError, @@ -236,6 +326,38 @@ export const useHomeController = () => { resolveMediaUrl, }); + useEffect(() => { + if (isAuthLoading || !userId) return; + let active = true; + + const prefetchAccounts = async () => { + try { + const { data: res } = await api.get>( + "/api/publish/accounts" + ); + if (!active) return; + const payload = unwrap(res); + updatePublishPrefetch({ accounts: payload.accounts || [] }); + } catch (error) { + console.error("预取账号失败:", error); + } + }; + + void prefetchAccounts(); + return () => { + active = false; + }; + }, [isAuthLoading, userId]); + + useEffect(() => { + if (generatedVideos.length === 0) return; + const prefetched = generatedVideos.map((video) => ({ + name: formatDate(video.created_at) + ` (${video.size_mb.toFixed(1)}MB)`, + path: video.path.startsWith("/") ? video.path.slice(1) : video.path, + })); + updatePublishPrefetch({ videos: prefetched }); + }, [generatedVideos]); + const { isRestored } = useHomePersistence({ isAuthLoading, storageKey, @@ -341,8 +463,11 @@ export const useHomeController = () => { }, [materials, selectedMaterial]); useEffect(() => { - if (!titlePreviewContainerRef.current) return; + if (!showStylePreview) return; const container = titlePreviewContainerRef.current; + if (!container) return; + + setPreviewContainerWidth(container.getBoundingClientRect().width); const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { @@ -355,7 +480,7 @@ export const useHomeController = () => { return () => { resizeObserver.disconnect(); }; - }, []); + }, [showStylePreview]); useEffect(() => { if (subtitleSizeLocked || subtitleStyles.length === 0) return; @@ -512,17 +637,21 @@ export const useHomeController = () => { setIsGeneratingMeta(true); try { - const { data } = await api.post("/api/ai/generate-meta", { text: text.trim() }); + const { data: res } = await api.post>( + "/api/ai/generate-meta", + { text: text.trim() } + ); + const payload = unwrap(res); // 更新首页标题 - const nextTitle = clampTitle(data.title || ""); + const nextTitle = clampTitle(payload.title || ""); titleInput.commitValue(nextTitle); // 同步到发布页 localStorage - localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(data.tags || [])); + localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(payload.tags || [])); } catch (err: any) { console.error("AI generate meta failed:", err); - const errorMsg = err.response?.data?.detail || err.message || String(err); + const errorMsg = err.response?.data?.message || err.message || String(err); alert(`AI 生成失败: ${errorMsg}`); } finally { setIsGeneratingMeta(false); @@ -597,9 +726,12 @@ export const useHomeController = () => { } // 创建生成任务 - const { data } = await api.post("/api/videos/generate", payload); + const { data: res } = await api.post>( + "/api/videos/generate", + payload + ); - const taskId = data.task_id; + const taskId = unwrap(res).task_id; // 保存任务ID到 localStorage,以便页面切换后恢复 localStorage.setItem(`vigent_${storageKey}_current_task`, taskId); @@ -644,6 +776,8 @@ export const useHomeController = () => { setPreviewMaterial, materials, fetchError, + isFetching, + lastMaterialCount, isUploading, uploadProgress, uploadError, @@ -654,6 +788,12 @@ export const useHomeController = () => { selectedMaterial, setSelectedMaterial, handlePreviewMaterial, + editingMaterialId, + editMaterialName, + setEditMaterialName, + startMaterialEditing, + saveMaterialEditing, + cancelMaterialEditing, text, setText, extractModalOpen, diff --git a/frontend/src/features/home/model/useMaterials.ts b/frontend/src/features/home/model/useMaterials.ts index ec8c949..b9cc93a 100644 --- a/frontend/src/features/home/model/useMaterials.ts +++ b/frontend/src/features/home/model/useMaterials.ts @@ -1,5 +1,6 @@ import { useCallback, useState } from "react"; import api from "@/shared/api/axios"; +import { ApiResponse, unwrap } from "@/shared/api/types"; interface Material { id: string; @@ -20,6 +21,8 @@ export const useMaterials = ({ }: UseMaterialsOptions) => { const [materials, setMaterials] = useState([]); const [fetchError, setFetchError] = useState(null); + const [isFetching, setIsFetching] = useState(false); + const [lastMaterialCount, setLastMaterialCount] = useState(0); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [uploadError, setUploadError] = useState(null); @@ -27,10 +30,15 @@ export const useMaterials = ({ const fetchMaterials = useCallback(async () => { try { setFetchError(null); + setIsFetching(true); - const { data } = await api.get(`/api/materials?t=${new Date().getTime()}`); - const nextMaterials = data.materials || []; + const { data: res } = await api.get>( + `/api/materials?t=${new Date().getTime()}` + ); + const payload = unwrap(res); + const nextMaterials = payload.materials || []; setMaterials(nextMaterials); + setLastMaterialCount(nextMaterials.length); const nextSelected = nextMaterials.find((item: Material) => item.id === selectedMaterial)?.id || nextMaterials[0]?.id @@ -41,6 +49,8 @@ export const useMaterials = ({ } catch (error) { console.error("获取素材失败:", error); setFetchError(String(error)); + } finally { + setIsFetching(false); } }, [selectedMaterial, setSelectedMaterial]); @@ -92,7 +102,7 @@ export const useMaterials = ({ } catch (err: any) { console.error("Upload failed:", err); setIsUploading(false); - const errorMsg = err.response?.data?.detail || err.message || String(err); + const errorMsg = err.response?.data?.message || err.message || String(err); setUploadError(`上传失败: ${errorMsg}`); } @@ -102,6 +112,8 @@ export const useMaterials = ({ return { materials, fetchError, + isFetching, + lastMaterialCount, isUploading, uploadProgress, uploadError, diff --git a/frontend/src/features/home/model/useRefAudios.ts b/frontend/src/features/home/model/useRefAudios.ts index 66dfa27..25d47e3 100644 --- a/frontend/src/features/home/model/useRefAudios.ts +++ b/frontend/src/features/home/model/useRefAudios.ts @@ -1,5 +1,6 @@ import { useCallback, useState } from "react"; import api from "@/shared/api/axios"; +import { ApiResponse, unwrap } from "@/shared/api/types"; interface RefAudio { id: string; @@ -29,8 +30,9 @@ export const useRefAudios = ({ const fetchRefAudios = useCallback(async () => { try { - const { data } = await api.get('/api/ref-audios'); - const items: RefAudio[] = data.items || []; + const { data: res } = await api.get>('/api/ref-audios'); + const payload = unwrap(res); + const items: RefAudio[] = payload.items || []; items.sort((a, b) => b.created_at - a.created_at); setRefAudios(items); } catch (error) { @@ -49,18 +51,19 @@ export const useRefAudios = ({ formData.append('file', file); formData.append('ref_text', refTextInput); - const { data } = await api.post('/api/ref-audios', formData, { + const { data: res } = await api.post>('/api/ref-audios', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); + const payload = unwrap(res); await fetchRefAudios(); - setSelectedRefAudio(data); - setRefText(data.ref_text); + setSelectedRefAudio(payload); + setRefText(payload.ref_text); setIsUploadingRef(false); } catch (err: any) { console.error("Upload ref audio failed:", err); setIsUploadingRef(false); - const errorMsg = err.response?.data?.detail || err.message || String(err); + const errorMsg = err.response?.data?.message || err.message || String(err); setUploadRefError(`上传失败: ${errorMsg}`); } }, [fetchRefAudios, fixedRefText, setRefText, setSelectedRefAudio]); diff --git a/frontend/src/features/home/model/useTitleSubtitleStyles.ts b/frontend/src/features/home/model/useTitleSubtitleStyles.ts index a159fca..88d98f5 100644 --- a/frontend/src/features/home/model/useTitleSubtitleStyles.ts +++ b/frontend/src/features/home/model/useTitleSubtitleStyles.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import api from "@/shared/api/axios"; +import { ApiResponse, unwrap } from "@/shared/api/types"; export interface SubtitleStyleOption { id: string; @@ -49,8 +50,11 @@ export const useTitleSubtitleStyles = ({ const refreshSubtitleStyles = useCallback(async () => { try { - const { data } = await api.get('/api/assets/subtitle-styles'); - const styles: SubtitleStyleOption[] = data.styles || []; + const { data: res } = await api.get>( + '/api/assets/subtitle-styles' + ); + const payload = unwrap(res); + const styles: SubtitleStyleOption[] = payload.styles || []; setSubtitleStyles(styles); const savedStyleId = localStorage.getItem(`vigent_${storageKey}_subtitleStyle`); @@ -67,8 +71,11 @@ export const useTitleSubtitleStyles = ({ const refreshTitleStyles = useCallback(async () => { try { - const { data } = await api.get('/api/assets/title-styles'); - const styles: TitleStyleOption[] = data.styles || []; + const { data: res } = await api.get>( + '/api/assets/title-styles' + ); + const payload = unwrap(res); + const styles: TitleStyleOption[] = payload.styles || []; setTitleStyles(styles); const savedStyleId = localStorage.getItem(`vigent_${storageKey}_titleStyle`); diff --git a/frontend/src/features/home/ui/HomePage.tsx b/frontend/src/features/home/ui/HomePage.tsx index 6ae53f2..25ed564 100644 --- a/frontend/src/features/home/ui/HomePage.tsx +++ b/frontend/src/features/home/ui/HomePage.tsx @@ -1,5 +1,7 @@ "use client"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; import VideoPreviewModal from "@/components/VideoPreviewModal"; import ScriptExtractionModal from "@/components/ScriptExtractionModal"; import { useHomeController } from "@/features/home/model/useHomeController"; @@ -15,6 +17,7 @@ import { TitleSubtitlePanel } from "@/features/home/ui/TitleSubtitlePanel"; import { VoiceSelector } from "@/features/home/ui/VoiceSelector"; export function HomePage() { + const router = useRouter(); const { apiBase, registerMaterialRef, @@ -22,6 +25,8 @@ export function HomePage() { setPreviewMaterial, materials, fetchError, + isFetching, + lastMaterialCount, isUploading, uploadProgress, uploadError, @@ -32,6 +37,12 @@ export function HomePage() { selectedMaterial, setSelectedMaterial, handlePreviewMaterial, + editingMaterialId, + editMaterialName, + setEditMaterialName, + startMaterialEditing, + saveMaterialEditing, + cancelMaterialEditing, text, setText, extractModalOpen, @@ -119,6 +130,10 @@ export function HomePage() { formatDate, } = useHomeController(); + useEffect(() => { + router.prefetch("/publish"); + }, [router]); + return (
@@ -131,6 +146,10 @@ export function HomePage() { setUploadError(null)} registerMaterialRef={registerMaterialRef} diff --git a/frontend/src/features/home/ui/MaterialSelector.tsx b/frontend/src/features/home/ui/MaterialSelector.tsx index 94fa1c0..11f364e 100644 --- a/frontend/src/features/home/ui/MaterialSelector.tsx +++ b/frontend/src/features/home/ui/MaterialSelector.tsx @@ -1,5 +1,5 @@ -import type { ChangeEvent } from "react"; -import { Upload, RefreshCw, Eye, Trash2, X } from "lucide-react"; +import type { ChangeEvent, MouseEvent } from "react"; +import { Upload, RefreshCw, Eye, Trash2, X, Pencil, Check } from "lucide-react"; interface Material { id: string; @@ -12,6 +12,10 @@ interface Material { interface MaterialSelectorProps { materials: Material[]; selectedMaterial: string; + isFetching: boolean; + lastMaterialCount: number; + editingMaterialId: string | null; + editMaterialName: string; isUploading: boolean; uploadProgress: number; uploadError: string | null; @@ -21,6 +25,10 @@ interface MaterialSelectorProps { onRefresh: () => void; onSelectMaterial: (id: string) => void; onPreviewMaterial: (path: string) => void; + onStartEditing: (material: Material, event: MouseEvent) => void; + onEditNameChange: (value: string) => void; + onSaveEditing: (materialId: string, event: MouseEvent) => void; + onCancelEditing: (event: MouseEvent) => void; onDeleteMaterial: (id: string) => void; onClearUploadError: () => void; registerMaterialRef: (id: string, element: HTMLDivElement | null) => void; @@ -29,6 +37,10 @@ interface MaterialSelectorProps { export function MaterialSelector({ materials, selectedMaterial, + isFetching, + lastMaterialCount, + editingMaterialId, + editMaterialName, isUploading, uploadProgress, uploadError, @@ -38,6 +50,10 @@ export function MaterialSelector({ onRefresh, onSelectMaterial, onPreviewMaterial, + onStartEditing, + onEditNameChange, + onSaveEditing, + onCancelEditing, onDeleteMaterial, onClearUploadError, registerMaterialRef, @@ -109,6 +125,18 @@ export function MaterialSelector({
API: {apiBase}/api/materials/
+ ) : isFetching && materials.length === 0 ? ( +
+ {Array.from({ length: Math.min(4, Math.max(1, lastMaterialCount || 1)) }).map((_, index) => ( +
+
+
+
+ ))} +
) : materials.length === 0 ? (
📁
@@ -131,10 +159,35 @@ export function MaterialSelector({ : "border-white/10 bg-white/5 hover:border-white/30" }`} > - + {editingMaterialId === m.id ? ( +
e.stopPropagation()}> + onEditNameChange(e.target.value)} + className="flex-1 bg-black/40 border border-white/20 rounded-md px-2 py-1 text-xs text-white" + autoFocus + /> + + +
+ ) : ( + + )}
+ {editingMaterialId !== m.id && ( + + )} + + + ) : ( - - - ) : ( - - )} + )} +
-
- ))} -
+ ))} + + )} @@ -197,7 +225,19 @@ export function PublishPage() { /> - {filteredVideos.length === 0 ? ( + {isVideosLoading ? ( +
+ {Array.from({ length: 2 }).map((_, index) => ( +
+
+
+
+ ))} +
+ ) : filteredVideos.length === 0 ? (
暂无可发布的视频
@@ -221,6 +261,15 @@ export function PublishPage() { e.stopPropagation(); handlePreviewVideo(v.path); }} + onMouseEnter={() => { + const src = v.path.startsWith("/") ? v.path : `/${v.path}`; + const prefetch = document.createElement("link"); + prefetch.rel = "preload"; + prefetch.as = "video"; + prefetch.href = src; + document.head.appendChild(prefetch); + setTimeout(() => prefetch.remove(), 2000); + }} className="p-1 text-gray-500 hover:text-purple-400 transition-colors" title="预览" > @@ -286,8 +335,16 @@ export function PublishPage() { : "border-white/10 bg-white/5 hover:border-white/30" }`} > - - {platformIcons[account.platform]} + + {platformIcons[account.platform] ? ( + {platformIcons[account.platform].alt} + ) : ( + 🌐 + )} {account.name} @@ -361,9 +418,15 @@ export function PublishPage() { }`} >
- - {platformIcons[result.platform]} - + {platformIcons[result.platform] ? ( + {platformIcons[result.platform].alt} + ) : ( + 🌐 + )} {result.success ? "发布成功" : "发布失败"} diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts new file mode 100644 index 0000000..88f1276 --- /dev/null +++ b/frontend/src/shared/api/types.ts @@ -0,0 +1,8 @@ +export type ApiResponse = { + success: boolean; + message: string; + data: T; + code: number; +}; + +export const unwrap = (response: ApiResponse): T => response.data; diff --git a/frontend/src/shared/lib/auth.ts b/frontend/src/shared/lib/auth.ts index 24ee5ec..fb66e06 100644 --- a/frontend/src/shared/lib/auth.ts +++ b/frontend/src/shared/lib/auth.ts @@ -15,76 +15,93 @@ export interface User { expires_at: string | null; } -export interface AuthResponse { - success: boolean; - message: string; - user?: User; -} +export interface AuthResponse { + success: boolean; + message: string; + user?: User; +} + +interface ApiResponse { + success: boolean; + message: string; + data: T; + code: number; +} /** * 用户注册 */ -export async function register(phone: string, password: string, username?: string): Promise { - const res = await fetch(`${API_BASE}/api/auth/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ phone, password, username }) - }); - return res.json(); -} +export async function register(phone: string, password: string, username?: string): Promise { + const res = await fetch(`${API_BASE}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ phone, password, username }) + }); + const payload = await res.json(); + const data = payload as ApiResponse; + return { success: data.success, message: data.message }; +} /** * 用户登录 */ -export async function login(phone: string, password: string): Promise { - const res = await fetch(`${API_BASE}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ phone, password }) - }); - return res.json(); -} +export async function login(phone: string, password: string): Promise { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ phone, password }) + }); + const payload = await res.json(); + const data = payload as ApiResponse<{ user?: User }>; + return { success: data.success, message: data.message, user: data.data?.user }; +} /** * 用户登出 */ -export async function logout(): Promise { - const res = await fetch(`${API_BASE}/api/auth/logout`, { - method: 'POST', - credentials: 'include' - }); - return res.json(); -} +export async function logout(): Promise { + const res = await fetch(`${API_BASE}/api/auth/logout`, { + method: 'POST', + credentials: 'include' + }); + const payload = await res.json(); + const data = payload as ApiResponse; + return { success: data.success, message: data.message }; +} /** * 修改密码 */ -export async function changePassword(oldPassword: string, newPassword: string): Promise { - const res = await fetch(`${API_BASE}/api/auth/change-password`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }) - }); - return res.json(); -} +export async function changePassword(oldPassword: string, newPassword: string): Promise { + const res = await fetch(`${API_BASE}/api/auth/change-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }) + }); + const payload = await res.json(); + const data = payload as ApiResponse; + return { success: data.success, message: data.message }; +} /** * 获取当前用户 */ -export async function getCurrentUser(): Promise { - try { - const res = await fetch(`${API_BASE}/api/auth/me`, { - credentials: 'include' - }); - if (!res.ok) return null; - return res.json(); - } catch { - return null; - } -} +export async function getCurrentUser(): Promise { + try { + const res = await fetch(`${API_BASE}/api/auth/me`, { + credentials: 'include' + }); + if (!res.ok) return null; + const payload = await res.json(); + const data = payload as ApiResponse; + return data.data || null; + } catch { + return null; + } +} /** * 检查是否已登录