diff --git a/.gitignore b/.gitignore index 41a059d..ae49093 100644 --- a/.gitignore +++ b/.gitignore @@ -20,11 +20,14 @@ node_modules/ out/ .turbo/ -# ============ IDE ============ +# ============ IDE / AI 工具 ============ .vscode/ .idea/ *.swp *.swo +.agents/ +.opencode/ +.claude/ # ============ 系统文件 ============ .DS_Store @@ -35,11 +38,21 @@ desktop.ini backend/outputs/ backend/uploads/ backend/cookies/ +backend/user_data/ +backend/debug_screenshots/ *_cookies.json -# ============ MuseTalk ============ +# ============ 模型权重 ============ +models/*/checkpoints/ models/MuseTalk/models/ models/MuseTalk/results/ +models/LatentSync/temp/ + +# ============ Remotion 构建 ============ +remotion/dist/ + +# ============ 临时文件 ============ +Temp/ # ============ 日志 ============ *.log diff --git a/Docs/BACKEND_DEV.md b/Docs/BACKEND_DEV.md index 4dbc96e..a5fe4b1 100644 --- a/Docs/BACKEND_DEV.md +++ b/Docs/BACKEND_DEV.md @@ -29,15 +29,15 @@ backend/ ├── app/ │ ├── core/ # config、deps、security、response │ ├── modules/ # 业务模块(路由 + 逻辑) -│ │ ├── videos/ # 视频生成任务 -│ │ ├── materials/ # 素材管理 +│ │ ├── videos/ # 视频生成任务(router/schemas/service/workflow) +│ │ ├── materials/ # 素材管理(router/schemas/service) │ │ ├── publish/ # 多平台发布 │ │ ├── auth/ # 认证与会话 │ │ ├── ai/ # AI 功能(标题标签生成等) │ │ ├── assets/ # 静态资源(字体/样式/BGM) -│ │ ├── ref_audios/ # 声音克隆参考音频 +│ │ ├── ref_audios/ # 声音克隆参考音频(router/schemas/service) │ │ ├── login_helper/ # 扫码登录辅助 -│ │ ├── tools/ # 工具接口 +│ │ ├── tools/ # 工具接口(router/schemas/service) │ │ └── admin/ # 管理员功能 │ ├── repositories/ # Supabase 数据访问 │ ├── services/ # 外部服务集成 @@ -124,10 +124,13 @@ backend/user_data/{user_uuid}/cookies/ ## 8. 开发流程建议 -- **新增功能**:先建模块,再写 router/service/workflow。 -- **修复 Bug**:顺手把涉及的逻辑抽到对应 service/workflow。 +- **新增功能**:先建模块,**必须**包含 `router.py + schemas.py + service.py`,不允许 router-only。 +- **修复 Bug**:顺手把涉及的逻辑抽到对应 service/workflow(渐进式改造)。 +- **改旧模块**:改动哪部分就拆哪部分,不要求一次重构整个文件。 - **核心流程变更**:必跑冒烟(登录/生成/发布)。 +> **渐进原则**:新代码高标准,旧代码逐步改。不做大规模一次性重构,避免引入回归风险。 + --- ## 9. 常用环境变量 diff --git a/Docs/BACKEND_README.md b/Docs/BACKEND_README.md index 935e2b4..9889472 100644 --- a/Docs/BACKEND_README.md +++ b/Docs/BACKEND_README.md @@ -15,15 +15,15 @@ backend/ ├── app/ │ ├── core/ # 核心配置 (config.py, security.py, response.py) │ ├── modules/ # 业务模块 (router/service/workflow/schemas) -│ │ ├── videos/ # 视频生成任务 -│ │ ├── materials/ # 素材管理 +│ │ ├── videos/ # 视频生成任务(router/schemas/service/workflow) +│ │ ├── materials/ # 素材管理(router/schemas/service) │ │ ├── publish/ # 多平台发布 │ │ ├── auth/ # 认证与会话 │ │ ├── ai/ # AI 功能(标题标签生成) │ │ ├── assets/ # 静态资源(字体/样式/BGM) -│ │ ├── ref_audios/ # 声音克隆参考音频 +│ │ ├── ref_audios/ # 声音克隆参考音频(router/schemas/service) │ │ ├── login_helper/ # 扫码登录辅助 -│ │ ├── tools/ # 工具接口(文案提取等) +│ │ ├── tools/ # 工具接口(router/schemas/service) │ │ └── admin/ # 管理员功能 │ ├── repositories/ # Supabase 数据访问 │ ├── services/ # 外部服务集成 (TTS/Remotion/Storage/Uploader 等) diff --git a/Docs/DevLogs/Day12.md b/Docs/DevLogs/Day12.md index 88a85ff..e122d9a 100644 --- a/Docs/DevLogs/Day12.md +++ b/Docs/DevLogs/Day12.md @@ -342,6 +342,6 @@ models/Qwen3-TTS/ ## 🔗 相关文档 -- [task_complete.md](../task_complete.md) - 任务总览 +- [TASK_COMPLETE.md](../TASK_COMPLETE.md) - 任务总览 - [Day11.md](./Day11.md) - 上传架构重构 - [QWEN3_TTS_DEPLOY.md](../QWEN3_TTS_DEPLOY.md) - Qwen3-TTS 部署指南 diff --git a/Docs/DevLogs/Day13.md b/Docs/DevLogs/Day13.md index e397316..35ccfa5 100644 --- a/Docs/DevLogs/Day13.md +++ b/Docs/DevLogs/Day13.md @@ -273,7 +273,7 @@ pm2 logs vigent2-qwen-tts --lines 50 ## 🔗 相关文档 -- [task_complete.md](../task_complete.md) - 任务总览 +- [TASK_COMPLETE.md](../TASK_COMPLETE.md) - 任务总览 - [Day12.md](./Day12.md) - iOS 兼容与 Qwen3-TTS 部署 - [QWEN3_TTS_DEPLOY.md](../QWEN3_TTS_DEPLOY.md) - Qwen3-TTS 部署指南 - [SUBTITLE_DEPLOY.md](../SUBTITLE_DEPLOY.md) - 字幕功能部署指南 diff --git a/Docs/DevLogs/Day14.md b/Docs/DevLogs/Day14.md index b020d18..2bd1627 100644 --- a/Docs/DevLogs/Day14.md +++ b/Docs/DevLogs/Day14.md @@ -397,6 +397,6 @@ if ((status === 401 || status === 403) && !isRedirecting && !isPublicPath) { ## 🔗 相关文档 -- [task_complete.md](../task_complete.md) - 任务总览 +- [TASK_COMPLETE.md](../TASK_COMPLETE.md) - 任务总览 - [Day13.md](./Day13.md) - 声音克隆功能集成 + 字幕功能 - [QWEN3_TTS_DEPLOY.md](../QWEN3_TTS_DEPLOY.md) - Qwen3-TTS 1.7B 部署指南 diff --git a/Docs/DevLogs/Day15.md b/Docs/DevLogs/Day15.md index 696ceca..b6ba93e 100644 --- a/Docs/DevLogs/Day15.md +++ b/Docs/DevLogs/Day15.md @@ -342,7 +342,7 @@ pm2 restart vigent2-backend vigent2-frontend ## 🔗 相关文档 -- [task_complete.md](../task_complete.md) - 任务总览 +- [TASK_COMPLETE.md](../TASK_COMPLETE.md) - 任务总览 - [Day14.md](./Day14.md) - 模型升级 + AI 标题标签 - [AUTH_DEPLOY.md](../AUTH_DEPLOY.md) - 认证系统部署指南 diff --git a/Docs/DevLogs/Day16.md b/Docs/DevLogs/Day16.md index ace01cf..a1f2ae4 100644 --- a/Docs/DevLogs/Day16.md +++ b/Docs/DevLogs/Day16.md @@ -136,4 +136,4 @@ if service["failures"] >= service['threshold']: - [x] `Docs/QWEN3_TTS_DEPLOY.md`: 添加 Flash Attention 安装指南 - [x] `Docs/DEPLOY_MANUAL.md`: 添加 Watchdog 部署说明 -- [x] `Docs/task_complete.md`: 更新进度至 100% (Day 16) +- [x] `Docs/TASK_COMPLETE.md`: 更新进度至 100% (Day 16) diff --git a/Docs/DevLogs/Day21.md b/Docs/DevLogs/Day21.md index ce5f599..558cfe4 100644 --- a/Docs/DevLogs/Day21.md +++ b/Docs/DevLogs/Day21.md @@ -246,3 +246,72 @@ PLATFORM_CONFIGS = { pm2 restart vigent2-backend # 发布服务 + QR登录 npm run build && pm2 restart vigent2-frontend # 刷脸验证UI ``` + +--- + +## 🏗️ 架构优化:前端结构微调 + 后端模块分层 (Day 21) + +### 概述 +根据架构审计结果,完成前端目录规范化和后端核心模块的分层补全。 + +### 一、前端结构微调 + +#### 1. ScriptExtractionModal 迁移 +- `components/ScriptExtractionModal.tsx` → `features/home/ui/ScriptExtractionModal.tsx` +- 连带 `components/script-extraction/` 目录一并迁移到 `features/home/ui/script-extraction/` +- 更新 `HomePage.tsx` 的 import 路径 + +#### 2. contexts/ 目录归并 +- `src/contexts/AuthContext.tsx` → `src/shared/contexts/AuthContext.tsx` +- `src/contexts/TaskContext.tsx` → `src/shared/contexts/TaskContext.tsx` +- 更新 6 处 import(layout.tsx, useHomeController.ts, usePublishController.ts, AccountSettingsDropdown.tsx, GlobalTaskIndicator.tsx) +- 删除空的 `src/contexts/` 目录 + +#### 3. 清理重构遗留空目录 +- 删除 `src/lib/`、`src/components/home/`、`src/hooks/` + +### 二、后端模块分层补全 + +将 3 个 400+ 行的 router-only 模块拆分为 `router.py + schemas.py + service.py`: + +| 模块 | 改造前 | 改造后 router | +|------|--------|--------------| +| `materials/` | 416 行 | 63 行 | +| `tools/` | 417 行 | 33 行 | +| `ref_audios/` | 421 行 | 71 行 | + +业务逻辑全部提取到 `service.py`,数据模型定义在 `schemas.py`,router 只做参数校验 + 调用 service + 返回响应。 + +### 三、开发规范更新 + +`BACKEND_DEV.md` 第 8 节新增渐进原则: +- 新模块**必须**包含 `router.py + schemas.py + service.py` +- 改旧模块时顺手拆涉及的部分 +- 新代码高标准,旧代码逐步改 + +### 涉及文件汇总 + +| 文件 | 变更 | +|------|------| +| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | 从 components/ 迁入 | +| `frontend/src/features/home/ui/script-extraction/` | 从 components/ 迁入 | +| `frontend/src/shared/contexts/AuthContext.tsx` | 从 contexts/ 迁入 | +| `frontend/src/shared/contexts/TaskContext.tsx` | 从 contexts/ 迁入 | +| `backend/app/modules/materials/schemas.py` | **新建** | +| `backend/app/modules/materials/service.py` | **新建** | +| `backend/app/modules/materials/router.py` | 精简为薄路由 | +| `backend/app/modules/tools/schemas.py` | **新建** | +| `backend/app/modules/tools/service.py` | **新建** | +| `backend/app/modules/tools/router.py` | 精简为薄路由 | +| `backend/app/modules/ref_audios/schemas.py` | **新建** | +| `backend/app/modules/ref_audios/service.py` | **新建** | +| `backend/app/modules/ref_audios/router.py` | 精简为薄路由 | +| `Docs/BACKEND_DEV.md` | 目录结构标注分层、新增渐进原则 | +| `Docs/BACKEND_README.md` | 目录结构标注分层 | +| `Docs/FRONTEND_DEV.md` | 更新目录结构(contexts 迁移、ScriptExtractionModal 迁移) | + +### 重启要求 +```bash +pm2 restart vigent2-backend +npm run build && pm2 restart vigent2-frontend +``` diff --git a/Docs/DevLogs/Day7.md b/Docs/DevLogs/Day7.md index 0d03b83..0ad6328 100644 --- a/Docs/DevLogs/Day7.md +++ b/Docs/DevLogs/Day7.md @@ -389,7 +389,7 @@ if not qr_element: ## 📋 文档规则优化 (16:42 - 17:10) -**问题**:Doc_Rules需要优化,避免误删历史内容、规范工具使用、防止任务清单遗漏 +**问题**:DOC_RULES需要优化,避免误删历史内容、规范工具使用、防止任务清单遗漏 **优化内容(最终版)**: @@ -411,7 +411,7 @@ if not qr_element: - 移除无关项目组件 **修改文件**: -- `Docs/Doc_Rules.md` - 包含检查清单的最终完善版 +- `Docs/DOC_RULES.md` - 包含检查清单的最终完善版 --- diff --git a/Docs/Doc_Rules.md b/Docs/Doc_Rules.md index 6081422..efeaa70 100644 --- a/Docs/Doc_Rules.md +++ b/Docs/Doc_Rules.md @@ -8,8 +8,8 @@ | 规则 | 说明 | |------|------| -| **默认更新** | 只更新 `DayN.md` | -| **按需更新** | `task_complete.md` 仅在用户**明确要求**时更新 | +| **默认更新** | 更新 `DayN.md` 和 `TASK_COMPLETE.md` | +| **按需更新** | 其他文档仅在内容变化涉及时更新 | | **智能修改** | 错误→替换,改进→追加(见下方详细规则) | | **先读后写** | 更新前先查看文件当前内容 | | **日内合并** | 同一天的多次小修改合并为最终版本 | @@ -23,7 +23,7 @@ | 优先级 | 文件路径 | 检查重点 | | :---: | :--- | :--- | | 🔥 **High** | `Docs/DevLogs/DayN.md` | **(最新日志)** 详细记录变更、修复、代码片段 | -| 🔥 **High** | `Docs/task_complete.md` | **(任务总览)** 更新 `[x]`、进度条、时间线 | +| 🔥 **High** | `Docs/TASK_COMPLETE.md` | **(任务总览)** 更新 `[x]`、进度条、时间线 | | ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 | | ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 | | ⚡ **Med** | `Docs/BACKEND_DEV.md` | **(后端规范)** 接口契约、模块划分、环境变量 | @@ -186,15 +186,15 @@ new_string: "**状态**:✅ 已修复" ``` ViGent2/Docs/ -├── task_complete.md # 任务总览(仅按需更新) -├── Doc_Rules.md # 本文件 +├── TASK_COMPLETE.md # 任务总览(仅按需更新) +├── DOC_RULES.md # 本文件 ├── BACKEND_DEV.md # 后端开发规范 ├── BACKEND_README.md # 后端功能文档 ├── FRONTEND_DEV.md # 前端开发规范 ├── FRONTEND_README.md # 前端功能文档 ├── DEPLOY_MANUAL.md # 部署手册 ├── SUPABASE_DEPLOY.md # Supabase 部署文档 -├── LatentSync_DEPLOY.md # LatentSync 部署文档 +├── LATENTSYNC_DEPLOY.md # LatentSync 部署文档 ├── QWEN3_TTS_DEPLOY.md # 声音克隆部署文档 ├── SUBTITLE_DEPLOY.md # 字幕系统部署文档 └── DevLogs/ @@ -206,8 +206,16 @@ ViGent2/Docs/ ## 📅 DayN.md 更新规则(日常更新) +### 更新时机 + +> **边开发边记录,不要等到最后才写。** + +- 每完成一个功能/修复后,**立即**追加到 DayN.md +- 避免积攒到对话末尾一次性补写,容易遗漏变更 +- `TASK_COMPLETE.md` 同理,重要变更完成后及时同步 + ### 新建判断 (对话开始前) -1. **回顾进度**:查看 `task_complete.md` 了解当前状态 +1. **回顾进度**:查看 `TASK_COMPLETE.md` 了解当前状态 2. **检查日期**:查看最新 `DayN.md` - **今天 (与当前日期相同)** → 🚨 **绝对禁止创建新文件**,必须**追加**到现有 `DayN.md` 末尾!即使是完全不同的功能模块。 - **之前 (昨天或更早)** → 创建 `Day{N+1}.md` @@ -263,17 +271,17 @@ ViGent2/Docs/ --- -## 📝 task_complete.md 更新规则(仅按需) +## 📝 TASK_COMPLETE.md 更新规则 -> ⚠️ **仅当用户明确要求更新 `task_complete.md` 时才更新** +> 与 DayN.md 同步更新,记录重要变更时更新任务总览。 ### 更新原则 -- **格式一致性**:直接参考 `task_complete.md` 现有格式追加内容。 +- **格式一致性**:直接参考 `TASK_COMPLETE.md` 现有格式追加内容。 - **进度更新**:仅在阶段性里程碑时更新进度百分比。 ### 🔍 完整性检查清单 (必做) -每次更新 `task_complete.md` 时,必须**逐一检查**以下所有板块: +每次更新 `TASK_COMPLETE.md` 时,必须**逐一检查**以下所有板块: 1. **文件头部 & 导航** - [ ] `更新时间`:必须是当天日期 diff --git a/Docs/FRONTEND_DEV.md b/Docs/FRONTEND_DEV.md index 3895cb8..ea3eadf 100644 --- a/Docs/FRONTEND_DEV.md +++ b/Docs/FRONTEND_DEV.md @@ -28,6 +28,9 @@ frontend/src/ │ │ ├── HomeHeader.tsx │ │ ├── MaterialSelector.tsx │ │ ├── ScriptEditor.tsx +│ │ ├── ScriptExtractionModal.tsx +│ │ ├── script-extraction/ +│ │ │ └── useScriptExtraction.ts │ │ ├── TitleSubtitlePanel.tsx │ │ ├── FloatingStylePreview.tsx │ │ ├── VoiceSelector.tsx @@ -55,11 +58,11 @@ frontend/src/ │ ├── types/ │ │ ├── user.ts # User 类型定义 │ │ └── publish.ts # 发布相关类型 -│ └── contexts/ # 已迁移的 Context -├── contexts/ # 全局 Context(Auth、Task) +│ └── contexts/ # 全局 Context(Auth、Task) +│ ├── AuthContext.tsx +│ └── TaskContext.tsx ├── components/ # 遗留通用组件 -│ ├── VideoPreviewModal.tsx -│ └── ScriptExtractionModal.tsx +│ └── VideoPreviewModal.tsx └── proxy.ts # Next.js middleware(路由保护) ``` @@ -278,8 +281,8 @@ import { formatDate } from '@/shared/lib/media'; - `shared/lib`:通用工具函数(media.ts / auth.ts / title.ts) - `shared/hooks`:跨功能通用 hooks - `shared/types`:跨功能实体类型(User / PublishVideo 等) -- `contexts/`:全局 Context(AuthContext / TaskContext) -- `components/`:遗留通用组件(VideoPreviewModal 等) +- `shared/contexts`:全局 Context(AuthContext / TaskContext) +- `components/`:遗留通用组件(VideoPreviewModal) ## 类型定义规范 diff --git a/Docs/task_complete.md b/Docs/task_complete.md index a9a47d9..875c449 100644 --- a/Docs/task_complete.md +++ b/Docs/task_complete.md @@ -10,18 +10,25 @@ > 这里记录了每一天的核心开发内容与 milestone。 -### Day 21: 缺陷修复与持久化回归治理 (Current) +### Day 21: 缺陷修复 + 浮动预览 + 发布重构 + 架构优化 (Current) - [x] **Remotion 崩溃容错**: 渲染进程 SIGABRT 退出时检查输出文件,避免误判失败导致标题/字幕丢失。 - [x] **首页作品选择持久化**: 修复 `fetchGeneratedVideos` 无条件覆盖恢复值的问题,新增 `preferVideoId` 参数控制选中逻辑。 - [x] **发布页作品选择持久化**: 根因为签名 URL 不稳定,全面改用 `video.id` 替代 `path` 进行选择/持久化/比较。 - [x] **预取缓存补全**: 首页预取发布页数据时加入 `id` 字段,确保缓存数据可用于持久化匹配。 +- [x] **浮动样式预览窗口**: 标题字幕预览改为 `position: fixed` 浮动窗口,固定左上角,滚动时始终可见。 +- [x] **移动端适配**: ScriptEditor 按钮换行、预览默认比例改为 9:16 竖屏。 +- [x] **多平台发布重构**: 平台配置独立化(DOUYIN_*/WEIXIN_*)、用户隔离 Cookie 管理、抖音刷脸验证二维码、微信发布流程优化。 +- [x] **前端结构微调**: ScriptExtractionModal 迁移到 features/、contexts 迁移到 shared/contexts/、清理空目录。 +- [x] **后端模块分层**: materials/tools/ref_audios 三个模块补全 router+schemas+service 分层。 +- [x] **开发规范更新**: BACKEND_DEV.md 新增渐进原则、DOC_RULES.md 取消 TASK_COMPLETE.md 手动触发约束。 +- [x] **文档全面更新**: BACKEND_DEV/README、FRONTEND_DEV、DEPLOY_MANUAL、README.md 同步更新。 ### Day 20: 代码质量与安全优化 - [x] **功能性修复**: LatentSync 回退逻辑、任务状态接口认证、User 类型统一。 - [x] **性能优化**: N+1 查询修复、视频上传流式处理、httpx 异步替换、GLM 异步包装。 - [x] **安全修复**: 硬编码 Cookie 配置化、日志敏感信息脱敏、ffprobe 安全调用、CORS 配置化。 - [x] **配置优化**: 存储路径环境变量化、Remotion 预编译加速、LatentSync 绝对路径。 -- [x] **文档更新**: 更新 Doc_Rules.md 清单,补齐后端与部署文档;更新 SUBTITLE_DEPLOY.md, FRONTEND_DEV.md, implementation_plan.md。 +- [x] **文档更新**: 更新 DOC_RULES.md 清单,补齐后端与部署文档;更新 SUBTITLE_DEPLOY.md, FRONTEND_DEV.md, implementation_plan.md。 - [x] **缺陷修复**: 修复 Remotion 路径解析、发布页持久化竞态、首页选中回归、素材闭包陷阱。 ### Day 19: 自动发布稳定性与发布体验优化 🚀 diff --git a/backend/app/modules/materials/router.py b/backend/app/modules/materials/router.py index 08fdda5..2d562df 100644 --- a/backend/app/modules/materials/router.py +++ b/backend/app/modules/materials/router.py @@ -1,416 +1,62 @@ -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 +from fastapi import APIRouter, HTTPException, Request, Depends +from loguru import logger +from app.core.deps import get_current_user +from app.core.response import success_response +from app.modules.materials.schemas import RenameMaterialRequest +from app.modules.materials import service -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 = APIRouter() @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) - + logger.info(f"Upload material request from user {user_id}") 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" - }) - + result = await service.upload_material(request, user_id) + return success_response(result) + except ValueError as e: + raise HTTPException(400, str(e)) 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)}") + raise HTTPException(500, f"Upload failed. 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="获取素材失败") + materials = await service.list_materials(user_id) + return success_response({"materials": materials}) -@router.delete("/{material_id:path}") -async def delete_material(material_id: str, current_user: dict = Depends(get_current_user)): +@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)}") - + await service.delete_material(material_id, user_id) + return success_response(message="素材已删除") + except PermissionError as e: + raise HTTPException(403, str(e)) + 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"] + try: + result = await service.rename_material(material_id, payload.new_name, user_id) + return success_response(result, message="重命名成功") + except PermissionError as e: + raise HTTPException(403, str(e)) + except ValueError as e: + raise HTTPException(400, str(e)) + except Exception as e: + raise HTTPException(500, f"重命名失败: {str(e)}") diff --git a/backend/app/modules/materials/schemas.py b/backend/app/modules/materials/schemas.py new file mode 100644 index 0000000..6e681f8 --- /dev/null +++ b/backend/app/modules/materials/schemas.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + + +class RenameMaterialRequest(BaseModel): + new_name: str + + +class MaterialItem(BaseModel): + id: str + name: str + path: str + size_mb: float + type: str = "video" + created_at: int = 0 diff --git a/backend/app/modules/materials/service.py b/backend/app/modules/materials/service.py new file mode 100644 index 0000000..8180caa --- /dev/null +++ b/backend/app/modules/materials/service.py @@ -0,0 +1,296 @@ +import re +import os +import time +import asyncio +import traceback +import aiofiles +from pathlib import Path +from loguru import logger + +from app.services.storage import storage_service + + +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 + + +def _extract_display_name(storage_name: str) -> str: + """从存储文件名中提取显示名(去掉时间戳前缀)""" + if '_' in storage_name: + parts = storage_name.split('_', 1) + if parts[0].isdigit(): + return parts[1] + return storage_name + + +async def _process_and_upload(temp_file_path: str, original_filename: str, content_type: str, user_id: str) -> str: + """Strip multipart headers and upload to Supabase, return storage_path""" + try: + logger.info(f"Processing raw upload: {temp_file_path} for user {user_id}") + + file_size = os.path.getsize(temp_file_path) + + with open(temp_file_path, 'rb') as f: + head = f.read(4096) + + 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] + logger.info(f"Detected boundary: {boundary}") + + 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}") + + f.seek(max(0, file_size - 200)) + tail = f.read() + + last_boundary_pos = tail.rfind(boundary) + if last_boundary_pos != -1: + 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}") + + 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) + bytes_to_copy = end_offset - start_offset + copied = 0 + while copied < bytes_to_copy: + chunk_size = min(1024 * 1024 * 10, bytes_to_copy - copied) + chunk = src.read(chunk_size) + if not chunk: + break + dst.write(chunk) + copied += len(chunk) + + logger.info(f"Extracted video content to {video_path}") + + timestamp = int(time.time()) + safe_name = re.sub(r'[^a-zA-Z0-9._-]', '', original_filename) + storage_path = f"{user_id}/{timestamp}_{safe_name}" + + with open(video_path, 'rb') as f: + file_content = f.read() + 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}") + + 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 + + +async def upload_material(request, user_id: str) -> dict: + """接收流式上传并存储到 Supabase,返回素材信息""" + filename = "unknown_video.mp4" + content_type = "video/mp4" + + timestamp = int(time.time()) + temp_filename = f"upload_{timestamp}.raw" + temp_path = os.path.join("/tmp", temp_filename) + if os.name == 'nt': + 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) + + 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 ValueError("Received empty body") + + 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}") + + storage_path = await _process_and_upload(temp_path, filename, content_type, user_id) + + signed_url = await storage_service.get_signed_url( + bucket=storage_service.BUCKET_MATERIALS, + path=storage_path + ) + + size_mb = total_size / (1024 * 1024) + display_name = _extract_display_name(storage_path.split('/')[-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) + + 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 + + +async def list_materials(user_id: str) -> list[dict]: + """列出用户的所有素材""" + 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 = _extract_display_name(name) + 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 materials + except Exception as e: + logger.error(f"List materials failed: {e}") + return [] + + +async def delete_material(material_id: str, user_id: str) -> None: + """删除素材""" + if not material_id.startswith(f"{user_id}/"): + raise PermissionError("无权删除此素材") + await storage_service.delete_file( + bucket=storage_service.BUCKET_MATERIALS, + path=material_id + ) + + +async def rename_material(material_id: str, new_name_raw: str, user_id: str) -> dict: + """重命名素材,返回更新后的素材信息""" + if not material_id.startswith(f"{user_id}/"): + raise PermissionError("无权重命名此素材") + + new_name_raw = new_name_raw.strip() if new_name_raw else "" + if not new_name_raw: + raise ValueError("新名称不能为空") + + 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 ValueError("新名称无效") + + 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}" + + 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 = _extract_display_name(new_filename) + + return { + "id": new_path, + "name": display_name, + "path": signed_url, + } diff --git a/backend/app/modules/ref_audios/router.py b/backend/app/modules/ref_audios/router.py index 8f57129..c564d35 100644 --- a/backend/app/modules/ref_audios/router.py +++ b/backend/app/modules/ref_audios/router.py @@ -1,83 +1,14 @@ -""" -参考音频管理 API -支持上传/列表/删除参考音频,用于 Qwen3-TTS 声音克隆 -""" +"""参考音频管理 API""" 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 +from app.modules.ref_audios.schemas import RenameRequest +from app.modules.ref_audios import 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("") async def upload_ref_audio( @@ -85,156 +16,12 @@ async def upload_ref_audio( 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 + result = await service.upload_ref_audio(file, ref_text, user["id"]) + return success_response(result) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"上传参考音频失败: {e}") raise HTTPException(status_code=500, detail=f"上传失败: {str(e)}") @@ -243,81 +30,9 @@ async def upload_ref_audio( @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 文件 - wav_files = [f for f in files if f.get("name", "").endswith(".wav")] - - if not wav_files: - return success_response(RefAudioListResponse(items=[]).model_dump()) - - # 并发获取所有 metadata 和签名 URL - async def fetch_audio_info(f): - """获取单个音频的信息(metadata + signed URL)""" - name = f.get("name", "") - storage_path = f"{user_id}/{name}" - 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(timeout=5.0) 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.debug(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) - - return RefAudioResponse( - id=storage_path, - name=display_name, - path=signed_url, - ref_text=ref_text, - duration_sec=duration_sec, - created_at=created_at - ) - - # 使用 asyncio.gather 并发获取所有音频信息 - import asyncio - items = await asyncio.gather(*[fetch_audio_info(f) for f in wav_files]) - - # 按创建时间倒序排列 - items = sorted(items, key=lambda x: x.created_at, reverse=True) - - return success_response(RefAudioListResponse(items=items).model_dump()) - + result = await service.list_ref_audios(user["id"]) + return success_response(result) except Exception as e: logger.error(f"列出参考音频失败: {e}") raise HTTPException(status_code=500, detail=f"获取列表失败: {str(e)}") @@ -326,96 +41,30 @@ async def list_ref_audios(user: dict = Depends(get_current_user)): @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 可能不存在 - + await service.delete_ref_audio(audio_id, user["id"]) return success_response(message="删除成功") - + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) 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="重命名成功") - + result = await service.rename_ref_audio(audio_id, request.new_name, user["id"]) + return success_response(result, message="重命名成功") + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"重命名失败: {e}") raise HTTPException(status_code=500, detail=f"重命名失败: {str(e)}") diff --git a/backend/app/modules/ref_audios/schemas.py b/backend/app/modules/ref_audios/schemas.py new file mode 100644 index 0000000..1099183 --- /dev/null +++ b/backend/app/modules/ref_audios/schemas.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel +from typing import List + + +class RefAudioResponse(BaseModel): + id: str + name: str + path: str + ref_text: str + duration_sec: float + created_at: int + + +class RefAudioListResponse(BaseModel): + items: List[RefAudioResponse] + + +class RenameRequest(BaseModel): + new_name: str diff --git a/backend/app/modules/ref_audios/service.py b/backend/app/modules/ref_audios/service.py new file mode 100644 index 0000000..55a7a75 --- /dev/null +++ b/backend/app/modules/ref_audios/service.py @@ -0,0 +1,269 @@ +import re +import os +import time +import json +import asyncio +import subprocess +import tempfile +from pathlib import Path +from typing import Optional + +import httpx +from loguru import logger + +from app.services.storage import storage_service +from app.modules.ref_audios.schemas import RefAudioResponse, RefAudioListResponse + +ALLOWED_AUDIO_EXTENSIONS = {'.wav', '.mp3', '.m4a', '.webm', '.ogg', '.flac', '.aac'} +BUCKET_REF_AUDIOS = "ref-audios" + + +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', + '-ac', '1', + '-acodec', 'pcm_s16le', + output_path + ], capture_output=True, timeout=60, check=True) + return True + except Exception as e: + logger.error(f"音频转换失败: {e}") + return False + + +async def upload_ref_audio(file, ref_text: str, user_id: str) -> dict: + """上传参考音频:转码、获取时长、存储到 Supabase""" + if not file.filename: + raise ValueError("文件名无效") + filename = file.filename + + ext = Path(filename).suffix.lower() + if ext not in ALLOWED_AUDIO_EXTENSIONS: + raise ValueError(f"不支持的音频格式: {ext}。支持的格式: {', '.join(ALLOWED_AUDIO_EXTENSIONS)}") + + if not ref_text or len(ref_text.strip()) < 2: + raise ValueError("参考文字不能为空") + + # 创建临时文件 + with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp_input: + content = await file.read() + tmp_input.write(content) + tmp_input_path = tmp_input.name + + try: + # 转换为 WAV 格式 + tmp_wav_path = tmp_input_path + ".wav" + if not _convert_to_wav(tmp_input_path, tmp_wav_path): + raise RuntimeError("音频格式转换失败") + + # 获取音频时长 + duration = _get_audio_duration(tmp_wav_path) + if duration < 1.0: + raise ValueError("音频时长过短,至少需要 1 秒") + if duration > 60.0: + raise ValueError("音频时长过长,最多 60 秒") + + # 检查重名 + existing_files = await storage_service.list_files(BUCKET_REF_AUDIOS, user_id) + dup_count = 0 + search_suffix = f"_{filename}" + for f in existing_files: + fname = f.get('name', '') + if fname.endswith(search_suffix): + dup_count += 1 + + final_display_name = filename + if dup_count > 0: + name_stem = Path(filename).stem + name_ext = Path(filename).suffix + final_display_name = f"{name_stem}({dup_count}){name_ext}" + + # 生成存储路径 + timestamp = int(time.time()) + safe_name = sanitize_filename(Path(filename).stem) + storage_path = f"{user_id}/{timestamp}_{safe_name}.wav" + + # 上传 WAV 文件 + 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, + "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) + + return RefAudioResponse( + id=storage_path, + name=filename, + path=signed_url, + ref_text=ref_text.strip(), + duration_sec=duration, + created_at=timestamp + ).model_dump() + + finally: + os.unlink(tmp_input_path) + if os.path.exists(tmp_input_path + ".wav"): + os.unlink(tmp_input_path + ".wav") + + +async def list_ref_audios(user_id: str) -> dict: + """列出用户的所有参考音频""" + files = await storage_service.list_files(BUCKET_REF_AUDIOS, user_id) + wav_files = [f for f in files if f.get("name", "").endswith(".wav")] + + if not wav_files: + return RefAudioListResponse(items=[]).model_dump() + + async def fetch_audio_info(f): + name = f.get("name", "") + storage_path = f"{user_id}/{name}" + 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_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, metadata_path) + async with httpx.AsyncClient(timeout=5.0) 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.debug(f"读取 metadata 失败: {e}") + try: + created_at = int(name.split("_")[0]) + except: + pass + + 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: + match = re.match(r'^\d+_(.+)$', name) + if match: + display_name = match.group(1) + + return RefAudioResponse( + id=storage_path, + name=display_name, + path=signed_url, + ref_text=ref_text, + duration_sec=duration_sec, + created_at=created_at + ) + + items = await asyncio.gather(*[fetch_audio_info(f) for f in wav_files]) + items = sorted(items, key=lambda x: x.created_at, reverse=True) + + return RefAudioListResponse(items=items).model_dump() + + +async def delete_ref_audio(audio_id: str, user_id: str) -> None: + """删除参考音频及其元数据""" + if not audio_id.startswith(f"{user_id}/"): + raise PermissionError("无权删除此文件") + + await storage_service.delete_file(BUCKET_REF_AUDIOS, audio_id) + + metadata_path = audio_id.replace(".wav", ".json") + try: + await storage_service.delete_file(BUCKET_REF_AUDIOS, metadata_path) + except: + pass + + +async def rename_ref_audio(audio_id: str, new_name: str, user_id: str) -> dict: + """重命名参考音频(修改 metadata 中的 display name)""" + if not audio_id.startswith(f"{user_id}/"): + raise PermissionError("无权修改此文件") + + new_name = new_name.strip() + if not new_name: + raise ValueError("新名称不能为空") + + if not Path(new_name).suffix: + new_name += ".wav" + + # 下载现有 metadata + metadata_path = audio_id.replace(".wav", ".json") + try: + metadata_url = await storage_service.get_signed_url(BUCKET_REF_AUDIOS, metadata_path) + 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 + } + + # 更新并覆盖上传 + metadata["original_filename"] = new_name + 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 {"name": new_name} diff --git a/backend/app/modules/tools/router.py b/backend/app/modules/tools/router.py index 59d3de8..c45293e 100644 --- a/backend/app/modules/tools/router.py +++ b/backend/app/modules/tools/router.py @@ -1,417 +1,32 @@ 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 +from typing import Optional import traceback -import re -import json -import requests -from urllib.parse import unquote +from loguru import logger -from app.services.whisper_service import whisper_service -from app.services.glm_service import glm_service from app.core.response import success_response +from app.modules.tools import 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: - 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 + result = await service.extract_script(file=file, url=url, rewrite=rewrite) + return success_response(result) + except ValueError as e: + raise HTTPException(400, str(e)) + except HTTPException: + raise 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 绕过反爬 - """ - import httpx - - 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" - } - - # 如果是短链或重定向 - 使用异步 httpx - async with httpx.AsyncClient(follow_redirects=True, timeout=10.0) as client: - resp = await client.get(url, headers=headers) - final_url = str(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 (从环境变量 DOUYIN_COOKIE 读取) - from app.core.config import settings - if not settings.DOUYIN_COOKIE: - logger.warning("[SuperIPAgent] DOUYIN_COOKIE 未配置,视频下载可能失败") - - 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": settings.DOUYIN_COOKIE, - "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...") - - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get(target_url, headers=headers_with_cookie) - - # 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) - 使用异步 httpx - 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', - } - - async with httpx.AsyncClient(timeout=60.0) as client: - async with client.stream("GET", video_url, headers=download_headers) as dl_resp: - if dl_resp.status_code == 200: - with open(temp_path, 'wb') as f: - async for chunk in dl_resp.aiter_bytes(chunk_size=8192): - 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/tools/schemas.py b/backend/app/modules/tools/schemas.py new file mode 100644 index 0000000..5d93cb1 --- /dev/null +++ b/backend/app/modules/tools/schemas.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel +from typing import Optional + + +class ExtractScriptResponse(BaseModel): + original_script: Optional[str] = None + rewritten_script: Optional[str] = None diff --git a/backend/app/modules/tools/service.py b/backend/app/modules/tools/service.py new file mode 100644 index 0000000..e87ba33 --- /dev/null +++ b/backend/app/modules/tools/service.py @@ -0,0 +1,355 @@ +import asyncio +import os +import re +import json +import time +import shutil +import subprocess +import traceback +from pathlib import Path +from typing import Optional, Any +from urllib.parse import unquote + +import httpx +from loguru import logger + +from app.services.whisper_service import whisper_service +from app.services.glm_service import glm_service + + +async def extract_script(file=None, url: Optional[str] = None, rewrite: bool = True) -> dict: + """ + 文案提取:上传文件或视频链接 -> Whisper 转写 -> (可选) GLM 洗稿 + """ + if not file and not url: + raise ValueError("必须提供文件或视频链接") + + 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) + + loop = asyncio.get_event_loop() + + # 1. 获取/保存文件 + if file: + filename = file.filename + if not filename: + raise ValueError("文件名无效") + safe_filename = Path(filename).name.replace(" ", "_") + temp_path = temp_dir / f"tool_extract_{timestamp}_{safe_filename}" + 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: + temp_path = await _download_video(url, temp_dir, timestamp) + + if not temp_path or not temp_path.exists(): + raise ValueError("文件获取失败") + + # 1.5 安全转换: 强制转为 WAV (16k) + audio_path = temp_dir / f"extract_audio_{timestamp}.wav" + try: + await loop.run_in_executor(None, lambda: _convert_to_wav(temp_path, audio_path)) + logger.info(f"Converted to WAV: {audio_path}") + except ValueError as ve: + if str(ve) == "HTML_DETECTED": + raise ValueError("下载的文件是网页而非视频,请重试或手动上传。") + else: + raise ValueError("下载的文件已损坏或格式无法识别。") + + # 2. 提取文案 (Whisper) + script = await whisper_service.transcribe(str(audio_path)) + + # 3. AI 洗稿 (GLM) + rewritten = None + if rewrite and script and len(script.strip()) > 0: + logger.info("Rewriting script...") + rewritten = await glm_service.rewrite_script(script) + + return { + "original_script": script, + "rewritten_script": rewritten + } + + 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}") + + +def _convert_to_wav(input_path: Path, output_path: Path) -> None: + """FFmpeg 转换为 16k WAV""" + try: + convert_cmd = [ + 'ffmpeg', + '-i', str(input_path), + '-vn', + '-acodec', 'pcm_s16le', + '-ar', '16000', + '-ac', '1', + '-y', + str(output_path) + ] + subprocess.run(convert_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + 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}") + head = b"" + try: + with open(input_path, 'rb') as f: + head = f.read(100) + except: + pass + if b' Path: + """下载视频(yt-dlp 优先,失败回退手动解析)""" + url_value = url + 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}") + loop = asyncio.get_event_loop() + + # 先尝试 yt-dlp + try: + temp_path = await loop.run_in_executor(None, lambda: _download_yt_dlp(url_value, temp_dir, timestamp)) + logger.info(f"yt-dlp downloaded to: {temp_path}") + return temp_path + except Exception as e: + logger.warning(f"yt-dlp download failed: {e}. Trying manual fallback...") + + if "douyin" in url_value: + manual_path = await _download_douyin_manual(url_value, temp_dir, timestamp) + if manual_path: + return manual_path + raise ValueError(f"视频下载失败。yt-dlp 报错: {str(e)}") + elif "bilibili" in url_value: + manual_path = await _download_bilibili_manual(url_value, temp_dir, timestamp) + if manual_path: + return manual_path + raise ValueError(f"视频下载失败。yt-dlp 报错: {str(e)}") + else: + raise ValueError(f"视频下载失败: {str(e)}") + + +def _download_yt_dlp(url_value: str, temp_dir: Path, timestamp: int) -> Path: + """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) + + +async def _download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Optional[Path]: + """手动下载抖音视频 (Fallback)""" + logger.info(f"[SuperIPAgent] Starting download for: {url}") + + try: + 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" + } + + async with httpx.AsyncClient(follow_redirects=True, timeout=10.0) as client: + resp = await client.get(url, headers=headers) + final_url = str(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}") + + target_url = f"https://www.douyin.com/user/MS4wLjABAAAAN_s_hups7LD0N4qnrM3o2gI0vuG3pozNaEolz2_py3cHTTrpVr1Z4dukFD9SOlwY?from_tab_name=main&modal_id={modal_id}" + + from app.core.config import settings + if not settings.DOUYIN_COOKIE: + logger.warning("[SuperIPAgent] DOUYIN_COOKIE 未配置,视频下载可能失败") + + 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": settings.DOUYIN_COOKIE, + "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...") + + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(target_url, headers=headers_with_cookie) + + content_match = re.findall(r'', response.text) + if not content_match: + 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 + + video_url = None + try: + 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]}...") + + 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', + } + + async with httpx.AsyncClient(timeout=60.0) as client: + async with client.stream("GET", video_url, headers=download_headers) as dl_resp: + if dl_resp.status_code == 200: + with open(temp_path, 'wb') as f: + async for chunk in dl_resp.aiter_bytes(chunk_size=8192): + 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 视频 (Playwright Fallback)""" + 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() + browser = await playwright.chromium.launch(headless=True, args=['--no-sandbox', '--disable-setuid-sandbox']) + + 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() + + logger.info("[Playwright] Navigating to Bilibili...") + await page.goto(url, timeout=45000) + + try: + await page.wait_for_selector('video', timeout=15000) + except: + logger.warning("[Playwright] Video selector timeout") + + 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]}...") + + if not audio_url: + logger.warning("[Playwright] Could not find audio in __playinfo__") + return None + + temp_path = temp_dir / f"bilibili_audio_{timestamp}.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/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index b0db1e0..6f365fd 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,8 +1,8 @@ import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; -import { AuthProvider } from "@/contexts/AuthContext"; -import { TaskProvider } from "@/contexts/TaskContext"; +import { AuthProvider } from "@/shared/contexts/AuthContext"; +import { TaskProvider } from "@/shared/contexts/TaskContext"; import { Toaster } from "sonner"; diff --git a/frontend/src/components/AccountSettingsDropdown.tsx b/frontend/src/components/AccountSettingsDropdown.tsx index f834e30..3e74e5c 100644 --- a/frontend/src/components/AccountSettingsDropdown.tsx +++ b/frontend/src/components/AccountSettingsDropdown.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { useAuth } from "@/contexts/AuthContext"; +import { useAuth } from "@/shared/contexts/AuthContext"; import api from "@/shared/api/axios"; import { ApiResponse } from "@/shared/api/types"; diff --git a/frontend/src/components/GlobalTaskIndicator.tsx b/frontend/src/components/GlobalTaskIndicator.tsx index 6163329..844e1ca 100644 --- a/frontend/src/components/GlobalTaskIndicator.tsx +++ b/frontend/src/components/GlobalTaskIndicator.tsx @@ -1,6 +1,6 @@ "use client"; -import { useTask } from "@/contexts/TaskContext"; +import { useTask } from "@/shared/contexts/TaskContext"; import Link from "next/link"; import { usePathname } from "next/navigation"; diff --git a/frontend/src/features/home/model/useHomeController.ts b/frontend/src/features/home/model/useHomeController.ts index 7e85655..f53533a 100644 --- a/frontend/src/features/home/model/useHomeController.ts +++ b/frontend/src/features/home/model/useHomeController.ts @@ -11,8 +11,8 @@ import { } from "@/shared/lib/media"; import { clampTitle } from "@/shared/lib/title"; import { useTitleInput } from "@/shared/hooks/useTitleInput"; -import { useAuth } from "@/contexts/AuthContext"; -import { useTask } from "@/contexts/TaskContext"; +import { useAuth } from "@/shared/contexts/AuthContext"; +import { useTask } from "@/shared/contexts/TaskContext"; import { toast } from "sonner"; import { usePublishPrefetch } from "@/shared/hooks/usePublishPrefetch"; import { PublishAccount } from "@/shared/types/publish"; diff --git a/frontend/src/features/home/ui/HomePage.tsx b/frontend/src/features/home/ui/HomePage.tsx index 00e3627..de24151 100644 --- a/frontend/src/features/home/ui/HomePage.tsx +++ b/frontend/src/features/home/ui/HomePage.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import VideoPreviewModal from "@/components/VideoPreviewModal"; -import ScriptExtractionModal from "@/components/ScriptExtractionModal"; +import ScriptExtractionModal from "./ScriptExtractionModal"; import { useHomeController } from "@/features/home/model/useHomeController"; import { BgmPanel } from "@/features/home/ui/BgmPanel"; import { GenerateActionBar } from "@/features/home/ui/GenerateActionBar"; diff --git a/frontend/src/components/ScriptExtractionModal.tsx b/frontend/src/features/home/ui/ScriptExtractionModal.tsx similarity index 100% rename from frontend/src/components/ScriptExtractionModal.tsx rename to frontend/src/features/home/ui/ScriptExtractionModal.tsx diff --git a/frontend/src/components/script-extraction/useScriptExtraction.ts b/frontend/src/features/home/ui/script-extraction/useScriptExtraction.ts similarity index 100% rename from frontend/src/components/script-extraction/useScriptExtraction.ts rename to frontend/src/features/home/ui/script-extraction/useScriptExtraction.ts diff --git a/frontend/src/features/publish/model/usePublishController.ts b/frontend/src/features/publish/model/usePublishController.ts index bf7e4e0..f6b10ab 100644 --- a/frontend/src/features/publish/model/usePublishController.ts +++ b/frontend/src/features/publish/model/usePublishController.ts @@ -5,8 +5,8 @@ import { ApiResponse, unwrap } from "@/shared/api/types"; import { formatDate, getApiBaseUrl, isAbsoluteUrl, resolveMediaUrl } from "@/shared/lib/media"; import { clampTitle } from "@/shared/lib/title"; import { useTitleInput } from "@/shared/hooks/useTitleInput"; -import { useAuth } from "@/contexts/AuthContext"; -import { useTask } from "@/contexts/TaskContext"; +import { useAuth } from "@/shared/contexts/AuthContext"; +import { useTask } from "@/shared/contexts/TaskContext"; import { toast } from "sonner"; import { usePublishPrefetch } from "@/shared/hooks/usePublishPrefetch"; import { diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/shared/contexts/AuthContext.tsx similarity index 100% rename from frontend/src/contexts/AuthContext.tsx rename to frontend/src/shared/contexts/AuthContext.tsx diff --git a/frontend/src/contexts/TaskContext.tsx b/frontend/src/shared/contexts/TaskContext.tsx similarity index 100% rename from frontend/src/contexts/TaskContext.tsx rename to frontend/src/shared/contexts/TaskContext.tsx