diff --git a/.DS_Store b/.DS_Store index 42d321a..3ee4bb6 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/AGENTS.md b/AGENTS.md index 0be8434..ec68605 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,15 +2,15 @@ ## 项目概述 -美家卡智剪是一个 AI 驱动的**短视频剪辑**桌面应用,采用 **Tauri + React + FastAPI** 混合架构。本项目由「美家卡智影」Fork 而来,从"数字人生成"转向"AI 辅助长视频剪辑"方向:用户导入长视频素材,AI 根据脚本自动分镜切割,配合语音克隆/TTS 生成配音,最终合成带字幕的成品短视频。 +美家卡智剪是一个 AI 驱动的**短视频剪辑**桌面应用,采用 **Tauri + React + FastAPI** 混合架构。本项目由「美家卡智影」Fork 而来,从"数字人生成"转向"AI 辅助长视频剪辑"方向:用户导入长视频素材或提供口播文案,AI 自动分镜并生成配音,最终合成带字幕的成品短视频。 ### 核心功能流程(6 步) 1. **脚本生成** (Step 1) - AI 生成或粘贴口播文案,自动拆分为带预估时长的分镜 -2. **视频粗剪** (Step 2) - 导入长视频素材,按脚本时长自动切割为片段 -3. **语音配音** (Step 3) - AI 声音克隆(KlingAI)+ TTS 合成,为每段生成配音音频 +2. **音频合成** (Step 2) - AI 声音克隆(KlingAI / MiniMax / Vidu)+ TTS 合成,为每段生成配音音频 +3. **视频生成** (Step 3) - 导入长视频素材并按脚本时长自动切割为片段,或为空镜匹配素材视频 4. **字幕压制** (Step 4) - 基于音频自动对齐时间轴,ASS 字幕渲染并压制到视频 -5. **封面制作** (Step 5) - 提取视频首帧并叠加标题样式生成封面 +5. **封面制作** (Step 5) - 使用 Fabric.js 设计封面,提取视频首帧并叠加标题样式 6. **视频合成** (Step 6) - FFmpeg 拼接视频片段,替换原声为 TTS 音频,导出成品 ### 技术架构 @@ -18,8 +18,8 @@ - **前端桌面壳**: Tauri 2.x + React 19 + TypeScript 5.8 + Vite 7 - **后端服务**: FastAPI (Python 3.13+) + PostgreSQL 15 + Redis 7 - **任务调度**: 自定义 Async Engine(基于 Redis 槽位管理),**非 Celery** -- **本地处理**: 嵌入式 FFmpeg,Rust 层封装视频/音频处理命令 -- **AI 服务**: 火山方舟(LLM/字幕)、可灵 AI(TTS/声音克隆)、OpenAI 兼容接口 +- **本地处理**: 嵌入式 FFmpeg + ffprobe,Rust 层封装视频/音频处理命令 +- **AI 服务**: 火山方舟(LLM/字幕)、可灵 AI(TTS/声音克隆/视频生成)、MiniMax(TTS)、Vidu(TTS/对口型)、OpenAI 兼容接口 ## 项目结构 @@ -27,19 +27,19 @@ meijiaka-zj/ ├── python-api/ # FastAPI 后端服务(AI 代理 + 认证 + 任务调度) │ ├── app/ -│ │ ├── api/v1/ # API 路由: auth, system, caption, voice +│ │ ├── api/v1/ # API 路由: auth, system, tasks, script, caption, voice, upload, vidu │ │ ├── ai/ # AI 模型路由、Provider、提示词模板 │ │ ├── core/ # 安全、配置加载、Token管理器、Redis客户端、异常处理 │ │ ├── crud/ # 数据访问层(users, avatar) │ │ ├── db/ # 数据库配置(PostgreSQL + asyncpg + SQLAlchemy 2.0) │ │ ├── models/ # SQLAlchemy 模型(当前仅 users) │ │ ├── schemas/ # Pydantic 校验模型 -│ │ ├── services/ # AI 服务代理、字幕/视频/TTS/声音克隆服务 +│ │ ├── services/ # AI 服务代理、字幕/视频/TTS/声音克隆/ASS生成服务 │ │ ├── scheduler/ # Async Engine 异步任务调度 │ │ ├── config.py # Pydantic Settings 配置管理 │ │ └── main.py # FastAPI 入口(含生命周期管理) │ ├── config/ # AI 模型配置文件(ai_models.yaml),支持热重载 -│ ├── alembic/ # 数据库迁移(当前无迁移文件) +│ ├── alembic/ # 数据库迁移(当前无迁移文件,dev 模式自动建表) │ ├── tests/ # 测试目录(当前仅 test_kling_tts.py) │ ├── pyproject.toml # Python 依赖和工具配置 │ ├── requirements.lock # uv 锁定依赖版本(如缺失需执行 make update-lock) @@ -55,8 +55,7 @@ meijiaka-zj/ │ │ │ ├── generated/ # OpenAPI 自动生成类型(只读) │ │ │ ├── modules/ # API 模块封装(HTTP + IPC) │ │ │ ├── client.ts # HTTP 客户端(自动 camelCase↔snake_case) -│ │ │ ├── types.ts # 手写核心类型 -│ │ │ └── ipc.ts # Tauri IPC 调用封装 +│ │ │ └── types.ts # 手写核心类型 │ │ ├── components/ # 可复用组件 │ │ ├── pages/ # 页面组件 │ │ │ ├── VideoCreation/ # 6 步视频创作流程 @@ -71,23 +70,50 @@ meijiaka-zj/ │ ├── src-tauri/ # Rust 后端源码 │ │ ├── src/ │ │ │ ├── lib.rs # Tauri 应用入口,命令注册 +│ │ │ ├── main.rs # 二进制入口 │ │ │ ├── ffmpeg_cmd.rs # FFmpeg 命令封装 │ │ │ ├── video_processing.rs # 视频合成业务逻辑 -│ │ │ ├── storage/ # 本地存储引擎(原子写入、文件锁、路径净化) -│ │ │ ├── commands/ # IPC 命令按领域拆分 │ │ │ ├── api_proxy.rs # Python API 代理转发 -│ │ │ └── utils.rs # 通用工具函数 +│ │ │ ├── auth.rs # 认证命令 +│ │ │ ├── avatar_cache.rs # 头像视频缓存管理 +│ │ │ ├── utils.rs # 通用工具函数 +│ │ │ ├── commands/ # IPC 命令按领域拆分 +│ │ │ │ ├── mod.rs +│ │ │ │ ├── asset.rs +│ │ │ │ ├── auth_state.rs +│ │ │ │ ├── avatar.rs +│ │ │ │ ├── avatar_material.rs +│ │ │ │ ├── product.rs +│ │ │ │ ├── project.rs +│ │ │ │ ├── video_compose.rs +│ │ │ │ └── voice.rs +│ │ │ └── storage/ # 本地存储引擎 +│ │ │ ├── mod.rs +│ │ │ ├── engine.rs # 原子写入、文件锁、路径净化 +│ │ │ ├── paths.rs # 集中化路径计算 +│ │ │ ├── project.rs +│ │ │ ├── auth.rs +│ │ │ ├── avatar.rs +│ │ │ ├── avatar_material.rs +│ │ │ ├── voice.rs +│ │ │ └── cache.rs │ │ ├── Cargo.toml │ │ ├── tauri.conf.json -│ │ └── binaries/ # 嵌入式 FFmpeg +│ │ ├── binaries/ # 嵌入式 FFmpeg / ffprobe +│ │ └── fonts/ # 封面字体(DouyinSansBold.ttf) │ ├── package.json │ ├── vite.config.ts │ ├── tsconfig.json -│ └── eslint.config.js +│ ├── vitest.config.ts +│ ├── eslint.config.js +│ ├── .prettierrc +│ └── .stylelintrc.json │ -├── docs/ # 项目文档 +├── docs/ # 项目文档(设计文档、API 参考、实施计划) ├── scripts/ # 数据修复/迁移脚本(非部署脚本) ├── package.json # 根级 package.json(monorepo 占位) +├── .python-version # 3.13 +├── CLAUDE.md # 开发者速查手册 └── AGENTS.md # 本文件 ``` @@ -105,9 +131,10 @@ meijiaka-zj/ | ORM | SQLAlchemy | 2.0 (异步) | 数据模型 | | 缓存/调度 | Redis + Async Engine | 5.2+ / 自定义 | 异步任务槽位调度 | | AI SDK | OpenAI / volcengine | 1.58+ / 5.0+ | LLM / 字幕调用 | -| TTS/语音 | KlingAI API | - | 语音合成、声音克隆 | +| TTS/语音 | KlingAI / MiniMax / Vidu | - | 语音合成、声音克隆 | | 认证 | python-jose + passlib | 3.4+ / 1.7+ | JWT 认证 | | HTTP 客户端 | httpx + aiohttp | 0.28+ / 3.13+ | 异步 HTTP | +| 对象存储 | qiniu | 7.13+ | 七牛云 Kodo | | 包管理/构建 | uv | - | 虚拟环境、依赖锁定、Docker 构建 | **后端架构说明**: @@ -115,7 +142,7 @@ meijiaka-zj/ - 云端仅存储:用户账户信息 - 业务数据(项目/脚本/媒体/成品)全部本地存储 - 任务调度使用**自定义 Async Engine**(基于 Redis 的槽位管理),**非 Celery** -- 当前活跃 API 模块仅 4 个:`auth`, `system`, `caption`, `voice` +- 当前活跃 API 模块:**8 个**:`auth`, `system`, `tasks`, `script`, `caption`, `voice`, `upload`, `vidu` - 调度器已注册 7 个 Handler:`Video`, `Avatar`, `Image`, `Subtitle`, `Copy`, `Script`, `TTS` ### 前端 (tauri-app) @@ -124,7 +151,7 @@ meijiaka-zj/ |------|------|------|------| | 桌面框架 | Tauri | 2.x | 桌面应用壳 | | UI 框架 | React | 19.1+ | 用户界面 | -| 路由 | 自定义 NavigationContext | - | 页面切换(react-router-dom 已安装但未用于主流程) | +| 路由 | 自定义 NavigationContext | - | 页面切换(react-router-dom 已安装但主流程未使用) | | 状态管理 | Zustand | 5.x | 全局状态 + Immer 中间件 | | 数据获取 | SWR | 2.x | 请求缓存 | | 虚拟列表 | @tanstack/react-virtual | 3.x | 大数据列表渲染 | @@ -132,23 +159,30 @@ meijiaka-zj/ | 测试 | Vitest + @testing-library | 4.x | 单元测试 | | 类型生成 | openapi-typescript | 7.x | 从 OpenAPI 生成 TS 类型 | | 字幕渲染 | assjs | 0.1.x | ASS 字幕前端渲染 | +| 封面设计 | fabric | 6.x | Canvas 封面编辑 | ### Rust 后端 (src-tauri/src) | 模块 | 用途 | |------|------| -| lib.rs | Tauri 应用入口,命令注册(含 FFmpeg / 音频处理 / 视频合成命令) | -| ffmpeg_cmd.rs | FFmpeg 命令封装(首帧提取、字幕压制、封面合成、音频替换/混音/标准化) | -| video_processing.rs | 视频合成业务逻辑 | -| storage/engine.rs | 本地存储引擎(原子写入、文件锁、路径净化) | -| storage/paths.rs | 集中化路径计算 | -| commands/project.rs | 项目本地存储 IPC 命令 | -| commands/asset.rs | 资源文件保存 IPC 命令 | -| commands/auth_state.rs | 认证状态文件持久化 | -| commands/voice.rs | 音频文件保存/列表/删除 IPC 命令 | -| commands/product.rs | 成品视频保存/列表/删除/重命名 IPC 命令 | -| api_proxy.rs | Python API 代理转发 | -| avatar_cache.rs | 头像视频缓存管理 | +| `lib.rs` | Tauri 应用入口,命令注册,全局错误处理 | +| `main.rs` | 二进制入口 | +| `ffmpeg_cmd.rs` | FFmpeg 命令封装(首帧提取、字幕压制、封面合成、音频替换/混音/标准化) | +| `video_processing.rs` | 视频合成业务逻辑 | +| `api_proxy.rs` | Python API HTTP 代理转发 | +| `auth.rs` | 认证相关 IPC 命令 | +| `avatar_cache.rs` | 头像视频缓存管理、海报生成 | +| `utils.rs` | 项目目录辅助函数 | +| `storage/engine.rs` | 本地存储引擎(原子写入、文件锁、路径净化) | +| `storage/paths.rs` | 集中化路径计算(`~/Documents/Meijiaka-zj/` 为根目录) | +| `commands/project.rs` | 项目本地存储 IPC 命令 | +| `commands/asset.rs` | 资源文件保存 IPC 命令 | +| `commands/auth_state.rs` | 认证状态文件持久化 | +| `commands/voice.rs` | 音频文件保存/列表/删除 IPC 命令 | +| `commands/product.rs` | 成品视频保存/列表/删除/重命名 IPC 命令 | +| `commands/avatar.rs` | 头像列表本地存储 | +| `commands/avatar_material.rs` | 人物出镜素材 CRUD | +| `commands/video_compose.rs` | 视频拼接、文件上传/下载 | ## 开发环境搭建 @@ -169,7 +203,7 @@ docker-compose up -d db redis # 安装依赖(使用 uv) uv pip install -e ".[dev]" -# 启动开发服务器(注意:Docker API 会占用 8080 端口) +# 启动开发服务器(Docker API 会占用 8081 端口) uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 # 另开终端启动 Async Engine Scheduler(必须同时启动,否则任务不会执行) @@ -177,15 +211,14 @@ python -m app.scheduler.main ``` 后端服务地址: -- API: http://localhost:8080/api/v1 -- 文档: http://localhost:8080/docs -- 健康检查: http://localhost:8080/health +- API: http://localhost:8081/api/v1(Docker 映射端口) +- 文档: http://localhost:8081/docs +- 健康检查: http://localhost:8081/health -**Docker Compose 服务组成**(4 个服务): -- `db`: PostgreSQL 15 -- `redis`: Redis 7 -- `api`: FastAPI 开发服务器(端口 8080→8000) +**Docker Compose 服务组成**(2 个服务 + 外部网络依赖): +- `api`: FastAPI 开发服务器(端口 **8081**→8000) - `scheduler`: Async Engine 统一调度器,处理所有第三方异步任务 +- 依赖外部网络 `meijiaka-network` 和外部 PostgreSQL/Redis 服务 ### 2. 启动 Tauri 前端 @@ -293,11 +326,15 @@ npm run gen:api # 从 OpenAPI 生成 TypeScript 类型 - `replace_audio_track` // 音频替换 - `mix_audio_tracks` // 音频混音 - `standardize_audio` // 音频标准化 +- `compose_video` // 视频拼接(Phase 2) - `save_project_meta*` / `load_project_meta*` // 本地文件系统 - `save_project_segments*` / `load_project_segments*` - `save_project_asset` / `get_video_save_path` / `get_image_save_path` - `save_final_product` / `list_local_products` / `delete_local_product` / `rename_local_product` - `save_audio` / `list_project_audios` / `delete_audio` +- `load_voice_materials` / `save_voice_material` / `delete_voice_material_cmd` +- `load_avatar_materials` / `upload_avatar_material` / `rename_avatar_material` / `delete_avatar_material` +- `query_avatar_cache` / `cache_avatar_video` / `save_avatar_poster` - 头像缓存相关 API **添加新 API 流程**: @@ -311,19 +348,28 @@ npm run gen:api # 从 OpenAPI 生成 TypeScript 类型 ``` app/ai/ -├── model_router.py # 模型路由器(自动降级) +├── model_router.py # 模型路由器(自动降级、YAML 配置热重载) ├── providers/ │ ├── base.py # Provider 抽象基类 -│ ├── generic_llm_provider.py # 通用 OpenAI 兼容 Provider +│ ├── generic_llm_provider.py # 通用 OpenAI 兼容 Provider(含 Mock) │ ├── volcengine_provider.py # 火山方舟官方 SDK -│ └── klingai_provider.py # KlingAI(可灵 AI) +│ ├── klingai_provider.py # KlingAI(可灵 AI) +│ ├── minimax_provider.py # MiniMax +│ └── vidu_provider.py # Vidu └── prompts/ # 提示词模板(禁止硬编码) + ├── loader.py + ├── system/ # 系统提示词(按 category/subcategory 组织) + ├── user/ # 用户提示词模板 + ├── polish/ # 润色提示词 + └── cover/ # 封面生成提示词 ``` 支持的 AI 平台: - **火山方舟** (字节跳动) - LLM、字幕服务 - **OpenAI** - GPT 系列(可选) -- **可灵 AI** (快手) - TTS 语音合成、声音克隆 +- **可灵 AI** (快手) - TTS 语音合成、声音克隆、视频/图片生成 +- **MiniMax** - TTS 语音合成、声音克隆、视频生成 +- **Vidu** - TTS 语音合成、声音克隆、对口型(lip-sync) - **文心一言** (百度) - 可选 - **通义千问** (阿里云) - 可选 @@ -353,7 +399,7 @@ API (POST /tasks/{type}) → Redis JobRegistry → AsyncEngine tick loop → Han | ScriptHandler | 10 | `script:slots` | LLM 脚本生成(含 AnyToCopy 视频文案提取) | | SubtitleHandler | 5 | `volc:subtitle_slots` | 火山引擎字幕/自动对齐 | | CopyHandler | 5 | `anytocopy:slots` | AnyToCopy 视频文案提取 | -| TTSHandler | - | `kling:tts_slots` | Kling TTS 语音合成 | +| TTSHandler | 10 | `kling:tts_slots` | Kling TTS 语音合成 | | AvatarHandler | 2 | `kling:avatar_slots` | Kling 形象克隆 | ### TokenManager(API 认证 Token 管理) @@ -394,10 +440,13 @@ Rust 层实现了 defense-in-depth 的本地存储系统:`src-tauri/src/storag - `with_file_lock()` — 通过 `fs2` 实现独占文件锁 - `read_json()` — 安全读取,文件不存在返回 `None` - **`paths.rs`**: 集中路径计算 - - `~/Documents/Meijiaka/projects/{id}/` (meta.json, segments.json, assets/) - - `~/Documents/Meijiaka/products/` - - `~/Documents/Meijiaka/avatars.json` + - `~/Documents/Meijiaka-zj/projects/{id}/` (meta.json, segments.json, assets/, videos/, images/) + - `~/Documents/Meijiaka-zj/products/` + - `~/Documents/Meijiaka-zj/avatars.json` + - `~/Documents/Meijiaka-zj/voices.json` + - `~/Documents/Meijiaka-zj/avatar_materials/` - `{app_config_dir}/auth.json` + - `{app_data_dir}/avatars/` (缓存) **所有本地 JSON 读写必须经过 StorageEngine,禁止在命令处理器中直接调用 `fs::write`**。 @@ -412,9 +461,9 @@ users -- 用户账户信息(mobile, nickname, avatar_url) > 注:`avatars` 和 `model_usage_logs` 相关模型及 CRUD 在最近的重构中已移除或清理。`crud/avatar.py` 仍存在但可能处于未使用状态。 **业务数据本地存储**: -- 项目/脚本/分镜 → 前端本地 JSON 文件(`~/Documents/Meijiaka/projects/`) +- 项目/脚本/分镜 → 前端本地 JSON 文件(`~/Documents/Meijiaka-zj/projects/`) - 音频/视频/图片文件 → 本地磁盘 -- 成品视频 → `~/Documents/Meijiaka/products/` +- 成品视频 → `~/Documents/Meijiaka-zj/products/` - 用户配置 → localStorage(少量 UI 状态) ### 数据流规范 @@ -430,31 +479,32 @@ users -- 用户账户信息(mobile, nickname, avatar_url) ### 本地存储结构 ``` -~/Documents/Meijiaka/ # 用户文档目录 +~/Documents/Meijiaka-zj/ # 用户文档目录 ├── config.json # 全局配置 ├── projects/ # 项目数据 │ └── {project_id}/ │ ├── meta.json # 项目元数据 │ ├── segments.json # 分镜数据 -│ ├── media/ # 导入的原始素材 -│ ├── shots/ # 自动切割后的片段 -│ ├── audio/ # TTS 生成的音频 -│ └── assets/ # 资源文件(封面、成品等) +│ ├── assets/ # 资源文件(封面、成品等) +│ ├── videos/ # 导入的原始素材 / 切割后的片段 +│ └── images/ # 图片素材 ├── products/ # 成品视频目录 ├── avatars.json # 形象列表(本地) -└── cache/ # 缓存目录 +├── voices.json # 私有音色素材库 +├── avatar_materials/ # 人物出镜视频素材 +└── avatar_materials_index.json # 人物出镜素材索引 ``` 项目元数据 `meta.json` 关键字段: - `id`, `title`, `topic`, `status` (draft | published) -- `currentStep`: 1=脚本生成, 2=视频粗剪, 3=语音配音, 4=字幕压制, 5=封面制作, 6=视频合成 +- `currentStep`: 1=脚本生成, 2=音频合成, 3=视频生成, 4=字幕压制, 5=封面制作, 6=视频合成 - `createdAt`, `updatedAt`, `exportedAt` - `coverPath`, `finalVideoPath` - `coverConfig`, `scriptDuration`, `scriptType` 分镜数据 `segments.json` 字段: - `id`, `type` (segment | empty_shot), `scene`, `voiceover`, `duration` -- `videoPath`, `videoUrl`, `elementId`, `voiceId` +- `videoPath`, `videoUrl`, `humanId`, `voiceId` - `alignmentResult`, `burnedVideoPath`, `burnedAt` ### 前端导航 @@ -463,9 +513,11 @@ users -- 用户账户信息(mobile, nickname, avatar_url) 页面结构: - `video-creation` - 6 步视频创作流程 +- `voice-material` - 音色素材库 - `avatar-clone` - 形象/声音克隆管理 - `my-works` - 我的作品 - `profile` - 个人中心 +- `usage-detail` - 用量详情 - `settings-*` - 设置相关页面 ### 状态管理 @@ -480,15 +532,15 @@ users -- 用户账户信息(mobile, nickname, avatar_url) | `uiStore` | Toast 通知队列 | 无 | | `progressStore` | 全局进度模态框 | 无 | | `settingsStore` | 主题模式、用户偏好 | localStorage | -| `voiceStore` | 语音/形象选择状态 | 无 | +| `voiceStore` | 语音/形象选择状态、音色素材、头像素材 | 无 | -`projectStore` **不自动保存**。数据在显式过渡点持久化到磁盘(如进入 step 2、调用 `setFinalVideoPath` 时触发 `saveMetaToLocalFile`)。`saveMetaToLocalFile()` 通过 Promise 链串行化写入,避免并发覆盖。 +`projectStore` **不自动保存**。数据在显式过渡点持久化到磁盘(如离开 Step 1 时触发 `saveMetaToLocalFile`)。`saveMetaToLocalFile()` 通过 Promise 链串行化写入,避免并发覆盖。 ## 开发规范 ### 核心原则 -1. **后端环境优先使用 Docker Compose**: 开发时通过 `docker-compose up -d` 启动后端。前端默认连接 `http://127.0.0.1:8080/api/v1`。 +1. **后端环境优先使用 Docker Compose**: 开发时通过 `docker-compose up -d` 启动后端。前端默认连接 `http://127.0.0.1:8081/api/v1`。 2. **接口契约优先**: 后端承诺无论使用什么 AI 模型,输出永远符合同一个 Schema 3. **类型单一来源**: 后端 Schema 是权威,前端通过 OpenAPI 生成类型 4. **Adapter 层隔离**: 前后端字段差异只允许在 Adapter 层处理 @@ -557,7 +609,7 @@ Makefile 提供 `make lint-semantic` 进行自动化检查: API Layer (api/v1/*.py) ↓ 调用 Service Layer (services/*.py) - 可选,复杂业务 - ↓ 调用 + ↓ 调用 CRUD Layer (crud/*.py) ↓ 调用 Model Layer (models/*.py) @@ -717,6 +769,8 @@ VOLCENGINE_CAPTION_APPID=your-caption-appid VOLCENGINE_CAPTION_TOKEN=your-caption-token KLINGAI_ACCESS_KEY=your-kling-access-key KLINGAI_SECRET_KEY=your-kling-secret-key +MINIMAX_API_KEY=your-minimax-key +VIDU_API_KEY=your-vidu-key OPENAI_API_KEY=sk-your-openai-key # 七牛云存储 @@ -725,8 +779,12 @@ QINIU_SECRET_KEY=your-qiniu-secret-key QINIU_VIDEO_BUCKET=media-liche QINIU_IMAGE_BUCKET=img-liche +# AnyToCopy 文案提取 +ANYTOCOPY_API_KEY=your-anytocopy-key +ANYTOCOPY_API_SECRET=your-anytocopy-secret + # CORS 允许的前端地址 -CORS_ORIGINS=http://localhost:1420,http://127.0.0.1:1420,http://localhost:8080 +CORS_ORIGINS=http://localhost:1420,http://127.0.0.1:1420,http://localhost:8081 ``` ### Tauri 配置 (tauri.conf.json) @@ -740,7 +798,7 @@ CORS_ORIGINS=http://localhost:1420,http://127.0.0.1:1420,http://localhost:8080 "frontendDist": "../dist" }, "bundle": { - "externalBin": ["binaries/ffmpeg"], + "externalBin": ["binaries/ffmpeg", "binaries/ffprobe"], "resources": { "fonts/*": "fonts/" } @@ -752,17 +810,17 @@ CORS_ORIGINS=http://localhost:1420,http://127.0.0.1:1420,http://localhost:8080 模型配置文件支持热重载,无需重启服务即可更新模型配置。主要配置项: -- **platforms**: AI 平台配置(mock, volcengine, klingai) +- **platforms**: AI 平台配置(mock, volcengine, klingai, minimax, vidu) - **models**: 可用模型列表及其能力标签 [script, polish, chat, image, video_generation, image2video, lip_sync, image_generation] - **task_defaults**: 任务类型到模型的默认映射 ## 视频创作流程 1. **脚本生成** (Step 1) - AI 生成视频脚本和分镜,或用户直接粘贴文案 -2. **视频粗剪** (Step 2) - 导入长视频,按脚本时长自动切割为片段 -3. **语音配音** (Step 3) - 声音克隆 + TTS 生成每段配音 +2. **音频合成** (Step 2) - 声音克隆 + TTS 生成每段配音 +3. **视频生成** (Step 3) - 导入长视频并按脚本时长自动切割为片段 4. **字幕压制** (Step 4) - 生成字幕并压制到视频中 -5. **封面制作** (Step 5) - 生成视频封面 +5. **封面制作** (Step 5) - 使用 Fabric.js 设计视频封面 6. **视频合成** (Step 6) - FFmpeg 拼接视频片段,替换原声为 TTS 音频,导出最终视频 ## 常见问题 @@ -783,7 +841,7 @@ CORS_ORIGINS=http://localhost:1420,http://127.0.0.1:1420,http://localhost:8080 ### Q: FFmpeg 在哪里? Tauri 应用已嵌入 FFmpeg 二进制文件: -- 位置: `tauri-app/src-tauri/binaries/ffmpeg-*` +- 位置: `tauri-app/src-tauri/binaries/ffmpeg-*` 和 `ffprobe-*` - 使用: Rust 层通过 `ffmpeg_cmd` 模块调用 - 打包时会作为 `externalBin` 资源嵌入 @@ -799,7 +857,7 @@ Tauri 应用已嵌入 FFmpeg 二进制文件: ### Q: 项目数据是如何持久化的? -- 项目元数据(`meta.json`)和分镜数据(`segments.json`)保存在 `~/Documents/Meijiaka/projects/{project_id}/` +- 项目元数据(`meta.json`)和分镜数据(`segments.json`)保存在 `~/Documents/Meijiaka-zj/projects/{project_id}/` - 不通过 Zustand `persist` 保存项目数据,而是通过 `localProjectApi` 显式调用 Tauri IPC 写入文件 - `projectStore` 的 `persist` 中间件仅保存少量 UI 状态 @@ -811,12 +869,8 @@ Tauri 应用已嵌入 FFmpeg 二进制文件: - 每个 Handler 实现 `AsyncHandler` 接口,状态机驱动任务生命周期 - 优势:更细粒度的并发控制、统一状态机、无 Celery 依赖 -### Q: 为什么后端某些 API 模块不存在? - -当前项目处于从「美家卡智影」向「美家卡智剪」的重构过程中。`app/api/v1/router.py` 当前仅注册了 4 个模块(`auth`, `system`, `caption`, `voice`),历史上存在的 `script`, `video`, `klingai`, `qiniu`, `ai_models`, `tasks` 等路由当前未激活。如需使用相关功能,需在路由中重新引入。 - --- -**最后更新**: 2026-04-21 +**最后更新**: 2026-04-22 **架构模式**: 单机版(轻量云账号 + 全本地业务数据) **项目状态**: 从 ai-meijiaka Fork 后的活跃重构中 diff --git a/docs/cover-design-impl-plan-v2.md b/docs/cover-design-impl-plan-v2.md new file mode 100644 index 0000000..e0cf530 --- /dev/null +++ b/docs/cover-design-impl-plan-v2.md @@ -0,0 +1,667 @@ +# 封面制作功能实施方案(Fabric.js 版) + +## 一、兼容性说明 + +Fabric.js 与现有架构**零冲突**: +- 纯前端库,无需修改 Rust/后端 +- React 组件内直接使用,无兼容问题 +- 字体复用项目已有的 `DouyinSans`(嵌入字体) +- PNG 导出通过 Fabric.js `toDataURL()` 直接生成 + +--- + +## 二、技术方案 + +### 方案:Fabric.js Canvas 预览 + PNG 导出 + +| 维度 | Fabric.js 方案 | +|------|----------------| +| 预览 | Fabric Canvas 实时渲染,所见即所得 | +| 导出 | `canvas.toDataURL('image/png')` → Rust 保存 | +| 文字控制 | 固定位置,禁止拖拽(`selectable: false`) | +| 模板切换 | 重建 Fabric 对象,换文字样式/位置 | +| 背景图 | `fabric.Image.fromURL()` 加载,居中裁剪填充 | + +--- + +## 三、详细设计 + +### 3.1 安装依赖 + +```bash +cd tauri-app +npm install fabric@6 +``` + +### 3.2 两种固定模板 + +#### 模板1:双标题居中 + +``` +┌────────────────────────┐ +│ │ +│ 「主标题文案」 │ ← 72px,金色 #FFD700,粗体,y=35% +│ │ +│ 「副标题文案」 │ ← 32px,白色 #FFFFFF,y=60% +│ │ +│ │ +└────────────────────────┘ +``` + +**Fabric 对象结构**: +```typescript +[ + fabric.Image(bgImage), // 背景图,锁定 + fabric.Text(mainTitle, { // 主标题 + fontSize: 72, + fill: '#FFD700', + fontWeight: 'bold', + textAlign: 'center', + originX: 'center', + originY: 'center', + left: 540, top: 672, // 1080*0.35*1920/1080... 实际按坐标 + selectable: false, + shadow: new fabric.Shadow({...}) // 黑色描边 + }), + fabric.Text(subtitle, { // 副标题 + fontSize: 32, + fill: '#FFFFFF', + textAlign: 'center', + originX: 'center', + originY: 'center', + left: 540, top: 1152, + selectable: false, + shadow: new fabric.Shadow({...}) + }) +] +``` + +#### 模板2:标题 + 标签列表 + +``` +┌────────────────────────┐ +│ │ +│ 「主标题文案」 │ ← 72px,金色 #FFD700,y=30% +│ │ +│ │ +│ 标签1 标签2 标签3 │ ← 28px,白色,带圆角背景,y=72% +│ 标签4 标签5 │ +│ │ +└────────────────────────┘ +``` + +**Fabric 对象结构**: +```typescript +[ + fabric.Image(bgImage), // 背景图 + fabric.Text(mainTitle, {...}), // 主标题 + fabric.Group([ // 标签组(多个 fabric.Rect + fabric.Text) + fabric.Rect({fill: 'rgba(0,0,0,0.5)', rx: 8, ry: 8}), + fabric.Text('标签1', {fill: '#FFFFFF', fontSize: 28}), + ... + ]) +] +``` + +### 3.3 界面布局 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 封面制作 │ +├───────────────────────────────┬─────────────────────────────┤ +│ │ │ +│ [模板选择] │ │ +│ ┌────┐ ┌────┐ │ ┌─────────────┐ │ +│ │模板1│ │模板2│ │ │ │ │ +│ │缩略图│ │缩略图│ │ │ Fabric │ │ +│ └────┘ └────┘ │ │ Canvas │ │ +│ │ │ 预览 │ │ +│ [背景图] │ │ │ │ +│ ┌─────────┐ [选择图片] │ └─────────────┘ │ +│ │ 缩略图 │ [清除] │ │ +│ └─────────┘ │ │ +│ │ │ +│ [主标题] │ │ +│ ┌───────────────────────┐ │ │ +│ │ 输入主标题文案... │ │ │ +│ └───────────────────────┘ │ │ +│ │ │ +│ [副标题/标签] │ │ +│ ┌───────────────────────┐ │ │ +│ │ 输入副标题或标签... │ │ │ +│ └───────────────────────┘ │ │ +│ │ │ +│ [生成封面图] │ │ +│ │ │ +└───────────────────────────────┴─────────────────────────────┘ +``` + +--- + +## 四、核心数据结构 + +```typescript +// 模板类型 +type CoverTemplate = 'dual-title' | 'title-tags'; + +// 封面配置(保存到 store) +interface CoverConfig { + template: CoverTemplate; + backgroundImage: string | null; // 本地图片路径 + mainTitle: string; + subtitle: string; // 模板1=副标题,模板2=逗号分隔的标签 +} + +// Fabric.js 模板定义 +interface FabricTemplateDef { + name: CoverTemplate; + width: number; // 1080 + height: number; // 1920 + mainTitle: { + fontSize: number; + fill: string; + top: number; // y坐标 + fontWeight: string; + shadow?: fabric.Shadow; + }; + subtitle: { + fontSize: number; + fill: string; + top: number; + }; +} +``` + +--- + +## 五、核心 Hook 设计 + +### 5.1 useCoverFabric Hook + +**新文件**:`src/hooks/useCoverFabric.ts` + +```typescript +import { useRef, useCallback, useEffect } from 'react'; +import * as fabric from 'fabric'; + +const CANVAS_WIDTH = 1080; +const CANVAS_HEIGHT = 1920; + +export function useCoverFabric() { + const canvasRef = useRef(null); + const fabricCanvasRef = useRef(null); + + // 初始化 Fabric Canvas + const initCanvas = useCallback(() => { + if (!canvasRef.current || fabricCanvasRef.current) return; + + const canvas = new fabric.Canvas(canvasRef.current, { + width: CANVAS_WIDTH, + height: CANVAS_HEIGHT, + backgroundColor: '#1a1a2e', + selection: false, // 禁用框选 + interactive: false, // 禁用所有交互(只读预览) + }); + + fabricCanvasRef.current = canvas; + }, []); + + // 渲染封面 + const renderCover = useCallback(async ( + config: CoverConfig, + previewScale: number = 1 + ) => { + const canvas = fabricCanvasRef.current; + if (!canvas) return; + + canvas.clear(); + + // 1. 绘制背景图 + if (config.backgroundImage) { + await loadBackgroundImage(canvas, config.backgroundImage); + } + + // 2. 根据模板添加文字 + const template = TEMPLATES[config.template]; + + // 主标题 + const mainText = new fabric.Text(config.mainTitle, { + left: CANVAS_WIDTH / 2, + top: template.mainTitle.top, + fontSize: template.mainTitle.fontSize, + fill: template.mainTitle.fill, + fontWeight: template.mainTitle.fontWeight, + fontFamily: '"DouyinSans", "PingFang SC", sans-serif', + textAlign: 'center', + originX: 'center', + originY: 'center', + selectable: false, + shadow: new fabric.Shadow({ + color: 'rgba(0,0,0,0.8)', + blur: 4, + offsetX: 2, + offsetY: 2, + }), + }); + canvas.add(mainText); + + // 副标题/标签 + if (config.template === 'dual-title') { + const subText = new fabric.Text(config.subtitle, { + left: CANVAS_WIDTH / 2, + top: template.subtitle.top, + fontSize: template.subtitle.fontSize, + fill: template.subtitle.fill, + fontFamily: '"DouyinSans", "PingFang SC", sans-serif', + textAlign: 'center', + originX: 'center', + originY: 'center', + selectable: false, + shadow: new fabric.Shadow({ + color: 'rgba(0,0,0,0.6)', + blur: 3, + offsetX: 1, + offsetY: 1, + }), + }); + canvas.add(subText); + } else { + // 标签列表:解析逗号分隔,自动换行布局 + renderTagList(canvas, config.subtitle, template); + } + + canvas.renderAll(); + }, []); + + // 导出 PNG + const exportPng = useCallback((): string => { + const canvas = fabricCanvasRef.current; + if (!canvas) return ''; + return canvas.toDataURL({ format: 'png', quality: 1.0 }); + }, []); + + // 销毁 + const destroy = useCallback(() => { + fabricCanvasRef.current?.dispose(); + fabricCanvasRef.current = null; + }, []); + + return { canvasRef, initCanvas, renderCover, exportPng, destroy }; +} + +// 加载背景图(居中裁剪填充) +async function loadBackgroundImage( + canvas: fabric.Canvas, + imagePath: string +): Promise { + return new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + const fabricImg = new fabric.Image(img); + // 计算缩放和裁剪(cover 模式) + const scale = Math.max( + CANVAS_WIDTH / fabricImg.width!, + CANVAS_HEIGHT / fabricImg.height! + ); + fabricImg.scale(scale); + fabricImg.set({ + left: (CANVAS_WIDTH - fabricImg.getScaledWidth()) / 2, + top: (CANVAS_HEIGHT - fabricImg.getScaledHeight()) / 2, + selectable: false, + evented: false, + }); + canvas.add(fabricImg); + canvas.sendObjectToBack(fabricImg); + resolve(); + }; + img.src = `asset://localhost/${encodeURIComponent(imagePath)}`; + }); +} + +// 渲染标签列表 +function renderTagList( + canvas: fabric.Canvas, + tagsText: string, + template: FabricTemplateDef +) { + const tags = tagsText.split(/[,,]/).map(t => t.trim()).filter(Boolean); + if (tags.length === 0) return; + + const tagHeight = 56; + const tagPadding = 20; + const gapX = 16; + const gapY = 16; + const maxLineWidth = CANVAS_WIDTH - 120; // 左右各60px边距 + + const groups: fabric.Group[] = []; + let currentX = 60; + let currentY = template.subtitle.top; + + for (const tag of tags) { + const text = new fabric.Text(tag, { + fontSize: 28, + fill: '#FFFFFF', + fontFamily: '"DouyinSans", "PingFang SC", sans-serif', + left: tagPadding, + top: tagHeight / 2, + originX: 'center', + originY: 'center', + }); + + const rect = new fabric.Rect({ + width: text.width! + tagPadding * 2, + height: tagHeight, + fill: 'rgba(0,0,0,0.5)', + rx: 8, + ry: 8, + }); + + const group = new fabric.Group([rect, text], { + left: currentX, + top: currentY, + selectable: false, + evented: false, + }); + + // 换行检查 + if (currentX + group.width! > CANVAS_WIDTH - 60 && currentX > 60) { + currentX = 60; + currentY += tagHeight + gapY; + group.set({ left: currentX, top: currentY }); + } + + currentX += group.width! + gapX; + groups.push(group); + } + + // 整体居中 + const allTags = new fabric.Group(groups, { + left: CANVAS_WIDTH / 2, + top: template.subtitle.top, + originX: 'center', + originY: 'top', + selectable: false, + evented: false, + }); + + canvas.add(allTags); +} + +// 模板定义 +const TEMPLATES: Record = { + 'dual-title': { + name: 'dual-title', + width: 1080, + height: 1920, + mainTitle: { fontSize: 72, fill: '#FFD700', top: 672, fontWeight: 'bold' }, + subtitle: { fontSize: 32, fill: '#FFFFFF', top: 1152 }, + }, + 'title-tags': { + name: 'title-tags', + width: 1080, + height: 1920, + mainTitle: { fontSize: 72, fill: '#FFD700', top: 576, fontWeight: 'bold' }, + subtitle: { fontSize: 28, fill: '#FFFFFF', top: 1382 }, + }, +}; +``` + +--- + +## 六、文件改动清单 + +| 文件 | 类型 | 说明 | +|------|------|------| +| `package.json` | 修改 | 添加 `fabric@6` 依赖 | +| `CoverDesign.tsx` | 重构 | 使用 Fabric.js 替换现有实现 | +| `CoverDesign.css` | 调整 | 适配新布局,预览区固定尺寸 | +| `src/hooks/useCoverFabric.ts` | **新增** | Fabric.js 渲染 Hook | +| `src/store/projectStore.ts` | 调整 | 更新 CoverConfig 类型 | + +--- + +## 七、CoverDesign.tsx 关键代码 + +```typescript +import { open } from '@tauri-apps/plugin-dialog'; +import { useCoverFabric } from '../../hooks/useCoverFabric'; + +export default function CoverDesign() { + const { canvasRef, initCanvas, renderCover, exportPng, destroy } = useCoverFabric(); + const [config, setConfig] = useState({ + template: 'dual-title', + backgroundImage: null, + mainTitle: '', + subtitle: '', + }); + + // 初始化 Fabric Canvas + useEffect(() => { + initCanvas(); + return destroy; + }, [initCanvas, destroy]); + + // 配置变化时重新渲染 + useEffect(() => { + renderCover(config); + }, [config, renderCover]); + + // 选择图片 + const handleSelectImage = async () => { + const selected = await open({ + multiple: false, + filters: [{ name: '图片', extensions: ['jpg', 'jpeg', 'png', 'webp'] }] + }); + if (selected) { + setConfig(prev => ({ ...prev, backgroundImage: selected as string })); + } + }; + + // 生成封面 + const handleGenerate = async () => { + const dataUrl = exportPng(); + const base64 = dataUrl.split(',')[1]; + + const result = await invoke('save_project_asset', { + projectId, + filename: `cover_${Date.now()}.png`, + base64Data: base64, + }); + + if (result.code === 200) { + setCoverPath(result.data); + setCoverConfig(config); + } + }; + + return ( +
+ {/* 左侧配置 */} +
+ {/* 模板选择 */} +
+ +
+ + +
+
+ + {/* 背景图 */} +
+ +
+ {config.backgroundImage && ( + 背景 + )} + + {config.backgroundImage && ( + + )} +
+
+ + {/* 主标题 */} +
+ +