feat: 空镜素材配置后端化,视频生成流程重构

- 后端: 空镜素材迁移到 config/materials.json,duration从文件名_{N}s_自动解析
- 后端: 新增 POST /api/v1/materials/match 接口,后端做关键词匹配
- 前端: VideoGeneration 空镜匹配改为调用后端接口
- 前端: 人物出镜素材改为本地文件选择器直接选取,不走素材库
- 前端: 视频生成流程简化,移除Vidu对口型和七牛云上传
- Rust: 视频合成支持从随机起始时间截取人物素材片段
- Rust: 修复ffprobe参数错误(添加-show_entries format=duration)
This commit is contained in:
小鱼开发
2026-04-22 18:49:20 +08:00
parent 5154af777c
commit 4e06f4abe2
46 changed files with 6785 additions and 1396 deletions
Vendored
BIN
View File
Binary file not shown.
+129 -75
View File
@@ -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 + ffprobeRust 层封装视频/音频处理命令
- **AI 服务**: 火山方舟(LLM/字幕)、可灵 AI(TTS/声音克隆/视频生成)、MiniMaxTTS)、ViduTTS/对口型)、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.jsonmonorepo 占位)
├── .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/v1Docker 映射端口)
- 文档: 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 形象克隆 |
### TokenManagerAPI 认证 Token 管理)
@@ -394,10 +440,13 @@ Rust 层实现了 defense-in-depth 的本地存储系统:`src-tauri/src/storag
- `with_file_lock()` — 通过 `fs2` 实现独占文件锁
- `read_json<T>()` — 安全读取,文件不存在返回 `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 后的活跃重构中
+667
View File
@@ -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,白色 #FFFFFFy=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,金色 #FFD700y=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<HTMLCanvasElement>(null);
const fabricCanvasRef = useRef<fabric.Canvas | null>(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<void> {
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<CoverTemplate, FabricTemplateDef> = {
'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<CoverConfig>({
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 (
<div className="step-layout cover-design">
{/* 左侧配置 */}
<div className="step-panel-left">
{/* 模板选择 */}
<div className="panel-section">
<label className="panel-label"></label>
<div className="template-selector">
<button className={config.template === 'dual-title' ? 'active' : ''}
onClick={() => setConfig(p => ({ ...p, template: 'dual-title' }))}>
</button>
<button className={config.template === 'title-tags' ? 'active' : ''}
onClick={() => setConfig(p => ({ ...p, template: 'title-tags' }))}>
+
</button>
</div>
</div>
{/* 背景图 */}
<div className="panel-section">
<label className="panel-label"></label>
<div className="bg-image-control">
{config.backgroundImage && (
<img src={`asset://localhost/${encodeURIComponent(config.backgroundImage)}`}
className="bg-preview-thumb" alt="背景" />
)}
<button className="btn btn-secondary" onClick={handleSelectImage}>
</button>
{config.backgroundImage && (
<button className="btn btn-ghost" onClick={() => setConfig(p => ({ ...p, backgroundImage: null }))}>
</button>
)}
</div>
</div>
{/* 主标题 */}
<div className="panel-section">
<label className="panel-label"></label>
<textarea
className="input"
rows={2}
placeholder="输入主标题文案"
value={config.mainTitle}
onChange={e => setConfig(p => ({ ...p, mainTitle: e.target.value }))}
/>
</div>
{/* 副标题/标签 */}
<div className="panel-section">
<label className="panel-label">
{config.template === 'dual-title' ? '副标题' : '标签列表(用逗号分隔)'}
</label>
<textarea
className="input"
rows={2}
placeholder={config.template === 'dual-title' ? '输入副标题文案' : '标签1, 标签2, 标签3'}
value={config.subtitle}
onChange={e => setConfig(p => ({ ...p, subtitle: e.target.value }))}
/>
</div>
{/* 生成按钮 */}
<button className="btn btn-primary cover-generate-btn"
disabled={!config.mainTitle.trim()}
onClick={handleGenerate}>
</button>
</div>
{/* 右侧预览 */}
<div className="step-panel-right">
<div className="cover-preview-wrapper">
<canvas ref={canvasRef} className="cover-fabric-canvas" />
</div>
</div>
</div>
);
}
```
---
## 八、CSS 调整
```css
/* 预览区固定尺寸,CSS 缩放 */
.cover-preview-wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.cover-fabric-canvas {
width: 270px; /* 1080 / 4 */
height: 480px; /* 1920 / 4 */
background: #1a1a2e;
border-radius: var(--radius-xl);
box-shadow: 0 8px 32px rgb(0 0 0 / 30%);
}
/* 模板选择 */
.template-selector {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-sm);
}
.template-selector button {
padding: var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-card);
cursor: pointer;
transition: all var(--transition-fast);
}
.template-selector button.active {
border-color: var(--primary);
background: var(--primary-light);
color: var(--primary);
}
/* 背景图缩略图 */
.bg-preview-thumb {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: var(--radius-md);
border: 1px solid var(--border-light);
}
.bg-image-control {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
```
---
## 九、Store 类型更新
```typescript
// src/store/projectStore.ts
interface CoverConfig {
template?: 'dual-title' | 'title-tags';
backgroundImage?: string | null;
mainTitle?: string;
subtitle?: string;
// 保留旧字段兼容
caption?: string;
coverStyle?: any;
selectedPreset?: string;
}
```
---
## 十、实施步骤
| 步骤 | 任务 | 预计时间 |
|------|------|----------|
| 1 | `npm install fabric@6` | 5min |
| 2 | 创建 `useCoverFabric.ts` Hook | 2h |
| 3 | 重构 `CoverDesign.tsx` | 2h |
| 4 | 调整 `CoverDesign.css` | 30min |
| 5 | 更新 Store 类型 | 15min |
| 6 | 测试模板切换、文字渲染、导出 | 1h |
| 7 | 字体加载和高清导出调优 | 30min |
**总计**:约 6.5 小时
---
## 十一、注意事项
1. **字体加载**Fabric.js 的 `fontFamily` 需等待字体加载完成,否则首次渲染可能用回退字体
- 解决:`document.fonts.ready.then(() => canvas.renderAll())`
2. **Tauri asset 协议**:背景图路径用 `asset://localhost/${encodeURIComponent(path)}`
3. **Canvas 缩放**Fabric Canvas 保持 1080×1920,外层 CSS 缩放到 270×480,保证高清导出
4. **标签换行**:模板2的标签自动计算宽度,超出一行时换行,整体垂直居中
5. **图片跨域**:Tauri 本地文件无跨域问题,但需设置 `img.crossOrigin = 'anonymous'`
+350
View File
@@ -0,0 +1,350 @@
# 封面制作功能实施方案
## 一、需求概述
用户需求:
1. **背景图**:支持选择本地图片(弹窗选取),替换现有"视频首帧"方案
2. **标题样式**:主标题 + 副标题,位置和样式固定
3. **实时预览**:输入文案后立即预览效果
---
## 二、技术方案
### 方案选择:Canvas 实时预览 + PNG 导出
| 方案 | 优点 | 缺点 |
|------|------|------|
| ASS压制 | 字体渲染专业 | 预览需assjs,渲染有差异 |
| **Canvas预览+导出PNG** | 所见即所得,无差异 | 需额外处理字体 |
| Fabric.js | API丰富 | ~200KB包体积 |
**推荐方案:纯 Canvas 方案**
- 前端使用 Canvas API 绘制封面
- 预览和导出同一套代码,保证一致性
- 导出 PNG 后通过 Rust 复制到本地存储(或直接上传七牛云)
---
## 三、详细设计
### 3.1 封面模板
提供 **2 种固定模板**
#### 模板1:双标题居中
```
┌────────────────────────┐
│ │
│ 主标题(大) │ ← 72px,金色(#FFD700),粗体,y=30%
│ │
│ 副标题(小) │ ← 32px,白色,y=60%
│ │
│ │
└────────────────────────┘
```
#### 模板2:标题+标签列表
```
┌────────────────────────┐
│ │
│ 主标题(大) │ ← 72px,金色(#FFD700),粗体,y=25%
│ │
│ 标签1 标签2 标签3 │ ← 28px,白色,y=75%
│ 标签4 标签5 │
│ │
└────────────────────────┘
```
### 3.2 界面设计
```
┌─────────────────────────────────────────────────────────────┐
│ 封面制作 │
├───────────────────────────────┬─────────────────────────────┤
│ │ │
│ [模板选择] │ │
│ ○ 模板1: 双标题居中 │ ┌─────────────┐ │
│ ○ 模板2: 标题+标签列表 │ │ │ │
│ │ │ 预览区 │ │
│ [背景图] │ │ (Canvas) │ │
│ ┌─────────┐ [选择图片] │ │ │ │
│ │ 缩略图 │ [清除] │ └─────────────┘ │
│ └─────────┘ │ │
│ │ │
│ [主标题] │ │
│ ┌───────────────────────┐ │ │
│ │ 输入主标题文案... │ │ │
│ └───────────────────────┘ │ │
│ │ │
│ [副标题/标签] │ │
│ ┌───────────────────────┐ │ │
│ │ 输入副标题或标签... │ │ │
│ └───────────────────────┘ │ │
│ │ │
│ [生成封面图] │ │
│ │ │
└───────────────────────────────┴─────────────────────────────┘
```
### 3.3 数据结构
```typescript
interface CoverDesignConfig {
template: 'dual-title' | 'title-with-tags'; // 模板类型
background: string | null; // 背景图本地路径
mainTitle: {
text: string;
fontSize: number; // 固定72px
color: string; // 固定金色 #FFD700
fontWeight: 'bold';
position: { y: number }; // y轴百分比
};
subtitle: {
text: string;
fontSize: number; // 模板1=32px, 模板2=28px
color: string; // 固定白色 #FFFFFF
position: { y: number }; // y轴百分比
};
}
```
### 3.4 封面尺寸
- **导出尺寸**1080 × 1920 px9:16竖屏)
- **预览尺寸**270 × 480 pxCSS缩放)
- **背景适配**:使用 `object-fit: cover` 填充,超出部分裁剪
---
## 四、实现步骤
### Step 1: 添加图片选择功能
**文件**`CoverDesign.tsx`
```typescript
import { open } from '@tauri-apps/plugin-dialog';
const handleSelectImage = async () => {
const selected = await open({
multiple: false,
filters: [{ name: '图片', extensions: ['jpg', 'jpeg', 'png', 'webp'] }]
});
if (selected) {
setBackgroundImage(selected as string);
}
};
```
### Step 2: 创建 Canvas 渲染 Hook
**新文件**`src/hooks/useCoverCanvas.ts`
```typescript
export function useCoverCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawCover = useCallback((
ctx: CanvasRenderingContext2D,
config: CoverDesignConfig,
width: number,
height: number
) => {
// 1. 绘制背景
// 2. 绘制主标题
// 3. 绘制副标题
}, []);
const exportAsPng = useCallback(() => {
// 导出 1080x1920 PNG
}, []);
return { canvasRef, drawCover, exportAsPng };
}
```
### Step 3: 重构 CoverDesign 组件
**主要改动**
| 改动点 | 说明 |
|--------|------|
| 移除 `useLocalVideo` | 不再依赖视频首帧 |
| 新增背景图状态 | `backgroundImage: string \| null` |
| 替换预览区 | `<canvas>` 替代 `<video>` + CSS overlay |
| 简化样式预设 | 移除字体大小滑块,改用固定模板 |
| 简化生成逻辑 | Canvas 导出 PNG |
### Step 4: 修改生成导出逻辑
```typescript
const handleGenerate = async () => {
// 1. 获取 Canvas 导出的 PNG (Base64)
const pngDataUrl = canvasRef.current.toDataURL('image/png');
// 2. 转换为 Base64 字符串
const base64 = pngDataUrl.split(',')[1];
// 3. 通过 Rust 命令保存
const result = await invoke('save_project_asset', {
projectId,
filename: `cover_${Date.now()}.png`,
base64Data: base64,
});
};
```
### Step 5: 更新 Store 接口
**文件**`src/store/projectStore.ts`
```typescript
interface CoverConfig {
template?: 'dual-title' | 'title-with-tags';
caption?: string;
subtitle?: string;
coverStyle?: any;
selectedPreset?: string;
backgroundImage?: string | null; // 新增
}
```
---
## 五、文件改动清单
| 文件 | 改动类型 | 说明 |
|------|----------|------|
| `CoverDesign.tsx` | 重构 | 替换为 Canvas 预览方案 |
| `CoverDesign.css` | 调整 | 适配新的布局 |
| `src/hooks/useCoverCanvas.ts` | 新增 | Canvas 渲染 hook |
| `src/store/projectStore.ts` | 调整 | 添加 backgroundImage 字段 |
---
## 六、模板样式定义
### 模板1:双标题居中
```typescript
const TEMPLATE_DUAL_TITLE = {
mainTitle: {
fontSize: 72,
color: '#FFD700',
fontWeight: 'bold',
positionY: 0.30, // 30%
},
subtitle: {
fontSize: 32,
color: '#FFFFFF',
positionY: 0.55,
},
};
```
### 模板2:标题+标签列表
```typescript
const TEMPLATE_TITLE_TAGS = {
mainTitle: {
fontSize: 72,
color: '#FFD700',
fontWeight: 'bold',
positionY: 0.25,
},
subtitle: {
fontSize: 28,
color: '#FFFFFF',
positionY: 0.70,
// 标签自动换行,每行最多显示5个
},
};
```
---
## 七、Canvas 绘制细节
### 7.1 文字渲染
```typescript
ctx.font = `${fontWeight} ${fontSize}px "DouyinSans", "PingFang SC", sans-serif`;
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
```
### 7.2 描边效果
```typescript
ctx.strokeStyle = '#000000';
ctx.lineWidth = strokeWidth;
ctx.strokeText(text, x, y);
```
### 7.3 文字换行(副标题/标签)
```typescript
function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
const words = text.split('');
const lines: string[] = [];
let currentLine = '';
for (const char of words) {
const testLine = currentLine + char;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine) {
lines.push(currentLine);
currentLine = char;
} else {
currentLine = testLine;
}
}
if (currentLine) lines.push(currentLine);
return lines;
}
```
### 7.4 背景图绘制
```typescript
const img = new Image();
img.onload = () => {
// 计算裁剪区域(居中裁剪)
const scale = Math.max(width / img.width, height / img.height);
const scaledWidth = img.width * scale;
const scaledHeight = img.height * scale;
const dx = (width - scaledWidth) / 2;
const dy = (height - scaledHeight) / 2;
ctx.drawImage(img, dx, dy, scaledWidth, scaledHeight);
};
img.src = `asset://localhost/${encodeURIComponent(imagePath)}`;
```
---
## 八、进度安排
| 阶段 | 任务 | 预计时间 |
|------|------|----------|
| Phase 1 | 添加图片选择功能,基础布局调整 | 1h |
| Phase 2 | 实现 Canvas 渲染 Hook | 2h |
| Phase 3 | 重构预览区为 Canvas | 2h |
| Phase 4 | 实现模板切换和文字渲染 | 2h |
| Phase 5 | 导出 PNG 并保存 | 1h |
| Phase 6 | 测试和样式调优 | 1h |
**总计**:约 9 小时
---
## 九、注意事项
1. **字体加载**:确保字体文件已嵌入,Canvas 需等待字体加载完成后再绘制
2. **跨域问题**Tauri 的 `asset://` 协议需要正确配置
3. **高清导出**:导出时使用 `toBlob('image/png', 1.0)` 保证最高质量
4. **性能优化**:防抖处理输入,避免频繁重绘
5. **移动端适配**:预览区使用 CSS `transform: scale()` 缩放
+293
View File
@@ -0,0 +1,293 @@
# 空镜素材匹配实现方案 v2(已对齐实际素材)
> 状态:待审阅
---
## 一、需求概述
Step 3(视频生成)统一处理所有分镜:
- **segment(人物出镜)**:从本地素材库选择形象视频,裁剪拼接
- **empty_shot(空镜)**:从七牛云素材库匹配,裁剪拼接
- **统一合成**:FFmpeg 裁剪所有素材后按顺序拼接,混音输出,最终视频时长与音频匹配
---
## 二、素材体系
### 2.1 人物出镜素材(本地)
**存储位置**`~/Documents/Meijiaka-zj/avatar_materials/`
**索引**`~/Documents/Meijiaka-zj/avatar_materials_index.json`
```json
{
"materials": [
{
"id": "avt_001",
"name": "男主播正面",
"filename": "host_male.mp4",
"duration": 15.2,
"uploadedAt": "2026-04-22T10:00:00Z"
}
]
}
```
**时长获取**:上传后用 ffprobe 提取,存入索引。
### 2.2 空镜素材(七牛云)
**实际 URL 格式**
```
https://media.liche.cn/meijiaka-zj/material/{slug}/{slug}_{序号}_{时长}s.mp4
```
**示例**
- `https://media.liche.cn/meijiaka-zj/material/ceiling/ceiling_001_5s.mp4`
- `https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_153_4s.mp4`
- `https://media.liche.cn/meijiaka-zj/material/paint/paint_001_5s.mp4`
- `https://media.liche.cn/meijiaka-zj/material/final/final_001_7s.mp4`
**关键发现**:文件名自带时长(`_{N}s.mp4`),无需 ffprobe,直接解析。
**映射表**`tauri-app/src/constants/materialUrls.ts`
```typescript
export interface MaterialInfo {
url: string;
duration: number; // 从文件名解析,如 "5s" → 5
}
export const MATERIAL_URLS: Record<string, MaterialInfo[]> = {
"ceiling": [
{ url: "https://media.liche.cn/meijiaka-zj/material/ceiling/ceiling_001_5s.mp4", duration: 5 },
],
"plumbing": [
{ url: "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_153_4s.mp4", duration: 4 },
],
"paint": [
{ url: "https://media.liche.cn/meijiaka-zj/material/paint/paint_001_5s.mp4", duration: 5 },
],
"final": [
{ url: "https://media.liche.cn/meijiaka-zj/material/final/final_001_7s.mp4", duration: 7 },
],
};
export const KEYWORD_MAP: Record<string, string> = {
"吊顶": "ceiling",
"天花": "ceiling",
"水电": "plumbing",
"水管": "plumbing",
"油漆": "paint",
"涂料": "paint",
"刷漆": "paint",
"完工": "final",
"竣工": "final",
"交付": "final",
};
```
> 新增 slug/关键词随时补充。
---
## 三、匹配规则(含时长检查)
### 3.1 人物出镜素材选择
用户手动选择一个本地素材作为"当前形象"。
系统检查:
```
let maxSegmentDuration = max(所有 segment 分镜的 duration);
if 形象素材.duration < maxSegmentDuration:
警告:形象素材时长不足,最长 segment 需要 X 秒,当前素材只有 Y 秒
```
### 3.2 空镜素材匹配
```typescript
function matchMaterial(scene: string, requiredDuration: number): MaterialInfo | null {
for (const [keyword, slug] of Object.entries(KEYWORD_MAP)) {
if (scene.includes(keyword)) {
const candidates = MATERIAL_URLS[slug]?.filter(m => m.duration >= requiredDuration);
if (candidates && candidates.length > 0) {
return candidates[Math.floor(Math.random() * candidates.length)];
}
}
}
return null;
}
```
**匹配失败场景**
- 无关键词匹配 → "未找到对应素材"
- 素材时长不足 → "素材时长不足(需要 X 秒,素材只有 Y 秒)"
---
## 四、裁剪与合成
### 4.1 裁剪策略
| 场景 | 素材时长 | 分镜时长 | 处理方式 |
|------|---------|---------|---------|
| 等于 | 5s | 5s | 从头截取 0-5s |
| 大于 | 8s | 5s | 随机从 [0, 3s] 开始截取 5s |
| 小于 | 3s | 5s | **匹配阶段已过滤,不会出现** |
**FFmpeg 裁剪**
```bash
ffmpeg -ss {start} -t {duration} -i {input} -c:v libx264 -preset fast -an -y {output}
```
### 4.2 拼接与混音
```bash
# concat 列表文件
file 'clip_001.mp4'
file 'clip_002.mp4'
file 'clip_003.mp4'
# 拼接 + 混音
ffmpeg -f concat -safe 0 -i clips.txt -i audio.mp3 -c:v libx264 -c:a aac -shortest output.mp4
```
`-shortest`:以较短的一方为准(视频/音频)。
### 4.3 时长兜底
拼接前检查:
```
videoTotal = sum(所有分镜 duration)
audioTotal = 音频时长
if |videoTotal - audioTotal| >= 1秒:
提示用户:分镜总时长与音频不匹配,建议调整
else:
正常合成,-shortest 处理微小偏差
```
---
## 五、Step 3(视频生成)交互
### 5.1 页面结构
```
┌─────────────────────────────────────────┐
│ Step 3: 视频生成 │
│ │
│ ┌─ 人物形象选择 ──────────────────────┐ │
│ │ [上传形象视频] │ │
│ │ │ │
│ │ ● 男主播正面 (15.2s) ← 已选 │ │
│ │ │ │
│ │ ✓ 最长 segment 需 8s,素材满足 │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌─ 分镜素材匹配 ──────────────────────┐ │
│ │ │ │
│ │ 分镜 1:人物出镜 (3s) │ │
│ │ 形象素材:男主播正面 │ │
│ │ (15.2s,从 2.1s 截取 3s) │ │
│ │ │ │
│ │ 分镜 2:瓦工贴墙砖 (5s) │ │
│ │ [自动匹配] → ✓ ceiling_001 │ │
│ │ (5s,从头截取) │ │
│ │ │ │
│ │ 分镜 3:防水施工 (4s) │ │
│ │ [自动匹配] → ✗ 素材不足 │ │
│ │ (需要 4splumbing 只有 4s │ │
│ │ 如果刚好等于,也是满足的) │ │
│ │ [手动选择本地文件] │ │
│ │ │ │
│ │ 分镜 4:人物出镜 (3s) │ │
│ │ 形象素材:男主播正面 │ │
│ │ (15.2s,从 8.5s 截取 3s) │ │
│ └──────────────────────────────────────┘ │
│ │
│ [生成视频] │
│ │
│ 进度:裁剪中... 拼接中... 混音中... │
└─────────────────────────────────────────┘
```
### 5.2 状态流转
1. 进入 Step 3 → 自动为所有 empty_shot 执行 `matchMaterial`
2. 绿色:素材满足(duration >= 分镜时长)
3. 红色:素材不足或无匹配 → 可手动选择本地文件替代
4. 点击【生成视频】→ FFmpeg 批量裁剪 + 拼接 + 混音
---
## 六、数据模型
```typescript
interface Segment {
id: string;
type: "segment" | "empty_shot";
scene: string;
voiceover: string;
duration: number;
// 视频生成后填充
videoUrl?: string; // 空镜:七牛云 URLsegmentnull(用本地形象)
videoPath?: string; // 本地路径(形象素材或手动替代文件)
// 素材匹配信息(仅展示用)
materialInfo?: {
source: "auto" | "manual"; // 自动匹配 / 手动选择
url?: string; // 原始 URL
duration: number; // 素材总时长
clipStart: number; // 截取起始点
clipDuration: number; // 截取时长(= 分镜 duration
};
}
```
---
## 七、实施清单
### Phase 1:人物出镜素材库
| # | 文件 | 内容 |
|---|------|------|
| 1 | `src-tauri/src/commands/avatar_material.rs` | IPC:上传、列表、删除 |
| 2 | `src-tauri/src/storage/avatar_material.rs` | 索引读写、ffprobe 提取时长 |
| 3 | `src/api/modules/avatarMaterial.ts` | 前端 API 封装 |
| 4 | `VideoGeneration.tsx` | 形象上传 + 选择组件 |
### Phase 2:空镜素材匹配
| # | 文件 | 内容 |
|---|------|------|
| 5 | `src/constants/materialUrls.ts` | 映射表(已按实际 URL 格式) |
| 6 | `VideoGeneration.tsx` | 自动匹配逻辑 + 状态展示 |
### Phase 3:视频合成
| # | 文件 | 内容 |
|---|------|------|
| 7 | `src-tauri/src/ffmpeg_cmd.rs` | 新增:批量裁剪函数 |
| 8 | `src-tauri/src/video_processing.rs` | 新增:合成主流程(裁剪→拼接→混音) |
| 9 | `src-tauri/src/commands/video_generate.rs` | IPC:生成视频命令 |
| 10 | `VideoGeneration.tsx` | [生成视频] 按钮 + 进度 |
---
## 八、确认事项
- [ ] 人物出镜素材存在本地,支持上传/列表/删除
- [ ] 空镜素材走七牛云,URL 格式 `meijiaka-zj/material/{slug}/{slug}_{序号}_{时长}s.mp4`
- [ ] 时长从文件名解析,无需 ffprobe
- [ ] 匹配时过滤时长不足的素材
- [ ] 素材时长 > 分镜时长时,随机位置截取
- [ ] 合成用 FFmpeg concat + 音频混流
- [ ] 时长差值 >= 1 秒时提示用户
**确认后我开始写。**
+306
View File
@@ -0,0 +1,306 @@
# 视频生成实现方案 v3(Vidu 对口型版)
> 状态:待审阅
---
## 一、流程概述
| Step | 页面 | 产出 |
|------|------|------|
| 1 | ScriptCreation | 分镜列表(含 scene + duration |
| 2 | VoiceDubbing | 配音音频(已存七牛云) |
| **3** | **VideoGeneration** | **最终视频(Vidu 对口型后)** |
| 4 | SubtitleBurning | 字幕压制 |
| 5 | CoverDesign | 封面 |
| 6 | VideoComposite | 最终成品(如需要额外合成) |
---
## 二、Step 3 完整流程
```
┌─────────────────────────────────────────────────────────────┐
│ Step 3: 视频生成 │
│ │
│ 1. 选择形象素材(本地 avatar_materials
│ └─ 上传 / 选择已有素材 │
│ │
│ 2. 匹配空镜素材(七牛云) │
│ └─ 根据 scene 关键词匹配 │
│ └─ 检查时长 >= 分镜 duration │
│ │
│ 3. FFmpeg 裁剪 │
│ └─ segment:从形象素材中截取 duration 秒 │
│ └─ empty_shot:从空镜素材中截取 duration 秒 │
│ └─ 输出:clip_001.mp4, clip_002.mp4, ... │
│ │
│ 4. FFmpeg 拼接(移除音频) │
│ └─ concat 所有 clip → raw_video.mp4(无声) │
│ │
│ 5. 上传临时视频 │
│ └─ raw_video.mp4 → 七牛云临时 URL │
│ │
│ 6. Vidu 对口型 │
│ └─ POST /ent/v2/lip-sync │
│ { video_url: 临时URL, audio_url: Step2音频URL } │
│ └─ 返回 task_id │
│ │
│ 7. 轮询等待 │
│ └─ GET /ent/v2/tasks/{task_id}/creations │
│ └─ state = "success" 时获取 video_url │
│ │
│ 8. 下载最终视频 │
│ └─ 下载到本地 products/{project_id}/ │
│ │
│ 9. 上传永久保存 │
│ └─ 上传七牛云 videos/{project_id}/final.mp4 │
│ │
│ 10. 更新项目状态 │
│ └─ segment.videoPath = 本地路径 │
│ └─ segment.videoUrl = 七牛云永久 URL │
└─────────────────────────────────────────────────────────────┘
```
---
## 三、素材体系
### 3.1 人物出镜素材(本地)
**存储位置**`~/Documents/Meijiaka-zj/avatar_materials/`
**索引**`~/Documents/Meijiaka-zj/avatar_materials_index.json`
```json
{
"materials": [
{
"id": "avt_001",
"name": "男主播正面",
"filename": "host_male.mp4",
"duration": 15.2,
"uploadedAt": "2026-04-22T10:00:00Z"
}
]
}
```
**时长获取**:上传后用 ffprobe 提取。
### 3.2 空镜素材(七牛云)
**URL 格式**`https://media.liche.cn/meijiaka-zj/material/{slug}/{slug}_{序号}_{时长}s.mp4`
**映射表**`tauri-app/src/constants/materialUrls.ts`
```typescript
export interface MaterialInfo {
url: string;
duration: number;
}
export const MATERIAL_URLS: Record<string, MaterialInfo[]> = {
"ceiling": [
{ url: "https://media.liche.cn/meijiaka-zj/material/ceiling/ceiling_001_5s.mp4", duration: 5 },
],
"plumbing": [
{ url: "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_153_4s.mp4", duration: 4 },
],
"paint": [
{ url: "https://media.liche.cn/meijiaka-zj/material/paint/paint_001_5s.mp4", duration: 5 },
],
"final": [
{ url: "https://media.liche.cn/meijiaka-zj/material/final/final_001_7s.mp4", duration: 7 },
],
};
export const KEYWORD_MAP: Record<string, string> = {
"吊顶": "ceiling",
"天花": "ceiling",
"水电": "plumbing",
"水管": "plumbing",
"油漆": "paint",
"涂料": "paint",
"刷漆": "paint",
"完工": "final",
"竣工": "final",
"交付": "final",
};
```
---
## 四、匹配规则(含时长检查)
### 4.1 人物出镜素材选择
用户手动选择一个本地素材作为"当前形象"。
系统检查:
```
let maxSegmentDuration = max(所有 segment 分镜的 duration);
if 形象素材.duration < maxSegmentDuration:
警告:形象素材时长不足
```
### 4.2 空镜素材匹配
```typescript
function matchMaterial(scene: string, requiredDuration: number): MaterialInfo | null {
for (const [keyword, slug] of Object.entries(KEYWORD_MAP)) {
if (scene.includes(keyword)) {
const candidates = MATERIAL_URLS[slug]?.filter(m => m.duration >= requiredDuration);
if (candidates && candidates.length > 0) {
return candidates[Math.floor(Math.random() * candidates.length)];
}
}
}
return null;
}
```
---
## 五、裁剪与拼接
### 5.1 裁剪
每个分镜从素材中截取 `duration` 秒:
| 场景 | 素材时长 | 截取方式 |
|------|---------|---------|
| 等于 | 5s | 从头截取 0-5s |
| 大于 | 8s | 随机从 [0, 3s] 开始截取 |
```bash
ffmpeg -ss {start} -t {duration} -i {input} -c:v libx264 -preset fast -an -y {output}
```
`-an` 确保输出无音频。
### 5.2 拼接(无声)
```bash
# clips.txt
file 'clip_001.mp4'
file 'clip_002.mp4'
...
# 拼接,无音频
ffmpeg -f concat -safe 0 -i clips.txt -c copy -an raw_video.mp4
```
### 5.3 上传临时视频
使用七牛云 SDK 上传 `raw_video.mp4`,获取临时访问 URL。
### 5.4 Vidu 对口型
```bash
POST https://api.vidu.cn/ent/v2/lip-sync
{
"video_url": "https://media.liche.cn/tmp/raw_video_xxx.mp4",
"audio_url": "https://media.liche.cn/audios/project_xxx.mp3"
}
```
**响应**`{ task_id: "xxx", state: "created" }`
**轮询**
```bash
GET https://api.vidu.cn/ent/v2/tasks/{task_id}/creations
```
**成功响应**
```json
{
"state": "success",
"creations": [{ "url": "https://.../final.mp4" }]
}
```
### 5.5 下载与保存
1. 下载 Vidu 返回的 `url` 到本地
2. 本地路径:`~/Documents/Meijiaka-zj/products/{project_id}/final.mp4`
3. 上传七牛云:`videos/{project_id}/final.mp4`
4. 更新项目数据
---
## 六、Step 3 页面交互
```
┌─────────────────────────────────────────┐
│ Step 3: 视频生成 │
│ │
│ ┌─ 人物形象 ──────────────────────────┐ │
│ │ [上传] 或 选择已有 │ │
│ │ ● 男主播正面 (15.2s) │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌─ 空镜匹配 ──────────────────────────┐ │
│ │ 分镜2:瓦工贴墙砖 (5s) │ │
│ │ ✓ ceiling_001 (5s) │ │
│ │ │ │
│ │ 分镜4:防水施工 (4s) │ │
│ │ ✗ 素材不足 (plumbing 只有 4s?) │ │
│ │ [手动选择本地文件] │ │
│ └──────────────────────────────────────┘ │
│ │
│ [生成视频] │
│ │
│ 进度: │
│ 裁剪中... │
│ 拼接中... │
│ 上传中... │
│ Vidu 处理中... (task_id: xxx) │
│ 下载中... │
│ 完成 ✓ │
└─────────────────────────────────────────┘
```
---
## 七、实施清单
### Phase 1:人物出镜素材库
| # | 文件 | 内容 |
|---|------|------|
| 1 | `src-tauri/src/commands/avatar_material.rs` | IPC:上传、列表、删除 |
| 2 | `src-tauri/src/storage/avatar_material.rs` | 索引读写、ffprobe 提取时长 |
| 3 | `src/api/modules/avatarMaterial.ts` | 前端 API 封装 |
| 4 | `VideoGeneration.tsx` | 形象上传 + 选择组件 |
### Phase 2:空镜素材匹配
| # | 文件 | 内容 |
|---|------|------|
| 5 | `src/constants/materialUrls.ts` | 映射表(按实际 URL 格式) |
| 6 | `VideoGeneration.tsx` | 自动匹配逻辑 |
### Phase 3Vidu 合成流水线
| # | 文件 | 内容 |
|---|------|------|
| 7 | `src-tauri/src/ffmpeg_cmd.rs` | 新增:批量裁剪 + 无声拼接 |
| 8 | `python-api/app/services/vidu_service.py` | Vidu API 封装(对口型 + 轮询) |
| 9 | `python-api/app/api/v1/vidu.py` | 后端路由:提交对口型、查询状态 |
| 10 | `src-tauri/src/commands/video_generate.rs` | IPC:生成视频主流程 |
| 11 | `VideoGeneration.tsx` | [生成视频] 按钮 + 进度显示 |
---
## 八、确认事项
- [ ] 人物出镜素材本地管理(上传/选择/时长检查)
- [ ] 空镜素材七牛云映射(关键词 → slug → URL)
- [ ] FFmpeg 裁剪 + 无声拼接
- [ ] 拼接后视频上传七牛云临时 URL
- [ ] Vidu 对口型 API(提交 + 轮询)
- [ ] 最终视频下载到本地 products/ + 上传七牛云永久保存
- [ ] Step 2 音频已在七牛云,直接取 URL
**确认后我开始写。**
+245
View File
@@ -0,0 +1,245 @@
# 空镜素材匹配实现方案
> 状态:待审阅
> 基于讨论结果整理
---
## 一、需求概述
在视频创作流程的 **Step 3(视频生成)** 中,为 `empty_shot`(空镜)分镜自动匹配七牛云上的空镜素材,合成时直接用 URL 拼接,无需下载到本地。
---
## 二、步骤映射(确认版)
| Step | 页面 | 内容 | 素材匹配相关 |
|------|------|------|-------------|
| 1 | ScriptCreation | 脚本生成 | AI 输出分镜(含 `scene` 字段) |
| 2 | VoiceDubbing | 音频合成 | 配音生成 |
| **3** | **VideoGeneration** | **视频生成** | **空镜素材匹配在这里** |
| 4 | SubtitleBurning | 字幕压制 | — |
| 5 | CoverDesign | 封面制作 | — |
| 6 | VideoComposite | 视频合成 | FFmpeg 直接用 URL 拼接 |
---
## 三、七牛云存储规范
- **Bucket**:复用现有 `media-liche`
- **Key 格式**`materials/{slug}/vid_{nnn}.mp4`
- **文件命名**:全英文,禁止中文
| slug | 场景 |
|------|------|
| `tiling` | 瓦工贴砖 |
| `waterproofing` | 防水施工 |
| `plumbing` | 水电施工 |
| `putty` | 腻子打磨 |
| `living_room` | 客厅全景 |
| `kitchen` | 厨房 |
| `bedroom` | 卧室 |
| `bathroom` | 卫生间 |
| `ceiling` | 吊顶 |
| `flooring` | 地板铺设 |
| `cabinet` | 橱柜安装 |
| `demolition` | 拆旧 |
| `masonry` | 砌墙 |
| `door_window` | 门窗安装 |
> 新增 slug 随时补充。
---
## 四、映射表设计
### 4.1 文件位置
`tauri-app/src/constants/materialUrls.ts`
### 4.2 数据结构
```typescript
// slug → URL 列表
export const MATERIAL_URLS: Record<string, string[]> = {
"tiling": [
"https://media.liche.cn/materials/tiling/vid_001.mp4",
"https://media.liche.cn/materials/tiling/vid_002.mp4",
],
"waterproofing": [
"https://media.liche.cn/materials/waterproofing/vid_001.mp4",
],
// ...
};
// 中文关键词 → slug 映射
export const KEYWORD_MAP: Record<string, string> = {
"贴砖": "tiling",
"瓦工": "tiling",
"瓷砖": "tiling",
"防水": "waterproofing",
"水电": "plumbing",
"腻子": "putty",
"客厅": "living_room",
"厨房": "kitchen",
"卧室": "bedroom",
"卫生间": "bathroom",
"吊顶": "ceiling",
"地板": "flooring",
"橱柜": "cabinet",
"拆旧": "demolition",
"砌墙": "masonry",
"门窗": "door_window",
};
```
### 4.3 匹配函数
```typescript
export function matchMaterial(scene: string): string | null {
for (const [keyword, slug] of Object.entries(KEYWORD_MAP)) {
if (scene.includes(keyword)) {
const urls = MATERIAL_URLS[slug];
if (urls && urls.length > 0) {
return urls[Math.floor(Math.random() * urls.length)];
}
}
}
return null;
}
```
- **匹配策略**:关键词包含(`scene.includes(keyword)`
- **多素材**:同一 slug 下有多个 URL 时随机选一个
- **无匹配**:返回 `null`
---
## 五、数据流
```
Step 1 脚本生成
↓ 产出分镜列表(含 scene 字段)
Step 2 音频合成
Step 3 视频生成
├─ segment 分镜(人物出镜)
│ └─ 现有逻辑:选择形象 → 生成数字人视频
└─ empty_shot 分镜(空镜)
└─ [匹配素材] 按钮
↓ 调用 matchMaterial(scene)
↓ 返回 URL → 写入 segment.videoUrl
Step 6 视频合成
↓ FFmpeg 读取 segment.videoUrl(支持 http/https
↓ 拼接输出成品
```
---
## 六、Step 3 前端交互
`VideoGeneration.tsx``empty_shot` 分镜卡片中增加:
```
┌─────────────────────────────────┐
│ 空镜:瓦工贴墙砖、贴地砖 │
│ │
│ [匹配空镜素材] │
│ ↓ │
│ ✓ 已匹配:vid_001.mp4 │
│ [▶ 预览] [重新匹配] │
│ │
│ (匹配失败时) │
│ ✗ 未匹配到素材 │
│ [手动输入 URL] │
└─────────────────────────────────┘
```
### 状态流转
| 状态 | 显示 | 操作 |
|------|------|------|
| 初始 | `[匹配空镜素材]` 按钮 | 点击执行匹配 |
| 匹配成功 | 显示文件名 + 预览按钮 + 重新匹配 | 点击预览播放 |
| 匹配失败 | `未匹配到素材` + 手动输入 | 可手动填 URL |
### 写入字段
匹配成功后写入 `segment.videoUrl`
```typescript
// 类型扩展
interface Segment {
id: string;
type: "segment" | "empty_shot";
scene: string;
voiceover: string;
duration: number;
videoUrl?: string; // 【新增】素材 URL 或数字人视频 URL
videoPath?: string; // 本地路径(数字人视频用)
}
```
---
## 七、视频合成适配
`VideoComposite.tsx` / Rust 层 FFmpeg 合成时:
```rust
// 输入来源判断
for segment in segments {
let input = if segment.type == "segment" {
// 人物出镜:用本地数字人视频路径
segment.video_path.clone()
} else if let Some(url) = &segment.video_url {
// 空镜:直接用七牛云 URLFFmpeg 支持 http 输入)
url.clone()
} else {
// 无素材:黑屏兜底(生成纯色视频)
generate_black_screen(segment.duration)
};
inputs.push(input);
}
```
---
## 八、更新流程
素材新增/变更时:
1. 你上传素材到七牛云 `materials/{slug}/`
2. 把 slug 和完整 URL 列表发给我
3. 我更新 `materialUrls.ts` 中的 `MATERIAL_URLS`
4. 如有新增关键词,同步更新 `KEYWORD_MAP`
5. 提交代码,发版后生效
> 不上线索索管理后台、不建数据库、不做动态配置。MVP 阶段手动维护映射表。
---
## 九、实施清单
| # | 文件 | 动作 | 内容 |
|---|------|------|------|
| 1 | `tauri-app/src/constants/materialUrls.ts` | 新建 | 映射表 + 匹配函数 |
| 2 | `tauri-app/src/api/types.ts` | 修改 | `Segment` 类型加 `videoUrl` 字段 |
| 3 | `tauri-app/src/pages/VideoCreation/VideoGeneration.tsx` | 修改 | empty_shot 卡片加匹配按钮和状态 |
| 4 | `tauri-app/src/store/projectStore.ts` | 修改 | `saveSegmentsToLocalFile` 支持 `videoUrl` 持久化 |
| 5 | `tauri-app/src-tauri/src/video_processing.rs` | 修改 | FFmpeg 合成支持 http URL 输入 |
---
## 十、确认事项
请审阅以上方案,确认或提出修改:
- [ ] 七牛云路径规范 `materials/{slug}/vid_{nnn}.mp4`
- [ ] 前端映射表文件位置 `src/constants/materialUrls.ts`
- [ ] 关键词包含匹配策略
- [ ] Step 3 交互设计(按钮位置、状态显示)
- [ ] 匹配失败时支持手动输入 URL
- [ ] FFmpeg 直接用 URL 合成(不下载本地)
- [ ] 素材更新方式:你发给我 → 我改代码
**全部确认后,我开始按清单写代码。**
+443
View File
@@ -0,0 +1,443 @@
# 空镜素材智能匹配方案
> 文档版本:v1.0
> 创建时间:2026-04-22
> 状态:待实现
## 一、需求背景
### 1.1 业务场景
在视频创作流程中,脚本生成阶段会产生分镜脚本,每个分镜包含:
- **画面描述**:AI 生成的口播场景描述(如 "厨房台面特写"
- **配音文字**:该分镜的口播文案
当前痛点:画面描述只是文字,没有对应的空镜素材,每个分镜只能配音,无法展示实际画面。
### 1.2 解决思路
建立**空镜素材库**,AI 生成脚本时同时输出**标签**,根据标签从素材库匹配对应视频素材,实现:
- 口播 + 空镜画面 的完整分镜效果
- 自动替换/拼接空镜素材,减少人工选材工作量
---
## 二、功能需求
### 2.1 标签体系
| 层级 | 示例 | 说明 |
|------|------|------|
| 父标签 | `室内` | 大场景分类 |
| 子标签 | `客厅``厨房``卫生间``卧室``书房` | 具体房间 |
| 扩展标签 | `台面特写``整体空间``角落细节` | 拍摄角度/风格 |
**标签格式**`父标签/子标签``父标签/子标签/扩展标签`
**示例**
```
室内/客厅
室内/厨房/开放式
室内/卫生间/干湿分离
室外/阳台
```
### 2.2 素材管理
| 需求 | 描述 |
|------|------|
| 素材上传 | 运营人员上传空镜视频到七牛云 |
| 标签标注 | 上传时指定素材的标签(支持多标签) |
| 素材索引 | 本地维护标签 → 素材文件 的映射关系 |
| 素材检索 | 根据标签快速查找匹配素材 |
### 2.3 脚本生成增强
| 需求 | 描述 |
|------|------|
| 标签输出 | AI 生成脚本时,在画面描述后输出对应标签 |
| 标签格式 | 统一使用 `【标签】室内/客厅` 格式 |
| 兼容旧格式 | 已有脚本无需修改,缺失标签的分镜跳过素材匹配 |
**示例输出**
```json
{
"segments": [
{
"id": "seg_001",
"scene": "开场:展示整洁的厨房整体空间",
"voiceover": "大家好,今天我们来聊聊厨房装修的注意事项...",
"duration": 15,
"tag": "室内/厨房/整体空间"
},
{
"id": "seg_002",
"scene": "特写:台面材质细节",
"voiceover": "首先,台面的材质选择非常重要...",
"duration": 12,
"tag": "室内/厨房/台面特写"
}
]
}
```
### 2.4 素材匹配流程
| 步骤 | 操作 | 说明 |
|------|------|------|
| 1 | 解析标签 | 从分镜数据中提取 `tag` 字段 |
| 2 | 查询索引 | 在本地索引中查找对应标签的素材列表 |
| 3 | 随机选择 | 从匹配素材中随机选择一个 |
| 4 | 获取 URL | 拼接七牛云访问 URL |
| 5 | 下载素材 | 下载到本地 `shots/` 目录 |
| 6 | 记录关联 | 将 `videoPath` 写入分镜数据 |
### 2.5 素材路径设计
**七牛云存储结构**(使用英文路径):
```
materials/
├── indoor/
│ ├── living_room/
│ │ ├── vid_001.mp4
│ │ ├── vid_002.mp4
│ │ └── ...
│ ├── kitchen/
│ │ ├── open/
│ │ │ ├── vid_010.mp4
│ │ │ └── ...
│ │ └── closed/
│ └── bathroom/
├── outdoor/
│ ├── balcony/
│ └── garden/
└── ...
```
**本地索引结构**`materials_index.json`):
```json
{
"version": "1.0",
"lastUpdated": "2026-04-22T10:00:00Z",
"tags": {
"室内/客厅": {
"displayName": "室内/客厅",
"qiniuDir": "materials/indoor/living_room",
"videos": [
{
"id": "vid_001",
"filename": "vid_001.mp4",
"qiniuKey": "materials/indoor/living_room/vid_001.mp4",
"duration": 8.5,
"size": 1024000,
"uploadedAt": "2026-04-20T08:30:00Z"
}
]
},
"室内/厨房/开放式": {
"displayName": "室内/厨房/开放式",
"qiniuDir": "materials/indoor/kitchen/open",
"videos": [...]
}
}
}
```
---
## 三、技术方案
### 3.1 架构概览
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ 脚本生成 │────▶│ 素材匹配服务 │────▶│ 七牛云存储 │
│ (AI 输出标签) │ │ (本地索引查询) │ │ (英文路径) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
┌──────────────────┐
│ 本地素材索引 │
│ (materials_index) │
└──────────────────┘
```
### 3.2 核心模块
| 模块 | 位置 | 职责 |
|------|------|------|
| `MaterialIndex` | `python-api/app/services/material_index.py` | 素材索引管理(加载/查询/更新) |
| `MaterialMatcher` | `python-api/app/services/material_matcher.py` | 素材匹配逻辑 |
| `QiniuMaterialService` | `python-api/app/services/qiniu_material.py` | 七牛云素材相关操作 |
| `material_router` | `python-api/app/api/v1/material.py` | 素材管理 API |
### 3.3 关键设计决策
| 决策 | 方案 | 理由 |
|------|------|------|
| 标签中文,路径英文 | 中文标签映射到英文目录 | URL 可读性、FFmpeg 兼容性 |
| 本地索引 + 七牛云 | 双层架构 | 减少 API 调用、快速检索 |
| 随机选择素材 | `random.choice()` | 避免每次生成视频都用相同素材 |
| 索引热更新 | 定时刷新 + 手动刷新 | 支持运营动态增删素材 |
---
## 四、数据模型
### 4.1 分镜数据扩展
```typescript
// 前端 types.ts
interface Segment {
id: string;
type: "segment" | "empty_shot";
scene: string; // 画面描述
voiceover: string; // 配音文案
duration: number; // 预估时长(秒)
tag?: string; // 【新增】素材标签,如 "室内/客厅"
videoPath?: string; // 【新增】匹配到的素材本地路径
videoUrl?: string; // 【新增】七牛云原 URL
status: "pending" | "matched" | "downloaded" | "failed";
}
```
### 4.2 素材索引 Schema
```json
// materials_index.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["version", "lastUpdated", "tags"],
"properties": {
"version": {
"type": "string",
"description": "索引文件版本"
},
"lastUpdated": {
"type": "string",
"format": "date-time",
"description": "最后更新时间"
},
"tags": {
"type": "object",
"description": "标签到素材的映射",
"additionalProperties": {
"type": "object",
"required": ["displayName", "qiniuDir", "videos"],
"properties": {
"displayName": {"type": "string"},
"qiniuDir": {"type": "string"},
"videos": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "filename", "qiniuKey"],
"properties": {
"id": {"type": "string"},
"filename": {"type": "string"},
"qiniuKey": {"type": "string"},
"duration": {"type": "number"},
"size": {"type": "integer"},
"uploadedAt": {"type": "string"}
}
}
}
}
}
}
}
}
```
---
## 五、API 设计
### 5.1 素材管理 API
| 方法 | 路径 | 描述 |
|------|------|------|
| GET | `/api/v1/materials` | 获取素材索引 |
| GET | `/api/v1/materials/tags` | 获取所有标签列表 |
| GET | `/api/v1/materials/match?tag=室内/客厅` | 根据标签匹配素材 |
| POST | `/api/v1/materials/sync` | 同步/刷新素材索引(管理员) |
| POST | `/api/v1/materials/upload` | 上传新素材并标注标签 |
### 5.2 匹配流程 API
| 方法 | 路径 | 描述 |
|------|------|------|
| POST | `/api/v1/materials/batch-match` | 批量匹配分镜素材 |
| GET | `/api/v1/materials/download/{id}` | 下载素材到本地 |
**批量匹配请求**
```json
POST /api/v1/materials/batch-match
{
"segments": [
{"id": "seg_001", "tag": "室内/客厅"},
{"id": "seg_002", "tag": "室内/厨房/台面特写"}
]
}
```
**批量匹配响应**
```json
{
"matched": [
{"segmentId": "seg_001", "videoId": "vid_001", "url": "https://..."},
{"segmentId": "seg_002", "videoId": "vid_015", "url": "https://..."}
],
"unmatched": ["seg_003"],
"summary": {
"total": 3,
"matched": 2,
"unmatched": 1
}
}
```
---
## 六、实施步骤
### Phase 1:基础设施(1天)
- [ ] 创建 `python-api/app/services/material_index.py`
- [ ] 创建 `python-api/app/services/material_matcher.py`
- [ ] 创建 `python-api/app/services/qiniu_material.py`
- [ ] 初始化示例 `materials_index.json`
### Phase 2API 层(1天)
- [ ] 创建 `python-api/app/api/v1/material.py` 路由
- [ ] 注册路由到 `router.py`
- [ ] 编写 API 文档和单元测试
### Phase 3:脚本生成集成(1天)
- [ ] 更新 `python-api/app/ai/prompts/system/script.txt` 添加标签输出要求
- [ ] 更新 `python-api/app/services/script_service.py` 支持素材匹配
- [ ] 测试端到端流程
### Phase 4:前端集成(1天)
- [ ] 更新 `tauri-app/src/api/types.ts` 添加 tag 字段
- [ ] 更新 `SegmentAdapter` 支持 tag 映射
- [ ] 前端显示素材匹配状态
### Phase 5:运营工具(待定)
- [ ] 开发素材上传 + 标签标注 Web 页面
- [ ] 开发素材索引管理后台
---
## 七、文件结构
```
meijiaka-zj/
├── python-api/
│ └── app/
│ ├── services/
│ │ ├── material_index.py # 【新建】素材索引管理
│ │ ├── material_matcher.py # 【新建】素材匹配器
│ │ └── qiniu_material.py # 【新建】七牛云素材操作
│ ├── api/v1/
│ │ ├── material.py # 【新建】素材管理 API
│ │ └── router.py # 【修改】注册 material 路由
│ └── schemas/
│ └── material.py # 【新建】Pydantic 模型
├── tauri-app/
│ └── src/
│ ├── api/
│ │ ├── modules/
│ │ │ └── material.ts # 【新建】素材 API 模块
│ │ └── types.ts # 【修改】添加 Segment.tag
│ └── pages/
│ └── VideoCreation/
│ └── ScriptCreation.tsx # 【修改】显示标签
├── docs/
│ └── material-matching-plan.md # 【新建】本文档
└── materials/
└── materials_index.json # 【新建】素材索引文件
```
---
## 八、配置项
### 8.1 环境变量
```bash
# .env 添加
QINIU_MATERIALS_ENABLED=true # 是否启用素材匹配
QINIU_MATERIALS_DOMAIN=https://materials.xxx.com # 素材专用域名
MATERIALS_INDEX_PATH=./materials_index.json # 本地索引文件路径
```
### 8.2 标签-目录映射表
| 中文标签 | 七牛云目录 | 英文路径 |
|----------|-----------|----------|
| 室内/客厅 | materials/indoor/living_room | materials/indoor/living_room |
| 室内/厨房 | materials/indoor/kitchen | materials/indoor/kitchen |
| 室内/厨房/开放式 | materials/indoor/kitchen/open | materials/indoor/kitchen/open |
| 室内/卫生间 | materials/indoor/bathroom | materials/indoor/bathroom |
| 室内/卧室 | materials/indoor/bedroom | materials/indoor/bedroom |
| 室外/阳台 | materials/outdoor/balcony | materials/outdoor/balcony |
---
## 九、注意事项
### 9.1 编码问题
- **七牛云 Key**:使用 UTF-8 编码,中文 key 技术上支持但建议避免
- **本地路径**:下载后确保路径处理兼容中文文件名
- **FFmpeg**:操作本地文件时优先使用绝对路径和引号包裹
### 9.2 容错处理
| 场景 | 处理方式 |
|------|----------|
| 标签无匹配素材 | 记录 `unmatched`,不影响后续分镜 |
| 七牛云下载失败 | 标记状态为 `failed`,允许重试 |
| 索引文件缺失 | 自动重建或返回空列表 |
### 9.3 性能考虑
- 素材索引加载到内存,避免每次请求都读文件
- 使用 LRU 缓存最近访问的素材 URL
- 下载操作异步执行,不阻塞主流程
---
## 十、附录
### 10.1 七牛云 Bucket 配置建议
```
Bucket 名称:media-materials
存储区域:华东 zone
访问域名:materials.xxx.comCDN 加速)
```
### 10.2 素材上传规范
| 字段 | 要求 |
|------|------|
| 格式 | MP4、H.264 编码 |
| 时长 | 5-30 秒 |
| 分辨率 | 1920x1080 或 1280x720 |
| 文件名 | `vid_{序号}.mp4` |
| 标签 | 上传后填写,支持多标签 |
### 10.3 参考文档
- [七牛云 Python SDK 指南](../docs/qiniu-kodo-python-sdk-guide.md)
- [火山引擎字幕 API](../docs/volcengine-video-caption-api.md)
+32
View File
@@ -0,0 +1,32 @@
"""
空镜素材 API
============
提供空镜素材匹配接口。
"""
from fastapi import APIRouter
from app.schemas.common import ApiResponse, success_response
from app.schemas.materials import MatchMaterialRequest, MaterialInfo
from app.services.material_service import match_material
router = APIRouter()
@router.post("/match", response_model=ApiResponse[MaterialInfo | None])
async def match_material_endpoint(request: MatchMaterialRequest):
"""
根据场景描述和所需时长匹配空镜素材
返回匹配到的素材信息,无匹配返回 data: null
"""
result = match_material(request.scene, request.duration)
if result is None:
return success_response(data=None, message="未匹配到素材")
return success_response(
data=MaterialInfo(url=result["url"], duration=result["duration"]),
message="匹配成功",
)
+12
View File
@@ -8,9 +8,12 @@ from fastapi import APIRouter
from app.api.v1 import (
auth,
caption,
materials,
script,
system,
tasks,
upload,
vidu,
voice,
)
@@ -33,3 +36,12 @@ api_router.include_router(caption.router, tags=["Caption"])
# 语音合成模块(TTS + 声音克隆)
api_router.include_router(voice.router, tags=["Voice"])
# 文件上传模块
api_router.include_router(upload.router, tags=["Upload"])
# Vidu 对口型模块
api_router.include_router(vidu.router, tags=["Vidu"])
# 空镜素材模块
api_router.include_router(materials.router, prefix="/materials", tags=["Materials"])
+189
View File
@@ -0,0 +1,189 @@
"""
文件上传 API
============
提供通用文件上传功能,直接上传到七牛云对象存储。
"""
import logging
import uuid
from pathlib import Path
import io
from fastapi import APIRouter, File, HTTPException, UploadFile
from pydantic import BaseModel, Field
from app.schemas.common import ApiResponse, success_response
from app.services.qiniu_service import get_qiniu_service
router = APIRouter(prefix="/upload", tags=["Upload"])
logger = logging.getLogger(__name__)
class UploadResponse(BaseModel):
"""上传响应"""
url: str = Field(..., description="七牛云文件 URL")
key: str = Field(..., description="七牛云文件 key")
size: int = Field(..., description="文件大小(字节)")
@router.post("/video", response_model=ApiResponse[UploadResponse])
async def upload_video(
file: UploadFile = File(..., description="视频文件"),
):
"""
上传视频到七牛云
支持格式:mp4, mov, avi, webm
返回七牛云永久访问 URL。
"""
try:
# 验证文件格式
allowed_types = {
"video/mp4",
"video/quicktime",
"video/x-msvideo",
"video/webm",
}
content_type = file.content_type or ""
# 如果 content_type 为空,尝试从文件名推断
if not content_type:
ext = Path(file.filename or "").suffix.lower()
ext_to_mime = {
".mp4": "video/mp4",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
".webm": "video/webm",
}
content_type = ext_to_mime.get(ext, "")
if content_type not in allowed_types:
raise HTTPException(
status_code=400,
detail=f"不支持的文件格式: {content_type},请上传 mp4/mov/avi/webm 视频",
)
# 读取文件内容
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="文件内容为空")
# 生成唯一文件名
ext = Path(file.filename or "video.mp4").suffix or ".mp4"
unique_name = f"{uuid.uuid4().hex[:16]}{ext}"
# 上传到七牛云
qiniu = get_qiniu_service()
bucket, domain = qiniu._get_bucket_and_domain("video")
key = qiniu.generate_key("video", unique_name)
stream = io.BytesIO(content)
result = qiniu.upload_stream(
stream=stream,
key=key,
mime_type=content_type or "video/mp4",
bucket=bucket,
domain=domain,
)
url = result.get("url")
key = result.get("key")
if not url:
raise HTTPException(status_code=500, detail="上传到七牛云失败:未返回 URL")
logger.info(f"[Upload] 视频上传成功: {url[:80]}..., size={len(content)}")
return success_response(
data=UploadResponse(
url=url,
key=key or unique_name,
size=len(content),
)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[Upload] 视频上传失败: {e}")
raise HTTPException(status_code=500, detail=f"上传失败: {e}")
@router.post("/image", response_model=ApiResponse[UploadResponse])
async def upload_image(
file: UploadFile = File(..., description="图片文件"),
):
"""
上传图片到七牛云
支持格式:jpg, png, gif, webp
"""
try:
allowed_types = {
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
}
content_type = file.content_type or ""
if not content_type:
ext = Path(file.filename or "").suffix.lower()
ext_to_mime = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
}
content_type = ext_to_mime.get(ext, "")
if content_type not in allowed_types:
raise HTTPException(
status_code=400,
detail=f"不支持的图片格式: {content_type}",
)
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="文件内容为空")
ext = Path(file.filename or "image.jpg").suffix or ".jpg"
unique_name = f"{uuid.uuid4().hex[:16]}{ext}"
qiniu = get_qiniu_service()
bucket, domain = qiniu._get_bucket_and_domain("image")
key = qiniu.generate_key("image", unique_name)
stream = io.BytesIO(content)
result = qiniu.upload_stream(
stream=stream,
key=key,
mime_type=content_type or "image/jpeg",
bucket=bucket,
domain=domain,
)
url = result.get("url")
key = result.get("key")
if not url:
raise HTTPException(status_code=500, detail="上传到七牛云失败:未返回 URL")
logger.info(f"[Upload] 图片上传成功: {url[:80]}..., size={len(content)}")
return success_response(
data=UploadResponse(
url=url,
key=key or unique_name,
size=len(content),
)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[Upload] 图片上传失败: {e}")
raise HTTPException(status_code=500, detail=f"上传失败: {e}")
+159
View File
@@ -0,0 +1,159 @@
"""
Vidu API 代理路由
================
提供 Vidu 对口型(lip-sync)任务的提交和查询接口。
前端通过此接口提交任务并轮询状态,无需直接访问 Vidu API。
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from app.api.deps import get_current_user
from app.models.user import User
from app.schemas.common import ApiResponse, success_response
from app.services.vidu_tts_service import ViduTTSService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/vidu", tags=["Vidu"])
# ========== 请求/响应模型 ==========
class LipSyncRequest(BaseModel):
"""对口型请求"""
video_url: str = Field(..., min_length=1, description="原视频 URL")
audio_url: str | None = Field(None, description="音频 URL(与 text 二选一)")
text: str | None = Field(None, description="文本内容(与 audio_url 二选一)")
voice_id: str | None = Field(None, description="音色 ID(文字驱动时生效)")
speed: float = Field(default=1.0, ge=0.5, le=2.0, description="语速")
volume: int = Field(default=0, ge=0, le=10, description="音量")
ref_photo_url: str | None = Field(None, description="人脸参考图 URL")
@staticmethod
def validate_at_least_one_audio_source(values: dict) -> dict:
"""验证至少提供 audio_url 或 text 之一"""
audio_url = values.get("audio_url")
text = values.get("text")
if not audio_url and not text:
raise ValueError("必须提供 audio_url 或 text 之一")
return values
class LipSyncResponse(BaseModel):
"""对口型任务提交响应"""
task_id: str = Field(..., description="Vidu 任务 ID")
message: str = Field(default="任务已提交", description="状态消息")
class LipSyncQueryResponse(BaseModel):
"""对口型任务查询响应"""
task_id: str = Field(..., description="任务 ID")
state: str = Field(..., description="任务状态: pending/processing/succeeded/failed")
video_url: str | None = Field(None, description="生成后的视频 URL(成功时)")
message: str | None = Field(None, description="状态描述或错误信息")
creations: list[dict] | None = Field(None, description="Vidu 原始 creations 数据")
# ========== API 路由 ==========
@router.post("/lip-sync", response_model=ApiResponse[LipSyncResponse])
async def create_lip_sync_task(
request: LipSyncRequest,
current_user: User = Depends(get_current_user),
):
"""
提交 Vidu 对口型任务
需要提供:
- video_url: 原视频 URL(人物出镜视频)
- audio_url: 音频 URL(与 text 二选一)
- text: 文本内容(与 audio_url 二选一,使用 Vidu 内置 TTS)
返回 Vidu task_id,用于后续轮询查询。
"""
try:
# 验证至少提供一种音频来源
if not request.audio_url and not request.text:
raise HTTPException(
status_code=400,
detail="必须提供 audio_url 或 text 之一",
)
service = ViduTTSService()
task_id = await service.lip_sync_create(
video_url=request.video_url,
audio_url=request.audio_url,
text=request.text,
voice_id=request.voice_id,
speed=request.speed,
volume=request.volume,
ref_photo_url=request.ref_photo_url,
)
logger.info(f"[Vidu] 对口型任务提交成功: task_id={task_id}, user={current_user.id}")
return success_response(
data=LipSyncResponse(
task_id=task_id,
message="对口型任务已提交,请轮询查询状态",
)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[Vidu] 提交对口型任务失败: {e}")
raise HTTPException(status_code=500, detail=f"提交对口型任务失败: {e}")
@router.get("/tasks/{task_id}/creations", response_model=ApiResponse[LipSyncQueryResponse])
async def query_lip_sync_task(
task_id: str,
current_user: User = Depends(get_current_user),
):
"""
查询 Vidu 对口型任务状态
返回任务状态及生成物信息。
当 state=succeeded 时,video_url 为生成后的对口型视频 URL。
"""
try:
service = ViduTTSService()
result = await service.lip_sync_query(task_id)
state = result.get("state", "unknown")
creations = result.get("creations", [])
# 提取视频 URL(成功时)
video_url = None
if state == "succeeded" and creations:
# creations 是数组,取第一个
first_creation = creations[0] if creations else {}
video_url = first_creation.get("url")
logger.info(
f"[Vidu] 查询对口型任务: task_id={task_id}, state={state}, user={current_user.id}"
)
return success_response(
data=LipSyncQueryResponse(
task_id=task_id,
state=state,
video_url=video_url,
message=result.get("message") if state == "failed" else None,
creations=creations if creations else None,
)
)
except Exception as e:
logger.error(f"[Vidu] 查询对口型任务失败: {e}")
raise HTTPException(status_code=500, detail=f"查询任务失败: {e}")
+9
View File
@@ -79,6 +79,15 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"Failed to load models from config: {e}")
# 加载空镜素材配置(从 JSON 文件)
try:
from app.services.material_service import load_config
load_config()
logger.info("Loaded material config from JSON file")
except Exception as e:
logger.warning(f"Failed to load material config: {e}")
yield
# 关闭时清理
+20
View File
@@ -0,0 +1,20 @@
"""
空镜素材 Schema
==============
"""
from pydantic import BaseModel, Field
class MaterialInfo(BaseModel):
"""素材条目"""
url: str = Field(description="素材 URL(远程或本地路径)")
duration: float = Field(description="素材时长(秒),从文件名解析)")
class MatchMaterialRequest(BaseModel):
"""匹配素材请求"""
scene: str = Field(description="分镜场景描述")
duration: float = Field(description="所需时长(秒)")
@@ -0,0 +1,81 @@
"""
空镜素材服务
============
从本地 JSON 配置文件加载素材库,提供匹配逻辑。
duration 从文件名 `_{N}s_` 中自动解析。
"""
import json
import random
import re
from pathlib import Path
# 正则:从文件名中提取时长,如 plumbing_10s_a23f8fcb.mp4 → 10
_DURATION_RE = re.compile(r"_(\d+)s_")
# 配置缓存(启动时加载,运行时只读)
_keywords: dict[str, str] = {}
_materials: dict[str, list[dict]] = {}
def _get_config_path() -> Path:
"""获取配置文件绝对路径"""
# 配置文件位于项目根目录的 config/ 下
return Path(__file__).resolve().parent.parent.parent / "config" / "materials.json"
def _parse_duration(url: str) -> float:
"""从 URL 文件名中解析时长(秒)"""
filename = url.split("/")[-1]
match = _DURATION_RE.search(filename)
if not match:
raise ValueError(f"无法从文件名解析时长: {filename}")
return float(match.group(1))
def load_config() -> None:
"""加载素材配置到内存缓存"""
global _keywords, _materials
config_path = _get_config_path()
if not config_path.exists():
raise FileNotFoundError(f"素材配置文件不存在: {config_path}")
with open(config_path, "r", encoding="utf-8") as f:
data = json.load(f)
_keywords = data.get("keywords", {})
raw_materials = data.get("materials", {})
# 解析每个素材的 duration
_materials = {}
for slug, entries in raw_materials.items():
parsed = []
for entry in entries:
url = entry["url"]
duration = _parse_duration(url)
parsed.append({"url": url, "duration": duration})
_materials[slug] = parsed
def match_material(scene: str, required_duration: float) -> dict | None:
"""
根据场景描述和所需时长匹配空镜素材
Args:
scene: 分镜场景描述
required_duration: 所需时长(秒)
Returns:
匹配到的素材 {url, duration},无匹配返回 None
"""
for keyword, slug in _keywords.items():
if keyword in scene:
candidates = [
m for m in _materials.get(slug, [])
if m["duration"] >= required_duration
]
if candidates:
return random.choice(candidates)
return None
+635
View File
@@ -0,0 +1,635 @@
{
"version": "1.0",
"keywords": {
"吊顶": "ceiling",
"天花": "ceiling",
"水电": "plumbing",
"水管": "plumbing",
"油漆": "paint",
"涂料": "paint",
"刷漆": "paint",
"完工": "final",
"竣工": "final",
"交付": "final"
},
"materials": {
"ceiling": [
{
"url": "https://media.liche.cn/meijiaka-zj/material/ceiling/ceiling_5s_c1e06c14.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/ceiling/ceiling_6s_4091bf65.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/ceiling/ceiling_6s_78fdb4d1.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/ceiling/ceiling_7s_52ffb6e9.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/ceiling/ceiling_8s_14ce7f1d.mp4"
}
],
"paint": [
{
"url": "https://media.liche.cn/meijiaka-zj/material/paint/paint_4s_7981c90b.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/paint/paint_5s_94e1a99e.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/paint/paint_5s_bec25ff4.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/paint/paint_6s_178e2b25.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/paint/paint_6s_26cc917c.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/paint/paint_7s_7a16fb72.mp4"
}
],
"tile": [
{
"url": "https://media.liche.cn/meijiaka-zj/material/tile/tile_4s_7be39898.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/tile/tile_4s_aeb1343d.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/tile/tile_5s_4c1e25dc.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/tile/tile_5s_b26b8cae.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/tile/tile_5s_fe2a8645.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/tile/tile_6s_55ffa0ff.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/tile/tile_6s_a2447a54.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/tile/tile_7s_95b87885.mp4"
}
],
"waterproof": [
{
"url": "https://media.liche.cn/meijiaka-zj/material/waterproof/waterproof_10s_a2b0f6da.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/waterproof/waterproof_11s_eaf4b9f0.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/waterproof/waterproof_5s_22f5e19b.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/waterproof/waterproof_5s_35498a2a.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/waterproof/waterproof_5s_6cbb95dc.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/waterproof/waterproof_5s_aa110132.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/waterproof/waterproof_5s_da3a9f8d.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/waterproof/waterproof_6s_32c90edb.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/waterproof/waterproof_6s_51d60412.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/waterproof/waterproof_6s_59cbfabd.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/waterproof/waterproof_6s_88d43e86.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/waterproof/waterproof_6s_9a6c1e20.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/waterproof/waterproof_7s_4be2c8b3.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/waterproof/waterproof_7s_efb40fd3.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/waterproof/waterproof_8s_13fc8a2a.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/waterproof/waterproof_8s_c816798f.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/waterproof/waterproof_9s_5c3820be.mp4"
}
],
"final": [
{
"url": "https://media.liche.cn/meijiaka-zj/material/final/final_10s_40b78665.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/final/final_11s_7c8c6833.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/final/final_4s_5ee06c87.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/final/final_4s_a8994032.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/final/final_5s_98b64716.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/final/final_6s_83e76197.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/final/final_7s_109bde47.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/final/final_7s_8b9df21b.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/final/final_7s_ee3be316.mp4"
}
],
"plumbing": [
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_10s_a23f8fcb.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_11s_8cad4c63.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_3s_014d4b99.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_3s_26c96bb3.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_3s_88f3c2ff.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_3s_8f9e2fd1.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_3s_917e73d2.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_3s_a21a93c2.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_3s_b41346c9.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_3s_b6c5d8d5.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_3s_c83e401f.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_3s_edc3ed94.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_06c176f7.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_0d8188b0.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_1d024051.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_26252c4f.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_289d05b9.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_291ff9cd.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_2a24e5dd.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_2ea437ff.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_30204ab3.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_3279abb0.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_370db89d.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_3fa42431.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_46a47e19.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_4b6e8a71.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_4db3ad9d.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_5670036a.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_5de6f9c7.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_635ceca4.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_66d70c58.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_6ac4e162.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_6f61c1d7.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_77a83b95.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_80f8c4db.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_8782b24d.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_8a4aac19.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_9193c60f.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_923da0a5.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_92c20623.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_991614c2.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_a5a8f8e9.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_abac94a2.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_adda5387.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_b2386645.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_c011f332.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_d7977183.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_daf3eb45.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_ddf27707.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_e8224a6a.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_edf6cd59.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_eed46b34.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_4s_f01dbb9a.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_01547dc3.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_01a8ac16.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_03c341ba.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_03fc74f5.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_10b4f6d7.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_12d6cc7f.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_17492b31.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_192bd9cc.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_1db4fcc0.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_1e8fabdb.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_1ebe0150.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_22532b8d.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_239aa4e0.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_2461447a.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_29b29710.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_2b144bbe.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_4ac4f57e.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_4e8a4605.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_50a842d5.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_58f310b5.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_5a6d3324.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_7afd5931.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_80d28904.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_845aaeb5.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_8920d53d.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_91f27b4d.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_924538a9.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_964c51f3.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_a76f5d31.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_a7afa965.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_aa9a3927.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_aac7da24.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_aafcc59d.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_b56deb39.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_b7150433.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_b920788e.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_ba04551e.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_bbb8dc91.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_bf0f4247.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_c02abcad.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_c06f8b5c.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_cb24fc27.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_cbaa1936.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_ccec7599.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_ddcadc8c.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_e0da2b38.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_fcf594b4.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_5s_ff920a11.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_06f77a48.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_0a814b44.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_0b62bfaa.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_1850465b.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_24db5614.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_41e2f984.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_4dcfba99.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_501f7797.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_54090579.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_548c3105.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_5683347b.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_7d3db3d5.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_7e30771b.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_801e5229.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_8dc61b6c.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_8ef50b5a.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_b39a845c.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_b456e9ea.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_b570d306.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_b8952314.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_bf9443da.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_cd17be55.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_de6b43ec.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_6s_dfb8c797.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_7s_0347af3b.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_7s_176e9cb9.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_7s_22a74e9e.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_7s_2b31aa82.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_7s_2e62e3c0.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_7s_3e6c3f0e.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_7s_696efd19.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_7s_7e1a3879.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_7s_867c9942.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_7s_95a93892.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_7s_9ef1fc68.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_7s_a9fb2286.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_7s_ca1063e6.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_7s_d73638e7.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_7s_e0b10567.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_7s_ea506fe9.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_7s_f0d979ba.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_8s_0fe1953c.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_8s_1f04e5f3.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_8s_21b61c48.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_8s_67a52a55.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_8s_69833536.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_8s_705db3bf.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_8s_ab98331b.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_8s_ae8c2c29.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_8s_be9d5579.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_8s_c4ad7c9f.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_8s_d9805d78.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_8s_e77c4bed.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_9s_47be1915.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_9s_4c1e542f.mp4"
},
{
"url": "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_9s_5e45c9e5.mp4"
}
]
}
}
+4 -4
View File
@@ -7,8 +7,8 @@ services:
environment:
- ENV=development
- DEBUG=true
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/meijiaka_zj
- REDIS_HOST=redis
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@meijiaka-db:5432/meijiaka_zj
- REDIS_HOST=meijiaka-redis
- REDIS_PORT=6379
- REDIS_DB=1
- SECRET_KEY=dev-secret-key-change-in-production
@@ -37,8 +37,8 @@ services:
environment:
- ENV=development
- DEBUG=true
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/meijiaka_zj
- REDIS_HOST=redis
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@meijiaka-db:5432/meijiaka_zj
- REDIS_HOST=meijiaka-redis
- REDIS_PORT=6379
- REDIS_DB=1
- SECRET_KEY=dev-secret-key-change-in-production
+1237 -36
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -26,6 +26,7 @@
"@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-opener": "^2",
"assjs": "^0.1.5",
"fabric": "^6.9.1",
"immer": "^11.1.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+18
View File
@@ -2199,6 +2199,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -3180,6 +3190,7 @@ dependencies = [
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
@@ -3191,6 +3202,7 @@ dependencies = [
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
@@ -4808,6 +4820,12 @@ dependencies = [
"unic-common",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
+1 -1
View File
@@ -28,7 +28,7 @@ serde_json = "1"
dirs = "5"
# HTTP 客户端: 精简功能
# 使用 default-tls(默认)+ json,无需额外 features
reqwest = { version = "0.12", features = ["json"] }
reqwest = { version = "0.12", features = ["json", "multipart"] }
uuid = { version = "1", features = ["v4"] }
# base64: 使用 0.22 最新版
# 注意:Tauri 内部仍使用 0.21(通过 swift-rs),无法避免重复
+6 -5
View File
@@ -4,6 +4,7 @@
//! 存储逻辑已迁移到 storage::cache,本模块保留命令入口和下载逻辑。
use tauri::AppHandle;
use crate::StringResultExt;
use reqwest::Client;
use base64::engine::general_purpose;
use base64::Engine as _;
@@ -19,7 +20,7 @@ pub async fn query_avatar_cache(
avatar_id: String,
) -> Result<CacheQueryResult, String> {
crate::storage::cache::query_avatar_cache(&app, &state, &avatar_id)
.map_err(|e| e.to_string())
.map_err_string()
}
/// 从远程 URL 下载视频并缓存到本地
@@ -54,7 +55,7 @@ pub async fn cache_avatar_video(
.map_err(|e| format!("Read failed: {}", e))?;
crate::storage::cache::save_cached_video(&app, &state, &avatar_id, &remote_url, &bytes)
.map_err(|e| e.to_string())
.map_err_string()
}
/// 保存封面图片(base64 格式)到缓存
@@ -77,7 +78,7 @@ pub async fn save_avatar_poster(
.map_err(|e| format!("Base64 decode failed: {}", e))?;
crate::storage::cache::save_cached_poster(&app, &state, &avatar_id, &decoded)
.map_err(|e| e.to_string())
.map_err_string()
}
/// 删除指定 avatar 的缓存
@@ -88,7 +89,7 @@ pub async fn delete_avatar_cache(
avatar_id: String,
) -> Result<CacheDeleteResult, String> {
crate::storage::cache::delete_avatar_cache(&app, &state, &avatar_id)
.map_err(|e| e.to_string())
.map_err_string()
}
/// 获取缓存统计信息
@@ -98,7 +99,7 @@ pub async fn get_cache_stats(
state: tauri::State<'_, CacheState>,
) -> Result<serde_json::Value, String> {
crate::storage::cache::get_cache_stats(&app, &state)
.map_err(|e| e.to_string())
.map_err_string()
}
/// 获取或创建全局 HTTP 客户端
+4 -3
View File
@@ -1,6 +1,7 @@
//! 项目资源存储命令
use crate::ApiResponse;
use crate::StringResultExt;
use crate::storage::project as project_storage;
#[tauri::command]
@@ -9,7 +10,7 @@ pub async fn save_project_asset(
filename: String,
base64_data: String,
) -> ApiResponse<String> {
match project_storage::save_project_asset(&project_id, &filename, &base64_data).map_err(|e| e.to_string()) {
match project_storage::save_project_asset(&project_id, &filename, &base64_data).map_err_string() {
Ok(path) => ApiResponse {
code: 200,
message: "资源保存成功".to_string(),
@@ -28,7 +29,7 @@ pub async fn get_video_save_path(
project_id: String,
filename: String,
) -> ApiResponse<String> {
match project_storage::get_video_save_path(&project_id, &filename).map_err(|e| e.to_string()) {
match project_storage::get_video_save_path(&project_id, &filename).map_err_string() {
Ok(path) => ApiResponse {
code: 200,
message: "获取路径成功".to_string(),
@@ -47,7 +48,7 @@ pub async fn get_image_save_path(
project_id: String,
filename: String,
) -> ApiResponse<String> {
match project_storage::get_image_save_path(&project_id, &filename).map_err(|e| e.to_string()) {
match project_storage::get_image_save_path(&project_id, &filename).map_err_string() {
Ok(path) => ApiResponse {
code: 200,
message: "获取路径成功".to_string(),
@@ -1,11 +1,12 @@
//! 认证状态存储命令
use crate::ApiResponse;
use crate::StringResultExt;
use crate::storage::auth as auth_storage;
#[tauri::command]
pub async fn load_auth_state(app: tauri::AppHandle) -> ApiResponse<Option<serde_json::Value>> {
match auth_storage::load_auth_state(&app).map_err(|e| e.to_string()) {
match auth_storage::load_auth_state(&app).map_err_string() {
Ok(data) => ApiResponse {
code: 200,
message: "Auth state loaded successfully".to_string(),
@@ -24,7 +25,7 @@ pub async fn save_auth_state(
app: tauri::AppHandle,
state: serde_json::Value,
) -> ApiResponse<bool> {
match auth_storage::save_auth_state(&app, &state).map_err(|e| e.to_string()) {
match auth_storage::save_auth_state(&app, &state).map_err_string() {
Ok(_) => ApiResponse {
code: 200,
message: "Auth state saved successfully".to_string(),
@@ -40,7 +41,7 @@ pub async fn save_auth_state(
#[tauri::command]
pub async fn clear_auth_state(app: tauri::AppHandle) -> ApiResponse<bool> {
match auth_storage::clear_auth_state(&app).map_err(|e| e.to_string()) {
match auth_storage::clear_auth_state(&app).map_err_string() {
Ok(_) => ApiResponse {
code: 200,
message: "Auth state cleared successfully".to_string(),
+3 -2
View File
@@ -1,11 +1,12 @@
//! 克隆形象列表存储命令
use crate::ApiResponse;
use crate::StringResultExt;
use crate::storage::avatar as avatar_storage;
#[tauri::command]
pub async fn load_avatars_list(_app: tauri::AppHandle) -> ApiResponse<Vec<serde_json::Value>> {
match avatar_storage::load_avatars_list().map_err(|e| e.to_string()) {
match avatar_storage::load_avatars_list().map_err_string() {
Ok(data) => ApiResponse {
code: 200,
message: "Avatars loaded successfully".to_string(),
@@ -24,7 +25,7 @@ pub async fn save_avatars_list(
_app: tauri::AppHandle,
avatars: Vec<serde_json::Value>,
) -> ApiResponse<bool> {
match avatar_storage::save_avatars_list(&avatars).map_err(|e| e.to_string()) {
match avatar_storage::save_avatars_list(&avatars).map_err_string() {
Ok(_) => ApiResponse {
code: 200,
message: "Avatars saved successfully".to_string(),
+1
View File
@@ -10,3 +10,4 @@ pub mod avatar;
pub mod product;
pub mod project;
pub mod voice;
pub mod video_compose;
+9 -8
View File
@@ -1,6 +1,7 @@
//! 项目存储命令
use crate::ApiResponse;
use crate::StringResultExt;
use crate::storage::project as project_storage;
// ============================================================
@@ -9,7 +10,7 @@ use crate::storage::project as project_storage;
#[tauri::command]
pub async fn save_project_meta(project_id: String, data: serde_json::Value) -> ApiResponse<bool> {
match project_storage::save_project_meta(&project_id, &data).map_err(|e| e.to_string()) {
match project_storage::save_project_meta(&project_id, &data).map_err_string() {
Ok(_) => ApiResponse {
code: 200,
message: "Project saved successfully".to_string(),
@@ -25,7 +26,7 @@ pub async fn save_project_meta(project_id: String, data: serde_json::Value) -> A
#[tauri::command]
pub async fn save_project_meta_raw(project_id: String, json_content: String) -> ApiResponse<bool> {
match project_storage::save_project_meta_raw(&project_id, &json_content).map_err(|e| e.to_string()) {
match project_storage::save_project_meta_raw(&project_id, &json_content).map_err_string() {
Ok(_) => ApiResponse {
code: 200,
message: "Project saved successfully".to_string(),
@@ -41,7 +42,7 @@ pub async fn save_project_meta_raw(project_id: String, json_content: String) ->
#[tauri::command]
pub async fn load_project_meta(project_id: String) -> ApiResponse<serde_json::Value> {
match project_storage::load_project_meta(&project_id).map_err(|e| e.to_string()) {
match project_storage::load_project_meta(&project_id).map_err_string() {
Ok(data) => ApiResponse {
code: 200,
message: "Project loaded successfully".to_string(),
@@ -57,7 +58,7 @@ pub async fn load_project_meta(project_id: String) -> ApiResponse<serde_json::Va
#[tauri::command]
pub async fn save_project_segments(project_id: String, segments: serde_json::Value) -> ApiResponse<bool> {
match project_storage::save_project_segments(&project_id, &segments).map_err(|e| e.to_string()) {
match project_storage::save_project_segments(&project_id, &segments).map_err_string() {
Ok(_) => ApiResponse {
code: 200,
message: "Segments saved successfully".to_string(),
@@ -73,7 +74,7 @@ pub async fn save_project_segments(project_id: String, segments: serde_json::Val
#[tauri::command]
pub async fn save_project_segments_raw(project_id: String, json_content: String) -> ApiResponse<bool> {
match project_storage::save_project_segments_raw(&project_id, &json_content).map_err(|e| e.to_string()) {
match project_storage::save_project_segments_raw(&project_id, &json_content).map_err_string() {
Ok(_) => ApiResponse {
code: 200,
message: "Segments saved successfully".to_string(),
@@ -89,7 +90,7 @@ pub async fn save_project_segments_raw(project_id: String, json_content: String)
#[tauri::command]
pub async fn load_project_segments(project_id: String) -> ApiResponse<serde_json::Value> {
match project_storage::load_project_segments(&project_id).map_err(|e| e.to_string()) {
match project_storage::load_project_segments(&project_id).map_err_string() {
Ok(data) => ApiResponse {
code: 200,
message: "Segments loaded successfully".to_string(),
@@ -105,7 +106,7 @@ pub async fn load_project_segments(project_id: String) -> ApiResponse<serde_json
#[tauri::command]
pub async fn list_local_projects() -> ApiResponse<Vec<serde_json::Value>> {
match project_storage::list_projects().map_err(|e| e.to_string()) {
match project_storage::list_projects().map_err_string() {
Ok(projects) => ApiResponse {
code: 200,
message: "Projects listed successfully".to_string(),
@@ -121,7 +122,7 @@ pub async fn list_local_projects() -> ApiResponse<Vec<serde_json::Value>> {
#[tauri::command]
pub async fn delete_local_project(project_id: String) -> ApiResponse<bool> {
match project_storage::delete_project(&project_id).map_err(|e| e.to_string()) {
match project_storage::delete_project(&project_id).map_err_string() {
Ok(_) => ApiResponse {
code: 200,
message: "Project deleted successfully".to_string(),
@@ -0,0 +1,338 @@
//! 视频合成命令
//!
//! 提供本地 FFmpeg 视频裁剪、拼接、上传功能。
//! 用于 Phase 2 视频生成流程:裁剪素材 → 拼接 → 上传七牛云 → Vidu 对口型。
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use crate::ffmpeg_cmd;
use crate::ffmpeg_cmd::sanitize_output_path;
use crate::ApiResponse;
/// 片段信息
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComposeSegment {
pub id: String,
#[allow(dead_code)]
pub r#type: String,
pub duration: f64,
pub source: String,
/// 截取起始时间(秒),默认从 0 开始
pub start_time: Option<f64>,
}
/// 视频合成请求参数
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComposeVideoArgs {
pub project_id: String,
pub segments: Vec<ComposeSegment>,
}
/// 视频合成响应
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ComposeVideoResult {
pub output_path: String,
pub duration: f64,
}
/// 上传视频响应
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UploadVideoResult {
pub url: String,
}
/// 获取项目视频目录
fn get_project_video_dir(project_id: &str) -> Result<std::path::PathBuf, String> {
let docs_dir = dirs::document_dir().ok_or("无法获取文档目录")?;
let dir = docs_dir.join("Meijiaka-zj").join("projects").join(project_id).join("videos");
std::fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {}", e))?;
Ok(dir)
}
/// 解析时长字符串为秒数
/// 视频合成:裁剪所有素材片段并拼接成一个视频
///
/// 流程:
/// 1. 对每个 segment 调用 FFmpeg 裁剪(本地文件或 HTTP URL
/// 2. 使用 concat 拼接所有片段
/// 3. 保存到项目 videos 目录
#[tauri::command]
pub async fn compose_video(
app: AppHandle,
args: ComposeVideoArgs,
) -> ApiResponse<ComposeVideoResult> {
let project_id = &args.project_id;
// 获取输出目录
let video_dir = match get_project_video_dir(project_id) {
Ok(dir) => dir,
Err(e) => {
return ApiResponse {
code: 500,
message: e,
data: None,
};
}
};
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
let mut clip_paths: Vec<String> = Vec::new();
let mut total_duration: f64 = 0.0;
// 1. 裁剪每个片段(保存到项目 videos 目录,避免 /tmp 路径验证问题)
for (i, segment) in args.segments.iter().enumerate() {
let duration = segment.duration;
total_duration += duration;
let clip_path = video_dir.join(format!("clip_{:03}_{}.mp4", i, timestamp));
let clip_path_str = clip_path.to_string_lossy().to_string();
// 裁剪视频:从指定起始时间裁剪指定时长
// 人物出镜素材支持随机截取,空镜从 0 开始
let start = segment.start_time.unwrap_or(0.0);
if let Err(e) = ffmpeg_cmd::clip_video(&app, &segment.source, start, duration, &clip_path_str).await {
// 清理已生成的片段
for path in &clip_paths {
let _ = std::fs::remove_file(path);
}
let _ = std::fs::remove_file(&clip_path_str);
return ApiResponse {
code: 500,
message: format!("裁剪片段 {} 失败: {}", segment.id, e),
data: None,
};
}
clip_paths.push(clip_path_str);
}
// 2. 生成 concat 列表文件
let list_path = video_dir.join(format!("concat_list_{}.txt", timestamp));
let mut list_content = String::new();
for path in &clip_paths {
// FFmpeg concat 列表中的路径需要用单引号包裹
list_content.push_str(&format!("file '{}'
", ffmpeg_cmd::escape_ffmpeg_path(path)));
}
if let Err(e) = std::fs::write(&list_path, list_content) {
for path in &clip_paths {
let _ = std::fs::remove_file(path);
}
return ApiResponse {
code: 500,
message: format!("创建拼接列表失败: {}", e),
data: None,
};
}
// 3. 拼接所有片段(直接使用 copy 模式,因为 clip_video 已标准化)
let output_filename = format!("composed_{}.mp4", timestamp);
let output_path = video_dir.join(&output_filename);
let output_path_str = output_path.to_string_lossy().to_string();
let concat_res = ffmpeg_cmd::concat_videos_copy(&app, list_path.to_str().unwrap(), &output_path_str).await;
// 4. 清理临时片段和列表文件
for path in &clip_paths {
let _ = std::fs::remove_file(path);
}
let _ = std::fs::remove_file(&list_path);
if let Err(e) = concat_res {
return ApiResponse {
code: 500,
message: format!("拼接视频失败: {}", e),
data: None,
};
}
ApiResponse {
code: 200,
message: "视频合成成功".to_string(),
data: Some(ComposeVideoResult {
output_path: output_path_str,
duration: total_duration,
}),
}
}
/// 上传视频请求参数
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UploadVideoArgs {
pub local_path: String,
}
/// 上传本地视频到后端,后端上传到七牛云并返回 URL
#[tauri::command]
pub async fn upload_video_file(
args: UploadVideoArgs,
) -> ApiResponse<UploadVideoResult> {
// 读取本地文件
let file_bytes = match std::fs::read(&args.local_path) {
Ok(bytes) => bytes,
Err(e) => {
return ApiResponse {
code: 500,
message: format!("读取视频文件失败: {}", e),
data: None,
};
}
};
// 获取文件名
let filename = std::path::Path::new(&args.local_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("video.mp4")
.to_string();
// 构建 multipart 请求
let backend_url = crate::PYTHON_API_BASE_URL;
let upload_url = format!("{}/upload/video", backend_url);
let client = reqwest::Client::new();
// 构建 multipart form
let form = reqwest::multipart::Form::new()
.part(
"file",
reqwest::multipart::Part::bytes(file_bytes)
.file_name(filename)
.mime_str("video/mp4")
.unwrap_or_else(|_| reqwest::multipart::Part::bytes(vec![])),
);
// 发送请求
let response = match client.post(&upload_url).multipart(form).send().await {
Ok(resp) => resp,
Err(e) => {
return ApiResponse {
code: 500,
message: format!("上传请求失败: {}", e),
data: None,
};
}
};
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return ApiResponse {
code: status.as_u16() as i32,
message: format!("上传失败: {} - {}", status, error_text),
data: None,
};
}
// 解析响应
let result: serde_json::Value = match response.json().await {
Ok(data) => data,
Err(e) => {
return ApiResponse {
code: 500,
message: format!("解析上传响应失败: {}", e),
data: None,
};
}
};
// 提取 URL
let url = result
.get("data")
.and_then(|d| d.get("url"))
.and_then(|u| u.as_str())
.map(|s| s.to_string());
match url {
Some(url) => ApiResponse {
code: 200,
message: "上传成功".to_string(),
data: Some(UploadVideoResult { url }),
},
None => ApiResponse {
code: 500,
message: "上传响应中未找到 URL".to_string(),
data: None,
},
}
}
/// 下载文件请求参数
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadFileArgs {
pub url: String,
pub output_path: String,
}
/// 从 URL 下载文件到本地
#[tauri::command]
pub async fn download_file(
args: DownloadFileArgs,
) -> ApiResponse<String> {
let safe_output = match sanitize_output_path(&args.output_path) {
Ok(p) => p,
Err(e) => {
return ApiResponse {
code: 500,
message: format!("路径验证失败: {}", e),
data: None,
};
}
};
let client = reqwest::Client::new();
let response = match client.get(&args.url).send().await {
Ok(resp) => resp,
Err(e) => {
return ApiResponse {
code: 500,
message: format!("下载请求失败: {}", e),
data: None,
};
}
};
if !response.status().is_success() {
return ApiResponse {
code: response.status().as_u16() as i32,
message: format!("下载失败: HTTP {}", response.status()),
data: None,
};
}
let bytes = match response.bytes().await {
Ok(b) => b,
Err(e) => {
return ApiResponse {
code: 500,
message: format!("读取下载内容失败: {}", e),
data: None,
};
}
};
if let Err(e) = std::fs::write(&safe_output, &bytes) {
return ApiResponse {
code: 500,
message: format!("保存文件失败: {}", e),
data: None,
};
}
ApiResponse {
code: 200,
message: "下载成功".to_string(),
data: Some(safe_output),
}
}
+50 -3
View File
@@ -1,9 +1,10 @@
use tauri_plugin_shell::ShellExt;
use crate::StringResultExt;
use tauri_plugin_shell::process::CommandEvent;
use tauri::{AppHandle, Emitter};
/// FFmpeg 路径转义(替换 `:` 为 `\:`,替换 `'` 为 `'\''`
fn escape_ffmpeg_path(path: &str) -> String {
pub fn escape_ffmpeg_path(path: &str) -> String {
path.replace("'", "'\\''")
}
@@ -39,7 +40,7 @@ fn validate_safe_path(path: &str) -> Result<String, String> {
}
/// 清理并验证输出路径
fn sanitize_output_path(path: &str) -> Result<String, String> {
pub fn sanitize_output_path(path: &str) -> Result<String, String> {
let path = std::path::Path::new(path);
// 获取父目录并验证
@@ -173,7 +174,7 @@ pub async fn concat_videos_robust(app: &AppHandle, video_paths: Vec<String>, out
// FFmpeg concat 列表中的路径需要用单引号包裹
list_content.push_str(&format!("file '{}'\n", escape_ffmpeg_path(&path.to_string_lossy())));
}
std::fs::write(&list_path, list_content).map_err(|e| e.to_string())?;
std::fs::write(&list_path, list_content).map_err_string()?;
// 3. 执行快速拼接(输出路径会被验证)
let concat_res = concat_videos_copy(app, list_path.to_str().unwrap(), output_path).await;
@@ -479,6 +480,52 @@ pub async fn mix_audio_tracks(
run_ffmpeg(app, args).await.map(|_| ())
}
/**
* 裁剪视频片段(支持本地文件和 HTTP URL)
*
* 从起始时间裁剪指定时长,同时标准化输出格式(1080x1920, 30fps, libx264, aac)。
* 适用于从人物形象素材或空镜素材中提取指定时长的片段。
*/
pub async fn clip_video(
app: &AppHandle,
input: &str,
start: f64,
duration: f64,
output_path: &str,
) -> Result<(), String> {
let safe_output = sanitize_output_path(output_path)?;
// 输入路径验证:本地文件需要安全检查,URL 直接传递
let safe_input = if input.starts_with("http://") || input.starts_with("https://") {
input.to_string()
} else {
validate_safe_path(input)?
};
let start_str = format!("{:.3}", start);
let duration_str = format!("{:.3}", duration);
let args = vec![
"-ss".to_string(), start_str,
"-t".to_string(), duration_str,
"-i".to_string(), safe_input,
"-vf".to_string(), "fps=30,scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,format=yuv420p".to_string(),
"-c:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "veryfast".to_string(),
"-crf".to_string(), "23".to_string(),
"-c:a".to_string(), "aac".to_string(),
"-ar".to_string(), "44100".to_string(),
"-ac".to_string(), "2".to_string(),
"-r".to_string(), "30".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-avoid_negative_ts".to_string(), "make_zero".to_string(),
"-y".to_string(),
safe_output,
];
run_ffmpeg(app, args).await.map(|_| ())
}
/**
* 转码音频为标准格式 (MP3 44.1kHz stereo 192kbps)
*/
+22
View File
@@ -118,6 +118,10 @@ pub fn run() {
commands::voice::load_voice_materials,
commands::voice::save_voice_material,
commands::voice::delete_voice_material_cmd,
// 视频合成(Phase 2
commands::video_compose::compose_video,
commands::video_compose::upload_video_file,
commands::video_compose::download_file,
// 音频处理
replace_audio_track,
mix_audio_tracks,
@@ -377,3 +381,21 @@ async fn video_composite_synthesis(
data: None,
}
}
// ============================================================
// 错误处理扩展
// ============================================================
/// 将 `Result<T, E>` 的错误转换为 `String`
///
/// 业界主流做法:通过 trait extension 避免 `.map_err(|e| e.to_string())`
/// 闭包参数类型推断失败的问题。
pub trait StringResultExt<T> {
fn map_err_string(self) -> Result<T, String>;
}
impl<T, E: std::fmt::Display> StringResultExt<T> for Result<T, E> {
fn map_err_string(self) -> Result<T, String> {
self.map_err(|e| e.to_string())
}
}
+2
View File
@@ -3,6 +3,8 @@
//! 提供路径净化、原子写入、文件锁三大核心能力,
//! 所有本地 JSON/二进制文件的读写必须通过此层。
#![allow(unused_imports)]
mod engine;
mod paths;
+2 -1
View File
@@ -45,7 +45,8 @@
"active": true,
"targets": "all",
"externalBin": [
"binaries/ffmpeg"
"binaries/ffmpeg",
"binaries/ffprobe"
],
"resources": {
"fonts/*": "fonts/"
@@ -68,6 +68,11 @@ export interface ProjectMeta {
};
selectedPreset?: string;
bgSource?: 'first-frame' | 'ai-generate';
// Fabric.js 新版封面配置
template?: 'dual-title' | 'title-tags';
backgroundImage?: string | null;
mainTitle?: string;
subtitle?: string;
};
scriptDuration?: number; // 脚本生成时的视频时长(秒)
scriptType?: string; // 脚本生成时的脚本类型
+39
View File
@@ -0,0 +1,39 @@
/**
* 空镜素材 API 模块
* =================
*
* 通过后端接口匹配空镜素材,替代本地硬编码配置。
*/
import { client } from '../client';
export interface MaterialInfo {
url: string;
duration: number;
}
export interface MatchMaterialResponse {
code: number;
message: string;
data: MaterialInfo | null;
}
/**
* 根据场景描述和所需时长匹配空镜素材
*
* @param scene 分镜场景描述
* @param duration 所需时长(秒)
* @returns 匹配到的素材信息,无匹配返回 null
*/
export async function matchMaterial(scene: string, duration: number): Promise<MaterialInfo | null> {
const res = await client.post<MatchMaterialResponse>('/materials/match', {
scene,
duration,
});
if (res.code !== 200) {
throw new Error(res.message);
}
return res.data;
}
+84
View File
@@ -0,0 +1,84 @@
/**
* 视频合成 IPC 模块
* =================
*
* Phase 2: 本地 FFmpeg 裁剪拼接 + 上传七牛云
*/
import { invoke } from '@tauri-apps/api/core';
export interface ComposeSegment {
id: string;
type: 'segment' | 'empty_shot';
duration: number;
source: string;
/** 截取起始时间(秒),默认从 0 开始 */
startTime?: number;
}
export interface ComposeVideoResult {
outputPath: string;
duration: number;
}
export interface UploadVideoResult {
url: string;
}
interface ApiResponse<T> {
code: number;
message: string;
data?: T;
}
/**
* 视频合成:裁剪素材片段并拼接
*
* @param projectId 项目 ID
* @param segments 片段列表(segment=人物形象, empty_shot=空镜)
* @returns 合成后的本地视频路径和时长
*/
export async function composeVideo(
projectId: string,
segments: ComposeSegment[]
): Promise<ComposeVideoResult> {
const res = await invoke<ApiResponse<ComposeVideoResult>>('compose_video', {
projectId,
segments,
});
if (res.code !== 200) {
throw new Error(res.message);
}
return res.data!;
}
/**
* 上传本地视频到后端(后端上传到七牛云)
*
* @param localPath 本地视频文件路径
* @returns 七牛云 URL
*/
export async function uploadVideoFile(localPath: string): Promise<string> {
const res = await invoke<ApiResponse<UploadVideoResult>>('upload_video_file', {
localPath,
});
if (res.code !== 200) {
throw new Error(res.message);
}
return res.data!.url;
}
/**
* 从 URL 下载文件到本地
*
* @param url 文件 URL
* @param outputPath 本地保存路径
* @returns 本地文件路径
*/
export async function downloadFile(url: string, outputPath: string): Promise<string> {
const res = await invoke<ApiResponse<string>>('download_file', { url, outputPath });
if (res.code !== 200) {
throw new Error(res.message);
}
return res.data!;
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Vidu 对口型 API 模块
* ====================
*
* 提供对口型任务提交和状态查询功能。
*/
import { client } from '../client';
export interface LipSyncRequest {
videoUrl: string;
audioUrl?: string;
text?: string;
voiceId?: string;
speed?: number;
volume?: number;
refPhotoUrl?: string;
}
export interface LipSyncResponse {
taskId: string;
message: string;
}
export interface LipSyncQueryResponse {
taskId: string;
state: string;
videoUrl: string | null;
message: string | null;
creations: Array<{
url: string;
[key: string]: unknown;
}> | null;
}
/**
* 提交 Vidu 对口型任务
*/
export async function submitLipSync(request: LipSyncRequest): Promise<LipSyncResponse> {
return client.post<LipSyncResponse>('/vidu/lip-sync', request);
}
/**
* 查询 Vidu 对口型任务状态
*/
export async function queryLipSyncTask(taskId: string): Promise<LipSyncQueryResponse> {
return client.get<LipSyncQueryResponse>(`/vidu/tasks/${taskId}/creations`);
}
+15 -45
View File
@@ -92,8 +92,10 @@ export interface VoiceMaterial {
export interface AvatarMaterial {
id: string;
name: string;
videoUrl: string; // 七牛云视频 URL
createdAt: string;
filename: string;
path: string; // 本地文件路径(或 http URL
duration: number;
uploadedAt: string;
}
// ====================== 音频文件管理类型 ======================
@@ -182,7 +184,17 @@ export async function loadVoiceMaterials(): Promise<VoiceMaterial[]> {
/** 保存音色素材到本地 */
export async function saveVoiceMaterial(material: VoiceMaterial): Promise<void> {
const result = await invoke<{ code: number; message: string }>('save_voice_material', material);
const result = await invoke<{ code: number; message: string }>('save_voice_material', {
args: {
id: material.id,
name: material.name,
voiceId: material.voiceId,
sourceUrl: material.sourceUrl,
trialUrl: material.trialUrl,
status: material.status,
createdAt: material.createdAt,
},
});
if (result.code !== 200) {
throw new Error(result.message || '保存素材失败');
}
@@ -196,48 +208,6 @@ export async function deleteVoiceMaterial(materialId: string): Promise<void> {
}
}
// ====================== 视频素材库 API(复用 avatar.json=====================
export async function loadAvatarMaterials(): Promise<AvatarMaterial[]> {
const result = await invoke<{ code: number; data?: AvatarMaterial[]; message: string }>('load_avatars_list');
if (result.code !== 200) {
throw new Error(result.message || '加载视频素材失败');
}
// avatar.json 存的可能是数组,需要转换
const raw = result.data || [];
return raw.map((item: AvatarMaterial) => ({
id: item.id,
name: item.name,
videoUrl: item.videoUrl,
createdAt: item.createdAt,
}));
}
export async function saveAvatarMaterial(material: AvatarMaterial): Promise<void> {
const list = await loadAvatarMaterials();
const exists = list.findIndex(m => m.id === material.id);
const updated = exists >= 0
? list.map((m, i) => i === exists ? material : m)
: [material, ...list];
const result = await invoke<{ code: number; message: string }>('save_avatars_list', {
avatars: updated,
});
if (result.code !== 200) {
throw new Error(result.message || '保存视频素材失败');
}
}
export async function deleteAvatarMaterial(materialId: string): Promise<void> {
const list = await loadAvatarMaterials();
const filtered = list.filter(m => m.id !== materialId);
const result = await invoke<{ code: number; message: string }>('save_avatars_list', {
avatars: filtered,
});
if (result.code !== 200) {
throw new Error(result.message || '删除视频素材失败');
}
}
// ====================== 本地音频文件管理(Tauri IPC ======================
/** 保存音频文件到本地 */
+1 -1
View File
@@ -20,7 +20,7 @@ const navItems: NavItem[] = [
label: '内容管理',
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
children: [
{ id: 'voice-material', label: '我的素材' },
{ id: 'voice-material', label: '声音克隆' },
{ id: 'my-works', label: '我的作品' },
],
},
+12
View File
@@ -0,0 +1,12 @@
/**
* 空镜素材映射表(已迁移到后端)
* ==============================
*
* 空镜素材配置和匹配逻辑已迁移到后端,通过 `/api/v1/materials/match` 接口获取。
* 本文件保留类型定义和导出,实际调用在 `api/modules/materials.ts` 中。
*/
export interface MaterialInfo {
url: string;
duration: number;
}
+417
View File
@@ -0,0 +1,417 @@
/**
* Fabric.js 封面渲染 Hook
* ========================
*
* 使用 Fabric.js v6 在 Canvas 上渲染封面预览,支持:
* - 背景图加载(居中裁剪填充)
* - 双标题模板 / 标题+标签模板
* - 文字描边阴影效果
* - PNG 导出
*/
import { useRef, useCallback, useEffect } from 'react';
import { Canvas, FabricText, FabricImage, Rect, Group, Shadow } from 'fabric';
import { readFile } from '@tauri-apps/plugin-fs';
import { homeDir } from '@tauri-apps/api/path';
export type CoverTemplate = 'dual-title' | 'title-tags';
export interface CoverDesignConfig {
template: CoverTemplate;
backgroundImage: string | null;
mainTitle: string;
subtitle: string;
}
const CANVAS_WIDTH = 1080;
const CANVAS_HEIGHT = 1920;
interface TemplateDef {
mainTitle: {
fontSize: number;
fill: string;
top: number;
fontWeight: string;
shadow: Shadow;
};
subtitle: {
fontSize: number;
fill: string;
top: number;
shadow: Shadow;
};
}
const FONT_FAMILY = '"DouyinSans", "PingFang SC", "Microsoft YaHei", sans-serif';
/**
* 将 Docker 容器路径转换为宿主机路径
*/
async function resolveHostPath(dockerPath: string): Promise<string> {
if (dockerPath.startsWith('/root/')) {
const home = await homeDir();
return dockerPath.replace(/^\/root/, home || '/Users');
}
return dockerPath;
}
/**
* 加载本地图片文件为 HTMLImageElement
* 使用 readFile + Blob URL 绕过 asset:// 协议在 macOS 上的 404 问题
*/
async function loadLocalImage(imagePath: string): Promise<HTMLImageElement> {
const hostPath = await resolveHostPath(imagePath);
const data = await readFile(hostPath);
const blob = new Blob([data]);
const url = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
resolve(img);
URL.revokeObjectURL(url);
};
img.onerror = (err) => {
URL.revokeObjectURL(url);
reject(err);
};
img.src = url;
});
}
const TEMPLATES: Record<CoverTemplate, TemplateDef> = {
'dual-title': {
mainTitle: {
fontSize: 72,
fill: '#FFD700',
top: 672, // 1920 * 0.35
fontWeight: 'bold',
shadow: new Shadow({
color: 'rgba(0,0,0,0.85)',
blur: 8,
offsetX: 3,
offsetY: 3,
}),
},
subtitle: {
fontSize: 32,
fill: '#FFFFFF',
top: 1152, // 1920 * 0.60
shadow: new Shadow({
color: 'rgba(0,0,0,0.7)',
blur: 6,
offsetX: 2,
offsetY: 2,
}),
},
},
'title-tags': {
mainTitle: {
fontSize: 72,
fill: '#FFD700',
top: 576, // 1920 * 0.30
fontWeight: 'bold',
shadow: new Shadow({
color: 'rgba(0,0,0,0.85)',
blur: 8,
offsetX: 3,
offsetY: 3,
}),
},
subtitle: {
fontSize: 28,
fill: '#FFFFFF',
top: 1382, // 1920 * 0.72 (标签组中心)
shadow: new Shadow({
color: 'rgba(0,0,0,0.6)',
blur: 4,
offsetX: 1,
offsetY: 1,
}),
},
},
};
function wrapTextByWidth(
text: string,
maxWidth: number,
fontSize: number
): string[] {
if (!text.trim()) return [];
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
ctx.font = `${fontSize}px ${FONT_FAMILY}`;
const chars = text.split('');
const lines: string[] = [];
let currentLine = '';
for (const char of chars) {
const testLine = currentLine + char;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine.length > 0) {
lines.push(currentLine);
currentLine = char;
} else {
currentLine = testLine;
}
}
if (currentLine) lines.push(currentLine);
return lines;
}
export function useCoverFabric() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const fabricCanvasRef = useRef<Canvas | null>(null);
// 初始化 Fabric Canvas
const initCanvas = useCallback(() => {
if (!canvasRef.current || fabricCanvasRef.current) return;
const canvas = new Canvas(canvasRef.current, {
width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
backgroundColor: '#1a1a2e',
selection: false,
interactive: false,
});
fabricCanvasRef.current = canvas;
}, []);
// 加载背景图
const loadBackground = useCallback(
async (canvas: Canvas, imagePath: string): Promise<void> => {
try {
const img = await loadLocalImage(imagePath);
const fabricImg = new FabricImage(img);
const scale = Math.max(
CANVAS_WIDTH / (fabricImg.width || 1),
CANVAS_HEIGHT / (fabricImg.height || 1)
);
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);
canvas.renderAll();
} catch (err) {
console.warn('[useCoverFabric] 背景图加载失败:', err);
}
},
[]
);
// 渲染标签列表
const renderTagList = useCallback(
(canvas: Canvas, tagsText: string, template: TemplateDef) => {
const tags = tagsText
.split(/[,]/)
.map((t) => t.trim())
.filter(Boolean);
if (tags.length === 0) return;
const tagHeight = 56;
const tagPaddingH = 20;
const gapX = 16;
const gapY = 16;
const maxLineWidth = CANVAS_WIDTH - 120;
const tagObjects: Group[] = [];
let currentX = 0;
let currentY = 0;
for (const tag of tags) {
const textObj = new FabricText(tag, {
fontSize: 28,
fill: '#FFFFFF',
fontFamily: FONT_FAMILY,
originX: 'center',
originY: 'center',
selectable: false,
evented: false,
});
const rect = new Rect({
width: (textObj.width || 0) + tagPaddingH * 2,
height: tagHeight,
fill: 'rgba(0,0,0,0.5)',
rx: 8,
ry: 8,
originX: 'center',
originY: 'center',
selectable: false,
evented: false,
});
const group = new Group([rect, textObj], {
left: currentX,
top: currentY,
selectable: false,
evented: false,
});
// 换行检查
if (
currentX + (group.width || 0) > maxLineWidth &&
currentX > 0
) {
currentX = 0;
currentY += tagHeight + gapY;
group.set({ left: currentX, top: currentY });
}
currentX += (group.width || 0) + gapX;
tagObjects.push(group);
}
// 整体居中
const allTags = new Group(tagObjects, {
left: CANVAS_WIDTH / 2,
top: template.subtitle.top,
originX: 'center',
originY: 'center',
selectable: false,
evented: false,
});
canvas.add(allTags);
},
[]
);
// 渲染封面
const renderCover = useCallback(
async (config: CoverDesignConfig) => {
const canvas = fabricCanvasRef.current;
if (!canvas) return;
canvas.clear();
canvas.backgroundColor = '#1a1a2e';
// 1. 背景图
if (config.backgroundImage) {
try {
await loadBackground(canvas, config.backgroundImage);
} catch (err) {
console.warn('[useCoverFabric] 背景图加载失败:', err);
}
}
const template = TEMPLATES[config.template];
// 2. 主标题(自动换行,最多2行)
if (config.mainTitle.trim()) {
const maxWidth = CANVAS_WIDTH - 160; // 左右各80px边距
const lines = wrapTextByWidth(
config.mainTitle.trim(),
maxWidth,
template.mainTitle.fontSize
);
const displayLines = lines.slice(0, 2); // 最多2行
const lineHeight = template.mainTitle.fontSize * 1.3;
const totalHeight =
displayLines.length * lineHeight - lineHeight * 0.3;
displayLines.forEach((line, i) => {
const text = new FabricText(line, {
left: CANVAS_WIDTH / 2,
top:
template.mainTitle.top -
totalHeight / 2 +
i * lineHeight +
lineHeight / 2,
fontSize: template.mainTitle.fontSize,
fill: template.mainTitle.fill,
fontWeight: template.mainTitle.fontWeight,
fontFamily: FONT_FAMILY,
textAlign: 'center',
originX: 'center',
originY: 'center',
selectable: false,
evented: false,
shadow: template.mainTitle.shadow,
});
canvas.add(text);
});
}
// 3. 副标题 / 标签
if (config.subtitle.trim()) {
if (config.template === 'dual-title') {
// 副标题:自动换行,最多3行
const maxWidth = CANVAS_WIDTH - 200;
const lines = wrapTextByWidth(
config.subtitle.trim(),
maxWidth,
template.subtitle.fontSize
);
const displayLines = lines.slice(0, 3);
const lineHeight = template.subtitle.fontSize * 1.4;
const totalHeight =
displayLines.length * lineHeight - lineHeight * 0.4;
displayLines.forEach((line, i) => {
const text = new FabricText(line, {
left: CANVAS_WIDTH / 2,
top:
template.subtitle.top -
totalHeight / 2 +
i * lineHeight +
lineHeight / 2,
fontSize: template.subtitle.fontSize,
fill: template.subtitle.fill,
fontFamily: FONT_FAMILY,
textAlign: 'center',
originX: 'center',
originY: 'center',
selectable: false,
evented: false,
shadow: template.subtitle.shadow,
});
canvas.add(text);
});
} else {
// 标签列表
renderTagList(canvas, config.subtitle, template);
}
}
canvas.renderAll();
},
[loadBackground, renderTagList]
);
// 导出 PNG
const exportPng = useCallback((): string => {
const canvas = fabricCanvasRef.current;
if (!canvas) return '';
return canvas.toDataURL({ format: 'png', quality: 1, multiplier: 1 });
}, []);
// 销毁
const destroy = useCallback(() => {
fabricCanvasRef.current?.dispose();
fabricCanvasRef.current = null;
}, []);
// 组件卸载时清理
useEffect(() => {
return () => {
destroy();
};
}, [destroy]);
return {
canvasRef,
initCanvas,
renderCover,
exportPng,
destroy,
};
}
@@ -1371,6 +1371,107 @@
font-size: var(--font-xs);
}
/* ============================================================
声音克隆页面样式
============================================================ */
.voice-clone-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-xl);
padding: 14px 20px;
background: var(--bg-card);
border: 1.5px dashed var(--border-color);
border-radius: var(--radius-lg);
}
.voice-clone-title-group {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.voice-clone-title-group h2 {
font-size: var(--font-xl);
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.voice-clone-desc {
font-size: var(--font-sm);
color: var(--text-secondary);
margin: 0;
}
.voice-upload-card {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: 12px 18px;
background: #f0fdf4;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
}
.voice-upload-card:hover {
background: #ecfdf5;
transform: translateY(-1px);
}
.voice-upload-icon {
width: 52px;
height: 52px;
border-radius: 12px;
background: #10b981;
display: flex;
align-items: center;
justify-content: center;
color: white;
flex-shrink: 0;
}
.voice-upload-text {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.voice-upload-title {
font-size: var(--font-base);
font-weight: 600;
color: var(--text-primary);
}
.voice-upload-hint {
font-size: var(--font-xs);
color: var(--text-secondary);
}
.voice-upload-arrow {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: transparent;
display: flex;
align-items: center;
justify-content: center;
color: #10b981;
flex-shrink: 0;
transition: all 0.2s ease;
}
.voice-upload-card:hover .voice-upload-arrow {
transform: translateX(4px);
}
/* End 声音克隆页面样式 */
/* Empty State */
.empty-state {
display: flex;
@@ -2,9 +2,8 @@
*
* ==========
*
*
* - mp3/wav Kling voices.json
* - mp4/mov avatar.json
*
* mp3/wav Kling voices.json
*/
import { useState, useEffect, useRef, useCallback } from 'react';
@@ -17,14 +16,12 @@ import ConfirmModal from '../../components/Modal/ConfirmModal';
import './ContentManagement.css';
export default function VoiceMaterialLibrary() {
const [activeTab, setActiveTab] = useState<'audio' | 'video'>('audio');
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadName, setUploadName] = useState('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
// 分页
const [audioPage, setAudioPage] = useState(1);
const [videoPage, setVideoPage] = useState(1);
const pageSize = 20;
// 重命名状态
@@ -33,7 +30,7 @@ export default function VoiceMaterialLibrary() {
// 删除确认状态
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string; type: 'audio' | 'video' } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const pollingIds = useRef<Set<string>>(new Set());
@@ -88,24 +85,17 @@ export default function VoiceMaterialLibrary() {
const {
voiceMaterials,
avatarMaterials,
isLoadingMaterials,
isLoadingAvatarMaterials,
loadVoiceMaterials,
loadAvatarMaterials,
addVoiceMaterial,
addAvatarMaterial,
renameVoiceMaterial,
renameAvatarMaterial,
deleteVoiceMaterial,
deleteAvatarMaterial,
updateVoiceMaterialStatus,
} = useVoiceStore();
// 加载数据
useEffect(() => {
loadVoiceMaterials();
loadAvatarMaterials();
}, []);
// 轮询 pending/processing 状态的音频素材
@@ -194,57 +184,12 @@ export default function VoiceMaterialLibrary() {
});
};
// 视频文件验证
const validateVideoFile = (file: File): Promise<{ valid: boolean; error?: string }> => {
return new Promise(resolve => {
const allowedExts = ['.mp4', '.mov'];
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
if (!allowedExts.includes(ext)) {
resolve({ valid: false, error: '仅支持 MP4、MOV 格式' });
return;
}
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
const duration = video.duration;
URL.revokeObjectURL(video.src);
if (duration < 3) {
resolve({ valid: false, error: `视频时长 ${duration.toFixed(1)} 秒,要求至少 3 秒` });
return;
}
if (duration > 10) {
resolve({ valid: false, error: `视频时长 ${duration.toFixed(1)} 秒,要求不超过 10 秒` });
return;
}
resolve({ valid: true });
};
video.onerror = () => {
URL.revokeObjectURL(video.src);
resolve({ valid: false, error: '无法读取视频文件' });
};
setTimeout(() => {
URL.revokeObjectURL(video.src);
resolve({ valid: false, error: '读取视频超时' });
}, 8000);
video.src = URL.createObjectURL(file);
});
};
// 文件选择
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const validation = activeTab === 'audio'
? await validateAudioFile(file)
: await validateVideoFile(file);
const validation = await validateAudioFile(file);
if (!validation.valid) {
toast.error(validation.error || '文件验证失败');
e.target.value = '';
@@ -252,7 +197,7 @@ export default function VoiceMaterialLibrary() {
}
setSelectedFile(file);
}, [activeTab]);
}, []);
// 上传处理
const handleUpload = useCallback(async () => {
@@ -261,45 +206,30 @@ export default function VoiceMaterialLibrary() {
const progress = useProgressStore.getState();
setUploadModalOpen(false);
if (activeTab === 'audio') {
progress.show('上传素材');
try {
progress.update('文件校验中...');
await addVoiceMaterial(selectedFile, uploadName.trim());
progress.update('正在生成专属音色...');
progress.success('提交成功');
} catch (err) {
progress.error(err instanceof Error ? err.message : '上传失败');
}
} else {
progress.show('上传素材');
try {
progress.update('文件校验中...');
await addAvatarMaterial(selectedFile, uploadName.trim());
progress.success('上传成功');
} catch (err) {
progress.error(err instanceof Error ? err.message : '上传失败');
}
progress.show('上传素材');
try {
progress.update('文件校验中...');
await addVoiceMaterial(selectedFile, uploadName.trim());
progress.update('正在生成专属音色...');
progress.success('提交成功');
} catch (err) {
progress.error(err instanceof Error ? err.message : '上传失败');
}
setUploadName('');
setSelectedFile(null);
}, [activeTab, uploadName, selectedFile, addVoiceMaterial, addAvatarMaterial]);
}, [uploadName, selectedFile, addVoiceMaterial]);
// 删除处理
const openDeleteModal = (id: string, name: string, type: 'audio' | 'video') => {
setDeleteTarget({ id, name, type });
const openDeleteModal = (id: string, name: string) => {
setDeleteTarget({ id, name });
setDeleteModalOpen(true);
};
const handleConfirmDelete = useCallback(async () => {
if (!deleteTarget) return;
try {
if (deleteTarget.type === 'audio') {
await deleteVoiceMaterial(deleteTarget.id);
} else {
await deleteAvatarMaterial(deleteTarget.id);
}
await deleteVoiceMaterial(deleteTarget.id);
toast.success('已删除');
} catch {
toast.error('删除失败');
@@ -307,7 +237,7 @@ export default function VoiceMaterialLibrary() {
setDeleteModalOpen(false);
setDeleteTarget(null);
}
}, [deleteTarget, deleteVoiceMaterial, deleteAvatarMaterial]);
}, [deleteTarget, deleteVoiceMaterial]);
const statusLabel = (status: string) => {
switch (status) {
@@ -319,16 +249,6 @@ export default function VoiceMaterialLibrary() {
}
};
const statusColor = (status: string) => {
switch (status) {
case 'ready': return '#22c55e';
case 'pending': return 'var(--text-secondary)';
case 'processing': return '#f59e0b';
case 'failed': return '#ef4444';
default: return 'var(--text-secondary)';
}
};
const startRename = (id: string, currentName: string) => {
setEditingId(id);
setEditingName(currentName);
@@ -345,78 +265,65 @@ export default function VoiceMaterialLibrary() {
return;
}
try {
if (activeTab === 'audio') {
await renameVoiceMaterial(editingId, editingName.trim());
} else {
await renameAvatarMaterial(editingId, editingName.trim());
}
await renameVoiceMaterial(editingId, editingName.trim());
setEditingId(null);
setEditingName('');
} catch {
toast.error('重命名失败');
}
}, [editingId, editingName, activeTab, renameVoiceMaterial, renameAvatarMaterial]);
}, [editingId, editingName, renameVoiceMaterial]);
return (
<div className="content-page">
<div className="content-header">
<h2></h2>
</div>
{/* Tab + 上传按钮 */}
<div
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, marginBottom: 'var(--spacing-md)' }}
>
<div style={{ display: 'flex', gap: 0, borderBottom: 0 }}>
<button
className={`voice-tab ${activeTab === 'audio' ? 'active' : ''}`}
onClick={() => { setActiveTab('audio'); setSelectedFile(null); setUploadName(''); }}
>
({voiceMaterials.length})
</button>
<button
className={`voice-tab ${activeTab === 'video' ? 'active' : ''}`}
onClick={() => { setActiveTab('video'); setSelectedFile(null); setUploadName(''); }}
>
({avatarMaterials.length})
</button>
{/* 页面标题和上传区域 */}
<div className="voice-clone-wrapper">
<div className="voice-clone-title-group">
<h2></h2>
<p className="voice-clone-desc">AI </p>
</div>
<button
className="btn btn-primary"
{/* 上传引导卡片 */}
<div
className="voice-upload-card"
onClick={() => setUploadModalOpen(true)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
borderRadius: 6,
flexShrink: 0,
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
{activeTab === 'audio' ? '音频' : '视频'}
</button>
<div className="voice-upload-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" y1="19" x2="12" y2="22" />
<line x1="8" y1="22" x2="16" y2="22" />
</svg>
</div>
<div className="voice-upload-text">
<span className="voice-upload-title"></span>
<span className="voice-upload-hint">MP3 / M4A / WAV 30s ~ 3min</span>
</div>
<div className="voice-upload-arrow">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
</div>
</div>
</div>
{/* 上传弹窗 */}
<Modal
open={uploadModalOpen}
onClose={() => setUploadModalOpen(false)}
title={`上传${activeTab === 'audio' ? '音频' : '视频'}素材`}
title="上传音频样本"
width="480px"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<label style={{ fontSize: 'var(--font-sm)', fontWeight: 500, marginBottom: 8, display: 'block' }}>
</label>
<input
type="text"
className="input"
placeholder={`例如:我的${activeTab === 'audio' ? '声音' : '形象'}`}
placeholder="例如:我的声音"
value={uploadName}
onChange={e => setUploadName(e.target.value)}
style={{ width: '100%' }}
@@ -443,7 +350,7 @@ export default function VoiceMaterialLibrary() {
<input
ref={fileInputRef}
type="file"
accept={activeTab === 'audio' ? '.mp3,.m4a,.wav' : '.mp4,.mov'}
accept=".mp3,.m4a,.wav"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
@@ -458,9 +365,7 @@ export default function VoiceMaterialLibrary() {
<div style={{ color: 'var(--text-secondary)' }}>
<div style={{ fontSize: 'var(--font-sm)' }}></div>
<div style={{ fontSize: 'var(--font-xs)', marginTop: 6, lineHeight: 1.6 }}>
{activeTab === 'audio'
? '支持 MP3 / M4A / WAV,人声干净无杂音,时长 10 秒 ~ 5 分钟,不超过 20MB'
: '支持 MP4 / MOV,人物正面视频,时长 3-10 秒'}
MP3 / M4A / WAV 10 ~ 5 20MB
</div>
</div>
)}
@@ -481,157 +386,29 @@ export default function VoiceMaterialLibrary() {
</Modal>
{/* 音频列表 */}
{activeTab === 'audio' && (
isLoadingMaterials ? (
<p style={{ color: 'var(--text-secondary)' }}>...</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, flex: 1, overflow: 'auto' }}>
<div className="voice-list" style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12, alignContent: 'start', alignItems: 'start' }}>
{voiceMaterials.length === 0 && (
<div className="empty-state" style={{ gridColumn: '1 / -1', minHeight: 300 }}>
<div className="empty-state-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
</svg>
</div>
<p className="empty-state-title"></p>
<p className="empty-state-desc"><br /></p>
</div>
)}
{voiceMaterials.slice((audioPage - 1) * pageSize, audioPage * pageSize).map(m => (
<div key={m.id} className="voice-row" style={{ cursor: 'default' }}>
<div className="voice-row-main">
<div className="voice-row-info">
{editingId === m.id ? (
<input
type="text"
className="input"
value={editingName}
onChange={e => setEditingName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') confirmRename();
if (e.key === 'Escape') cancelRename();
}}
onBlur={confirmRename}
autoFocus
style={{ width: '100%', height: 28, padding: '2px 8px', fontSize: 'var(--font-sm)' }}
/>
) : (
<div className="voice-row-name">
{m.name}
<span className="voice-row-desc-inline">{statusLabel(m.status)}</span>
</div>
)}
</div>
<div className="voice-row-actions">
{m.sourceUrl && (
<button
className="preview-icon"
onClick={e => {
e.stopPropagation();
togglePlay(m.id, m.sourceUrl);
}}
title={playingId === m.id ? '暂停' : '播放'}
>
{playingId === m.id ? '⏸' : '▶'}
</button>
)}
<button
className="action-icon"
onClick={() => startRename(m.id, m.name)}
title="重命名"
>
</button>
<button
className="action-icon"
onClick={() => openDeleteModal(m.id, m.name, 'audio')}
title="删除"
>
</button>
</div>
</div>
</div>
))}
</div>
{voiceMaterials.length > pageSize && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 12, padding: '8px 0' }}>
<button
className="btn btn-secondary"
style={{ padding: '4px 12px', fontSize: 'var(--font-sm)' }}
onClick={() => setAudioPage(p => Math.max(1, p - 1))}
disabled={audioPage <= 1}
>
</button>
<span style={{ fontSize: 'var(--font-sm)', color: 'var(--text-secondary)' }}>
{audioPage} / {Math.ceil(voiceMaterials.length / pageSize)}
</span>
<button
className="btn btn-secondary"
style={{ padding: '4px 12px', fontSize: 'var(--font-sm)' }}
onClick={() => setAudioPage(p => Math.min(Math.ceil(voiceMaterials.length / pageSize), p + 1))}
disabled={audioPage >= Math.ceil(voiceMaterials.length / pageSize)}
>
</button>
</div>
)}
</div>
)
)}
{/* 视频列表 */}
{activeTab === 'video' && (
isLoadingAvatarMaterials ? (
<p style={{ color: 'var(--text-secondary)' }}>...</p>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 16, flex: 1 }}>
{avatarMaterials.length === 0 && (
<div className="empty-state" style={{ gridColumn: '1 / -1', minHeight: 300, flex: 1 }}>
{isLoadingMaterials ? (
<p style={{ color: 'var(--text-secondary)' }}>...</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, flex: 1, overflow: 'auto' }}>
<div className="voice-list" style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12, alignContent: 'start', alignItems: 'start' }}>
{voiceMaterials.length === 0 && (
<div className="empty-state" style={{ gridColumn: '1 / -1', minHeight: 200 }}>
<div className="empty-state-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18" />
<line x1="7" y1="2" x2="7" y2="22" />
<line x1="17" y1="2" x2="17" y2="22" />
<line x1="2" y1="12" x2="22" y2="12" />
<line x1="2" y1="7" x2="7" y2="7" />
<line x1="2" y1="17" x2="7" y2="17" />
<line x1="17" y1="17" x2="22" y2="17" />
<line x1="17" y1="7" x2="22" y2="7" />
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
</svg>
</div>
<p className="empty-state-title"></p>
<p className="empty-state-desc"><br /></p>
<p className="empty-state-title"></p>
<p className="empty-state-desc"><br />AI </p>
</div>
)}
{avatarMaterials.map(m => (
<div
key={m.id}
style={{
padding: 'var(--spacing-sm)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-color)',
background: 'var(--bg-card)',
}}
>
<video
src={m.videoUrl}
controls
style={{
width: '100%',
borderRadius: 'var(--radius-sm)',
aspectRatio: '9/16',
objectFit: 'cover',
background: '#000',
}}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 6, gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
{voiceMaterials.slice((audioPage - 1) * pageSize, audioPage * pageSize).map(m => (
<div key={m.id} className="voice-row" style={{ cursor: 'default' }}>
<div className="voice-row-main">
<div className="voice-row-info">
{editingId === m.id ? (
<input
type="text"
@@ -644,43 +421,71 @@ export default function VoiceMaterialLibrary() {
}}
onBlur={confirmRename}
autoFocus
style={{ width: '100%', height: 26, padding: '2px 8px', fontSize: 'var(--font-sm)' }}
style={{ width: '100%', height: 28, padding: '2px 8px', fontSize: 'var(--font-sm)' }}
/>
) : (
<span style={{ fontWeight: 500, fontSize: 'var(--font-sm)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
<div className="voice-row-name">
{m.name}
</span>
<span className="voice-row-desc-inline">{statusLabel(m.status)}</span>
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
<div className="voice-row-actions">
{m.sourceUrl && (
<button
className="preview-icon"
onClick={e => {
e.stopPropagation();
togglePlay(m.id, m.sourceUrl);
}}
title={playingId === m.id ? '暂停' : '播放'}
>
{playingId === m.id ? '⏸' : '▶'}
</button>
)}
<button
className="btn btn-ghost"
style={{ padding: '4px', width: 26, height: 26 }}
className="action-icon"
onClick={() => startRename(m.id, m.name)}
title="重命名"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
className="btn btn-ghost"
style={{ padding: '4px', width: 26, height: 26, color: 'var(--text-tertiary)' }}
onClick={() => openDeleteModal(m.id, m.name, 'video')}
className="action-icon"
onClick={() => openDeleteModal(m.id, m.name)}
title="删除"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</div>
</div>
))}
</div>
)
{voiceMaterials.length > pageSize && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 12, padding: '8px 0' }}>
<button
className="btn btn-secondary"
style={{ padding: '4px 12px', fontSize: 'var(--font-sm)' }}
onClick={() => setAudioPage(p => Math.max(1, p - 1))}
disabled={audioPage <= 1}
>
</button>
<span style={{ fontSize: 'var(--font-sm)', color: 'var(--text-secondary)' }}>
{audioPage} / {Math.ceil(voiceMaterials.length / pageSize)}
</span>
<button
className="btn btn-secondary"
style={{ padding: '4px 12px', fontSize: 'var(--font-sm)' }}
onClick={() => setAudioPage(p => Math.min(Math.ceil(voiceMaterials.length / pageSize), p + 1))}
disabled={audioPage >= Math.ceil(voiceMaterials.length / pageSize)}
>
</button>
</div>
)}
</div>
)}
{/* 删除确认弹窗 */}
@@ -371,3 +371,66 @@
.cover-title-line {
white-space: nowrap;
}
/* ===== Fabric.js 新版封面样式 ===== */
/* 模板选择 */
.template-selector {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-sm);
}
.template-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-card);
cursor: pointer;
transition: all var(--transition-fast);
}
.template-btn:hover {
border-color: var(--primary);
background: var(--bg-hover);
}
.template-btn.active {
border-color: var(--primary);
background: var(--primary-light);
}
.template-btn-name {
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-primary);
}
.template-btn.active .template-btn-name {
color: var(--primary);
}
.template-btn-desc {
font-size: var(--font-xs);
color: var(--text-tertiary);
}
/* 背景图控制 */
.bg-image-control {
display: flex;
align-items: center;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
.bg-preview-thumb {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: var(--radius-md);
border: 1px solid var(--border-light);
}
+235 -326
View File
@@ -1,256 +1,181 @@
/**
* (Step 5)
* =================
* (Step 5) - Fabric.js
* ================================
*
* +
* 1. 2. Kling Omni-Image AI
*
* 使 Fabric.js Canvas
* -
* - / +
* -
* - PNG
*/
import { useState, useRef, useEffect, useMemo } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-dialog';
import { readFile } from '@tauri-apps/plugin-fs';
import { useProjectStore } from '../../store';
import { getCurrentProjectId } from '../../api/modules/localStorage';
import { useLocalVideo } from '../../hooks/useLocalVideo';
import { generateCoverAss, saveAssFile, htmlColorToAss, cssColorToAss } from '../../utils/assGenerator';
import { useCoverFabric, type CoverTemplate } from '../../hooks/useCoverFabric';
import { useProgressStore } from '../../store/progressStore';
import './CoverDesign.css';
import '../../components/Slider/Slider.css';
interface CoverStyle {
fontSize: number;
color: string;
strokeColor: string;
strokeWidth: number;
shadow?: number; // 阴影偏移量(0=无阴影)
backgroundColor?: string; // 背景色(存在时用 BorderStyle: 3 色块,忽略描边和阴影)
}
// 预设样式(4个字命名,按实际效果)
const STYLE_PRESETS = [
{ id: 'white', name: '白字暗底', color: '#FFFFFF', strokeColor: '#000000', strokeWidth: 0, backgroundColor: 'rgba(0,0,0,0.5)' },
{ id: 'black', name: '白字黑边', color: '#FFFFFF', strokeColor: '#000000', strokeWidth: 3 },
{ id: 'yellow', name: '黄字黑边', color: '#FFD700', strokeColor: '#000000', strokeWidth: 3 },
{ id: 'neon', name: '黑字黄底', color: '#000000', strokeColor: '#000000', strokeWidth: 0, backgroundColor: '#FFD700' },
const TEMPLATE_OPTIONS: { id: CoverTemplate; name: string; desc: string }[] = [
{ id: 'dual-title', name: '双标题居中', desc: '主标题+副标题,垂直居中排列' },
{ id: 'title-tags', name: '标题+标签', desc: '主标题+标签列表,适合多关键词' },
];
const VIDEO_HEIGHT = 1920;
const MARGIN_V = 360;
const MARGIN_LR = 108;
/**
* CSS text-shadow -webkit-text-stroke libass
*/
function generateTextShadow(strokeColor: string, strokeWidthPx: number, shadowPx: number): string | undefined {
if (strokeWidthPx <= 0 && shadowPx <= 0) return undefined;
const shadows: string[] = [];
const w = Math.max(0, Math.round(strokeWidthPx));
for (let r = 1; r <= w; r++) {
shadows.push(`${-r}px ${-r}px 0 ${strokeColor}`);
shadows.push(`${r}px ${-r}px 0 ${strokeColor}`);
shadows.push(`${-r}px ${r}px 0 ${strokeColor}`);
shadows.push(`${r}px ${r}px 0 ${strokeColor}`);
shadows.push(`${-r}px 0 0 ${strokeColor}`);
shadows.push(`${r}px 0 0 ${strokeColor}`);
shadows.push(`0 ${-r}px 0 ${strokeColor}`);
shadows.push(`0 ${r}px 0 ${strokeColor}`);
}
if (shadowPx > 0) {
const s = Math.round(shadowPx);
shadows.push(`${s}px ${s}px ${s}px ${strokeColor}`);
}
return shadows.join(', ');
}
/**
*
*/
function wrapText(text: string, maxChars: number): string {
if (!text || maxChars <= 0) {
return text;
}
// 先按用户手动换行拆分成段落
const paragraphs = text.split('\n');
const wrappedParagraphs = paragraphs.map((paragraph) => {
const lines: string[] = [];
let current = '';
for (const char of paragraph) {
if (current.length >= maxChars) {
lines.push(current);
current = char;
} else {
current += char;
}
}
if (current) {
lines.push(current);
}
return lines.join('\\N');
});
return wrappedParagraphs.join('\\N');
}
export default function CoverDesign() {
const segments = useProjectStore(state => state.segments);
const topic = useProjectStore(state => state.topic);
const coverConfig = useProjectStore(state => state.coverConfig);
const setCoverPath = useProjectStore(state => state.setCoverPath);
const setCoverConfig = useProjectStore(state => state.setCoverConfig);
const coverConfig = useProjectStore((state) => state.coverConfig);
const setCoverPath = useProjectStore((state) => state.setCoverPath);
const setCoverConfig = useProjectStore((state) => state.setCoverConfig);
const projectId = getCurrentProjectId();
const [caption, setCaption] = useState(topic || '');
const [coverStyle, setCoverStyle] = useState<CoverStyle>({
fontSize: 72,
color: '#FFFFFF',
strokeColor: '#000000',
strokeWidth: 2,
const [config, setConfig] = useState<{
template: CoverTemplate;
backgroundImage: string | null;
mainTitle: string;
subtitle: string;
}>({
template: 'dual-title',
backgroundImage: null,
mainTitle: '',
subtitle: '',
});
const [selectedPreset, setSelectedPreset] = useState('white');
// 防抖后的样式值 — 用于预览渲染,避免滑块拖动时频繁更新
const [debouncedCoverStyle, setDebouncedCoverStyle] = useState(coverStyle);
const { canvasRef, initCanvas, renderCover, exportPng } = useCoverFabric();
// 背景图预览 Blob URL
const [bgPreviewUrl, setBgPreviewUrl] = useState<string | null>(null);
const bgBlobUrlRef = useRef<string | null>(null);
// 初始化 Canvas
useEffect(() => {
const timer = setTimeout(() => setDebouncedCoverStyle(coverStyle), 150);
return () => clearTimeout(timer);
}, [coverStyle]);
initCanvas();
}, [initCanvas]);
// 加载之前保存的封面配置
useEffect(() => {
if (!coverConfig) return;
if (coverConfig.caption !== undefined) setCaption(coverConfig.caption);
if (coverConfig.coverStyle !== undefined) setCoverStyle(coverConfig.coverStyle);
if (coverConfig.selectedPreset !== undefined) setSelectedPreset(coverConfig.selectedPreset);
setConfig((prev) => ({
template: coverConfig.template || prev.template,
backgroundImage: coverConfig.backgroundImage ?? prev.backgroundImage,
mainTitle: coverConfig.mainTitle ?? coverConfig.caption ?? prev.mainTitle,
subtitle: coverConfig.subtitle ?? prev.subtitle,
}));
}, [coverConfig]);
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// 配置变化时重新渲染 Canvas
useEffect(() => {
const timer = setTimeout(() => {
renderCover(config);
}, 50);
return () => clearTimeout(timer);
}, [config, renderCover]);
// 预览缩放比例:与字幕模板一致,按视频预览容器高度 600px / 视频基准高度 1920px
// 字幕模板由 assjs 内部自动按视频高度缩放,这里手动对齐同一比例
const previewScale = 600 / VIDEO_HEIGHT;
// 固定取镜头1的视频路径作为首帧来源
const shot1 = segments.find(s => Number(s.id) === 1);
const shot1VideoPath = shot1?.burnedVideoPath || shot1?.videoPath;
// 使用 useLocalVideo hook 加载本地视频(解决 asset:// 403 问题)
const { videoUrl: firstFrameVideoUrl } = useLocalVideo(shot1VideoPath);
const applyPreset = (presetId: string) => {
const preset = STYLE_PRESETS.find(p => p.id === presetId);
if (!preset) {
// 背景图变化时加载预览缩略图
useEffect(() => {
if (!config.backgroundImage) {
setBgPreviewUrl(null);
return;
}
setSelectedPreset(presetId);
setCoverStyle({
...coverStyle,
color: preset.color,
strokeColor: preset.strokeColor,
strokeWidth: preset.strokeWidth,
shadow: (preset as any).shadow,
backgroundColor: (preset as any).backgroundColor,
});
};
/**
* 使
*/
const getBackgroundImagePath = async (): Promise<string> => {
if (!shot1VideoPath) {
throw new Error('镜头1没有可用视频,无法提取首帧');
}
const outputResult = await invoke<{ code: number; data?: string; message: string }>('get_image_save_path', {
projectId,
filename: `cover_first_frame_${Date.now()}.jpg`,
});
if (outputResult.code !== 200 || !outputResult.data) {
throw new Error(outputResult.message);
}
const extractResult = await invoke<{ code: number; data?: string; message: string }>('extract_video_first_frame', {
request: {
video_path: shot1VideoPath,
output_path: outputResult.data,
},
});
if (extractResult.code !== 200 || !extractResult.data) {
throw new Error(extractResult.message);
}
return extractResult.data;
};
let canceled = false;
async function loadPreview() {
try {
const data = await readFile(config.backgroundImage!);
if (canceled) return;
const blob = new Blob([data]);
const url = URL.createObjectURL(blob);
if (bgBlobUrlRef.current) {
URL.revokeObjectURL(bgBlobUrlRef.current);
}
bgBlobUrlRef.current = url;
if (!canceled) {
setBgPreviewUrl(url);
}
} catch (e) {
console.error('[CoverDesign] 背景图预览加载失败:', e);
}
}
loadPreview();
return () => {
canceled = true;
};
}, [config.backgroundImage]);
// 组件卸载时释放 Blob URL
useEffect(() => {
return () => {
if (bgBlobUrlRef.current) {
URL.revokeObjectURL(bgBlobUrlRef.current);
bgBlobUrlRef.current = null;
}
};
}, []);
// 选择本地图片
const handleSelectImage = useCallback(async () => {
const selected = await open({
multiple: false,
filters: [
{
name: '图片文件',
extensions: ['jpg', 'jpeg', 'png', 'webp'],
},
],
});
if (selected && typeof selected === 'string') {
setConfig((prev) => ({ ...prev, backgroundImage: selected }));
}
}, []);
// 清除背景图
const handleClearImage = useCallback(() => {
setConfig((prev) => ({ ...prev, backgroundImage: null }));
}, []);
// 生成封面图
const handleGenerate = async () => {
if (!projectId) {
return;
}
if (!caption.trim()) {
return;
}
if (!projectId) return;
if (!config.mainTitle.trim()) return;
useProgressStore.getState().show('封面生成');
try {
// 1. 获取背景图片(视频首帧)
useProgressStore.getState().update('正在提取视频首帧...');
const bgImagePath = await getBackgroundImagePath();
useProgressStore.getState().update('正在导出封面图片...');
// 2. 生成 ASS 文件并压制标题字幕
useProgressStore.getState().update('正在合成封面...');
const maxCharsPerLine = Math.max(3, Math.floor(864 / coverStyle.fontSize));
const wrappedText = wrapText(caption.trim(), maxCharsPerLine);
// 1. 导出 PNG (base64)
const dataUrl = exportPng();
if (!dataUrl) {
throw new Error('封面导出失败');
}
const base64 = dataUrl.split(',')[1];
const isBackgroundStyle = !!coverStyle.backgroundColor;
// BorderStyle: 3 的背景框大小由 outline 决定,需要 outline > 0 才能显示背景
// outlineColor 和 backColor 设为相同颜色,让背景框和背景融合为统一色块
const bgOutline = isBackgroundStyle ? 40 : coverStyle.strokeWidth;
const bgAssColor = isBackgroundStyle ? cssColorToAss(coverStyle.backgroundColor!) : '&H00000000';
const assContent = generateCoverAss(wrappedText, {
fontSize: coverStyle.fontSize,
outline: bgOutline,
shadow: isBackgroundStyle ? 0 : (coverStyle.shadow || 0),
primaryColor: htmlColorToAss(coverStyle.color),
outlineColor: isBackgroundStyle ? bgAssColor : htmlColorToAss(coverStyle.strokeColor),
backColor: bgAssColor,
borderStyle: isBackgroundStyle ? 3 : 1,
alignment: 8,
marginV: MARGIN_V,
marginL: MARGIN_LR,
marginR: MARGIN_LR,
});
const assFilename = `cover_title_${Date.now()}.ass`;
const assPath = await saveAssFile(projectId, assFilename, assContent);
// 确定输出路径(images 目录)
const outputResult = await invoke<{ code: number; data?: string; message: string }>('get_image_save_path', {
// 2. 保存到本地
const result = await invoke<{
code: number;
data?: string;
message: string;
}>('save_project_asset', {
projectId,
filename: `cover_${Date.now()}.png`,
base64Data: base64,
});
if (outputResult.code !== 200 || !outputResult.data) {
throw new Error(outputResult.message);
}
const coverOutputPath = outputResult.data;
// FFmpeg 压制封面
const coverResult = await invoke<{ code: number; data?: string; message: string }>('generate_cover_image', {
request: {
image_path: bgImagePath,
ass_path: assPath,
output_path: coverOutputPath,
},
});
if (coverResult.code !== 200 || !coverResult.data) {
throw new Error(coverResult.message);
if (result.code !== 200 || !result.data) {
throw new Error(result.message);
}
// 3. 更新状态
setCoverPath(coverOutputPath);
// 4. 快照封面配置到 store(触发 saveMetaToLocalFile
setCoverPath(result.data);
setCoverConfig({
caption: caption.trim(),
coverStyle,
selectedPreset,
template: config.template,
backgroundImage: config.backgroundImage,
mainTitle: config.mainTitle.trim(),
subtitle: config.subtitle.trim(),
});
useProgressStore.getState().success('封面生成完成');
@@ -261,92 +186,120 @@ export default function CoverDesign() {
}
};
// ===== CSS 预览计算 =====
const previewFontSize = debouncedCoverStyle.fontSize * previewScale;
const previewShadow = (debouncedCoverStyle.shadow || 0) * previewScale;
const previewMarginTop = MARGIN_V * previewScale;
const previewMarginH = MARGIN_LR * previewScale;
const previewLines = useMemo(() => {
if (!caption.trim()) return [];
const maxCharsPerLine = Math.max(3, Math.floor(864 / debouncedCoverStyle.fontSize));
const wrappedText = wrapText(caption.trim(), maxCharsPerLine);
return wrappedText.split('\\N');
}, [caption, debouncedCoverStyle.fontSize]);
const hasPreview = !!firstFrameVideoUrl && !!caption.trim() && previewLines.length > 0;
const canGenerate = !!config.mainTitle.trim();
return (
<div className="step-layout cover-design-variant cover-design">
{/* 左侧:配置区域 */}
<div className="step-panel-left">
<div className="cover-config-scroll" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-lg)' }}>
{/* 标题文案输入 */}
<div
className="cover-config-scroll"
style={{
flex: 1,
minHeight: 0,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 'var(--spacing-lg)',
}}
>
{/* 模板选择 */}
<div className="panel-section">
<label className="panel-label"></label>
<textarea
className="input"
rows={3}
placeholder="输入封面标题文案"
value={caption}
onChange={e => setCaption(e.target.value)}
/>
</div>
{/* 样式预设 */}
<div className="panel-section">
<label className="panel-label"></label>
<div className="style-presets">
{STYLE_PRESETS.map(preset => (
<label className="panel-label"></label>
<div className="template-selector">
{TEMPLATE_OPTIONS.map((t) => (
<button
key={preset.id}
className={`preset-btn ${selectedPreset === preset.id ? 'active' : ''}`}
onClick={() => applyPreset(preset.id)}
key={t.id}
className={`template-btn ${config.template === t.id ? 'active' : ''}`}
onClick={() =>
setConfig((prev) => ({ ...prev, template: t.id }))
}
>
<span
className="preset-preview"
style={{
color: preset.color,
textShadow: !(preset as any).backgroundColor
? `-1px -1px 0 ${preset.strokeColor}, 1px -1px 0 ${preset.strokeColor}, -1px 1px 0 ${preset.strokeColor}, 1px 1px 0 ${preset.strokeColor}`
: undefined,
backgroundColor: (preset as any).backgroundColor || undefined,
padding: (preset as any).backgroundColor ? '4px 16px' : undefined,
borderRadius: '2px',
}}
>
Aa
</span>
<span className="preset-name">{preset.name}</span>
<span className="template-btn-name">{t.name}</span>
<span className="template-btn-desc">{t.desc}</span>
</button>
))}
</div>
</div>
{/* 字体大小调节 */}
{/* 背景图片 */}
<div className="panel-section">
<label className="panel-label">
<span className="font-size-value">{coverStyle.fontSize}px</span>
</label>
<input
type="range"
className="slider-input"
min={72}
max={120}
step={4}
value={coverStyle.fontSize}
onChange={e => setCoverStyle({ ...coverStyle, fontSize: Number(e.target.value) })}
style={{ '--slider-percent': `${((coverStyle.fontSize - 72) / 48) * 100}%` } as React.CSSProperties}
<label className="panel-label"></label>
<div className="bg-image-control">
{bgPreviewUrl && (
<img
src={bgPreviewUrl}
className="bg-preview-thumb"
alt="背景预览"
/>
)}
<button
className="btn btn-secondary"
onClick={handleSelectImage}
>
</button>
{config.backgroundImage && (
<button
className="btn btn-secondary"
style={{ opacity: 0.7 }}
onClick={handleClearImage}
>
</button>
)}
</div>
{!config.backgroundImage && (
<p className="cover-hint cover-hint-muted">
使
</p>
)}
</div>
{/* 主标题 */}
<div className="panel-section">
<label className="panel-label"></label>
<textarea
className="input"
rows={2}
placeholder="输入封面主标题文案"
value={config.mainTitle}
onChange={(e) =>
setConfig((prev) => ({ ...prev, mainTitle: e.target.value }))
}
/>
</div>
{/* 副标题 / 标签 */}
<div className="panel-section">
<label className="panel-label">
{config.template === 'dual-title' ? '副标题' : '标签列表'}
</label>
<textarea
className="input"
rows={2}
placeholder={
config.template === 'dual-title'
? '输入副标题文案'
: '用逗号分隔多个标签,如:装修, 设计, 客厅'
}
value={config.subtitle}
onChange={(e) =>
setConfig((prev) => ({ ...prev, subtitle: e.target.value }))
}
/>
{config.template === 'title-tags' && (
<p className="cover-hint cover-hint-muted">
</p>
)}
</div>
</div>
{/* 生成按钮 */}
<button
className="btn btn-primary cover-generate-btn"
disabled={!caption.trim() || !shot1VideoPath}
disabled={!canGenerate}
onClick={handleGenerate}
style={{ marginTop: 'auto', flexShrink: 0 }}
>
@@ -357,62 +310,18 @@ export default function CoverDesign() {
{/* 右侧:竖屏预览 */}
<div className="step-panel-right video-gen-right">
<div className="video-preview-wrapper">
<div ref={containerRef} className="video-preview-container">
{firstFrameVideoUrl ? (
<>
<video
ref={videoRef}
src={firstFrameVideoUrl}
className="preview-video"
muted
playsInline
onLoadedData={() => {
if (videoRef.current) {
videoRef.current.currentTime = 0.01;
videoRef.current.pause();
}
}}
/>
{/* CSS 标题文字层(替换 assjs */}
{hasPreview && (
<div
className="cover-title-overlay"
style={{
top: `${previewMarginTop}px`,
paddingLeft: `${previewMarginH}px`,
paddingRight: `${previewMarginH}px`,
fontSize: `${previewFontSize}px`,
color: debouncedCoverStyle.color,
textShadow: !debouncedCoverStyle.backgroundColor
? generateTextShadow(debouncedCoverStyle.strokeColor, debouncedCoverStyle.strokeWidth * previewScale, previewShadow)
: undefined,
}}
>
{previewLines.map((line, i) => (
<div key={i} className="cover-title-line" style={{
backgroundColor: debouncedCoverStyle.backgroundColor || undefined,
padding: debouncedCoverStyle.backgroundColor ? `${40 * previewScale}px ${40 * previewScale}px` : undefined,
display: debouncedCoverStyle.backgroundColor ? 'inline-block' : undefined,
borderRadius: debouncedCoverStyle.backgroundColor ? '2px' : undefined,
}}>{line}</div>
))}
</div>
)}
</>
) : (
<div className="video-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1">
<path d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<p></p>
<p className="placeholder-sub"></p>
</div>
)}
<div className="video-preview-container">
<canvas
ref={canvasRef}
style={{
width: '100%',
height: '100%',
display: 'block',
}}
/>
</div>
</div>
</div>
</div>
);
}
@@ -1,243 +1,42 @@
import { useState, useEffect } from 'react';
import Modal from '../../components/Modal/Modal';
import { open } from '@tauri-apps/plugin-dialog';
import { readFile } from '@tauri-apps/plugin-fs';
import { toast } from '../../store/uiStore';
import { useProjectStore, saveMetaToLocalFile } from '../../store';
import { useNavigation } from '../../App';
import { useAvatarLibrary } from '../../hooks/useAvatarLibrary';
import { useCachedVideoUrl, useCachedPosterUrl } from '../../hooks/useAvatarCache';
import { useVideoGeneration, type GenerationResult } from '../../hooks/useVideoGeneration';
import { useProgressStore } from '../../store/progressStore';
import { useVideoGeneration } from '../../hooks/useVideoGeneration';
import { matchMaterial } from '../../api/modules/materials';
import { useLocalVideo } from '../../hooks/useLocalVideo';
import {
composeVideo,
type ComposeSegment,
} from '../../api/modules/videoCompose';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentProjectId, localProjectApi } from '../../api/modules/localStorage';
import './VideoGeneration.css';
// 形象选择卡片组件
interface AvatarSelectCardProps {
avatar: {
id: string;
name: string;
videoUrl?: string;
trialUrl?: string;
};
isSelected: boolean;
onClick: () => void;
}
// 生成视频封面的函数
function generateVideoPoster(videoUrl: string): Promise<string> {
return new Promise((resolve) => {
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.src = videoUrl;
video.muted = true;
video.playsInline = true;
video.onloadeddata = () => {
video.currentTime = 0.1;
};
video.onseeked = () => {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth || 300;
canvas.height = video.videoHeight || 400;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/jpeg', 0.8));
} else {
resolve('');
}
};
video.onerror = () => {
resolve('');
};
video.load();
});
}
function AvatarSelectCard({ avatar, isSelected, onClick }: AvatarSelectCardProps) {
// 判断是否有视额可加载
// 使用本地缓存的视频和封面
const { videoUrl: cachedVideoUrl, isCaching } = useCachedVideoUrl(
avatar.id,
avatar.videoUrl
);
// 生成封面(使用实际可用的视频 URL)
const actualVideoUrl = cachedVideoUrl || avatar.videoUrl;
const generatePoster = () =>
actualVideoUrl ? generateVideoPoster(actualVideoUrl) : Promise.resolve('');
const { posterUrl, isLoaded: isPosterLoaded } = useCachedPosterUrl(avatar.id, generatePoster);
// 加载状态:正在缓存视额 或 正在生成封面
const isLoading = isCaching || !isPosterLoaded;
// 使用生成的封面
const displayUrl = posterUrl;
return (
<div
className={`material-card-avatar-item ${isSelected ? 'selected' : ''}`}
onClick={onClick}
style={{
padding: 'var(--spacing-sm)',
border: `2px solid ${isSelected ? 'var(--primary)' : 'var(--border-light)'}`,
borderRadius: 'var(--radius-lg)',
background: isSelected ? 'var(--primary-light)' : 'var(--bg-card)',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
>
{/* 3:4 缩略图 */}
<div
className="avatar-item-thumbnail"
style={{
width: '100%',
aspectRatio: '3/4',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
background: 'var(--bg-input)',
position: 'relative',
}}
>
{isLoading ? (
/* 加载中状态 - 显示动画 */
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg-hover)',
}}
>
<div
style={{
width: '32px',
height: '32px',
border: '3px solid var(--border-light)',
borderTop: '3px solid var(--primary)',
borderRadius: '50%',
animation: 'avatar-spin 1s linear infinite',
}}
/>
</div>
) : displayUrl ? (
/* 封面加载完成 */
<img
src={displayUrl}
alt={avatar.name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
/* 无预览 */
<div
className="avatar-item-placeholder"
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--spacing-sm)',
color: 'var(--text-tertiary)',
}}
>
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span style={{ fontSize: 'var(--font-xs)' }}></span>
</div>
)}
{isSelected && (
<div
className="avatar-item-check"
style={{
position: 'absolute',
top: '8px',
right: '8px',
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'var(--primary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="white"
strokeWidth="3"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
)}
</div>
<span
className="avatar-item-name"
style={{
fontSize: 'var(--font-sm)',
fontWeight: 600,
color: 'var(--text-primary)',
textAlign: 'center',
marginTop: '8px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
width: '100%',
}}
>
{avatar.name || '未命名形象'}
</span>
</div>
);
}
export default function VideoGeneration() {
const segments = useProjectStore(state => state.segments);
const selectedHumanId = useProjectStore(state => state.selectedHumanId);
const selectedElementId = useProjectStore(state => state.selectedElementId);
const selectedVoiceId = useProjectStore(state => state.selectedVoiceId);
const setSelectedAvatarInfo = useProjectStore(state => state.setSelectedAvatarInfo);
const updateSegment = useProjectStore(state => state.updateSegment);
const projectId = getCurrentProjectId();
const { navigate } = useNavigation();
const { avatarLibrary } = useAvatarLibrary();
const [showModal, setShowModal] = useState(false);
// 本地人物出镜素材(直接从文件选择器选取,不经过素材库)
const [selectedAvatarMaterial, setSelectedAvatarMaterial] = useState<{ id: string; name: string; duration: number; path: string } | null>(null);
const [materialMatchMap, setMaterialMatchMap] = useState<Record<string, { url: string; duration: number } | null>>({});
const [regeneratingShots, setRegeneratingShots] = useState<Set<string>>(new Set());
const [isComposing, setIsComposing] = useState(false);
const {
isGenerating,
isRegenerating,
startGeneration,
regenerateShot,
reset: resetGeneration,
} = useVideoGeneration();
@@ -262,100 +61,184 @@ export default function VideoGeneration() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shots]);
// 克隆形象列表
const clonedAvatars = avatarLibrary.filter(a => a.elementId && a.elementId > 0);
const selectedInfo = clonedAvatars.find(a => a.id === selectedHumanId);
// 旧数据兼容:meta.json 里只有 selectedHumanId 没有 selectedElementId 时,自动补全
// 自动匹配空镜素材(调用后端接口)
useEffect(() => {
if (selectedHumanId && !selectedElementId && selectedInfo) {
setSelectedAvatarInfo(selectedHumanId, selectedInfo.elementId, selectedInfo.voiceId);
const emptyShots = shots.filter((s) => s.type === 'empty_shot' && s.scene);
if (emptyShots.length === 0) {
setMaterialMatchMap({});
return;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedHumanId, selectedElementId, selectedInfo]);
// 弹窗分页
const [modalPage, setModalPage] = useState(1);
const modalPageSize = 8;
const modalTotalPages = Math.ceil(clonedAvatars.length / modalPageSize);
const modalPaginatedAvatars = clonedAvatars.slice(
(modalPage - 1) * modalPageSize,
modalPage * modalPageSize
);
let canceled = false;
const handleGenerationResult = (data: GenerationResult) => {
const videoPathMap = new Map<number, string>();
const videoUrlMap = new Map<number, string>();
data.shots.forEach(shotResult => {
if (shotResult.status === 'completed') {
const shotId = Number(shotResult.shotId);
if (!isNaN(shotId)) {
if (shotResult.localPath) videoPathMap.set(shotId, shotResult.localPath);
// 优先使用七牛持久 URLfallback 到 Kling 临时 URL
const url = shotResult.qiniuUrl || shotResult.videoUrl;
if (url) videoUrlMap.set(shotId, url);
}
async function doMatch() {
const newMap: Record<string, { url: string; duration: number } | null> = {};
await Promise.all(
emptyShots.map(async (shot) => {
const duration =
typeof shot.duration === 'number'
? shot.duration
: parseInt(String(shot.duration).replace(/[^0-9]/g, ''), 10) || 5;
try {
const result = await matchMaterial(shot.scene || '', duration);
if (!canceled) {
newMap[String(shot.id)] = result;
}
} catch (err) {
console.error(`[VideoGeneration] 空镜匹配失败 (shot ${shot.id}):`, err);
if (!canceled) {
newMap[String(shot.id)] = null;
}
}
})
);
if (!canceled) {
setMaterialMatchMap(newMap);
}
});
videoPathMap.forEach((path, shotId) => {
updateSegment(shotId, {
videoPath: path,
videoUrl: videoUrlMap.get(shotId) || undefined,
alignmentResult: undefined,
burnedVideoPath: undefined,
burnedAt: undefined,
});
});
if (projectId) {
const segmentsToSave = shots.map(s => {
const shotId = Number(s.id);
const newPath = videoPathMap.get(shotId);
const newUrl = videoUrlMap.get(shotId);
const hasNewVideo = !!(newPath || newUrl);
return {
id: shotId,
type: s.type as 'segment' | 'empty_shot',
scene: s.scene,
voiceover: s.voiceover,
duration: String(s.duration),
videoPath: (newPath || s.videoPath) || undefined,
videoUrl: (newUrl || s.videoUrl) || undefined,
elementId: s.elementId,
voiceId: s.voiceId,
alignmentResult: hasNewVideo ? undefined : s.alignmentResult,
burnedVideoPath: hasNewVideo ? undefined : s.burnedVideoPath,
burnedAt: hasNewVideo ? undefined : s.burnedAt,
};
});
localProjectApi.saveSegments(projectId, segmentsToSave).catch((err) => {
console.error('[VideoGeneration] Save segments failed:', err);
});
// 同步保存 meta,确保 selectedHumanId 落盘
saveMetaToLocalFile({
selectedHumanId: selectedHumanId,
selectedElementId: selectedElementId,
selectedVoiceId: selectedVoiceId,
currentStep: 2,
}).catch((err) => {
console.error('[VideoGeneration] Save meta failed:', err);
});
}
const firstWithVideo = data.shots.find(r => r.localPath || r.videoUrl);
if (firstWithVideo) {
setTimeout(() => setActiveScene(Number(firstWithVideo.shotId)), 100);
doMatch();
return () => {
canceled = true;
};
}, [shots]);
// ============================================================
// 本地人物视频选择
// ============================================================
/**
*
*/
const validateLocalVideo = async (
filePath: string
): Promise<{
valid: boolean;
duration: number;
width: number;
height: number;
error?: string;
}> => {
try {
const data = await readFile(filePath);
const blob = new Blob([data], { type: 'video/mp4' });
const url = URL.createObjectURL(blob);
return new Promise((resolve) => {
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
const duration = video.duration;
const width = video.videoWidth;
const height = video.videoHeight;
URL.revokeObjectURL(url);
if (duration < 5 || duration > 20) {
resolve({
valid: false,
duration,
width,
height,
error: `视频时长 ${duration.toFixed(1)} 秒,要求 5-20 秒`,
});
return;
}
if (width !== 1080 || height !== 1920) {
resolve({
valid: false,
duration,
width,
height,
error: `视频分辨率 ${width}x${height},要求 1080x1920`,
});
return;
}
resolve({ valid: true, duration, width, height });
};
video.onerror = () => {
URL.revokeObjectURL(url);
resolve({
valid: false,
duration: 0,
width: 0,
height: 0,
error: '无法读取视频文件,请检查文件是否损坏',
});
};
setTimeout(() => {
URL.revokeObjectURL(url);
resolve({
valid: false,
duration: 0,
width: 0,
height: 0,
error: '读取视频超时,请重试',
});
}, 15000);
video.src = url;
});
} catch (e) {
return {
valid: false,
duration: 0,
width: 0,
height: 0,
error: `读取文件失败: ${e instanceof Error ? e.message : String(e)}`,
};
}
};
/**
*
*/
const handleSelectLocalVideo = async () => {
try {
const selected = await open({
multiple: false,
filters: [
{
name: 'Video',
extensions: ['mp4', 'mov'],
},
],
});
if (!selected || Array.isArray(selected)) return;
const filePath = selected;
const fileName = filePath.split(/[\\/]/).pop() || '未知文件';
const result = await validateLocalVideo(filePath);
if (!result.valid) {
toast.error(result.error || '视频验证失败');
return;
}
setSelectedAvatarMaterial({
id: `local_${Date.now()}`,
name: fileName,
duration: result.duration,
path: filePath,
});
} catch (e) {
console.error('[VideoGeneration] 选择文件失败:', e);
toast.error('选择文件失败');
}
};
const handleGenerate = async () => {
if (!selectedElementId) {
toast.warning('请选择一个形象');
setShowModal(true);
if (!selectedAvatarMaterial) {
toast.warning('请选择人物出镜素材');
return;
}
@@ -369,27 +252,90 @@ export default function VideoGeneration() {
return;
}
resetGeneration();
// 检查空镜素材是否都已匹配
const emptyShots = shots.filter((s) => s.type === 'empty_shot');
const unmatched = emptyShots.filter((s) => !materialMatchMap[String(s.id)]);
if (unmatched.length > 0) {
toast.warning(`${unmatched.length} 个空镜未匹配到素材,请检查场景描述`);
return;
}
setIsComposing(true);
const progress = useProgressStore.getState();
progress.show('视频生成');
try {
const data = await startGeneration({
projectId,
elementId: selectedElementId,
shots: shots.map(s => ({
id: String(s.id),
type: s.type === 'empty_shot' ? 'empty_shot' : 'segment',
scene: s.scene || '',
voiceover: s.voiceover || '',
duration: parseInt(String(s.duration).replace(/[^0-9]/g, ''), 10) || 5,
voice_id: s.type === 'empty_shot' ? selectedVoiceId : undefined,
})),
// Step 1: 合成视频(裁剪 + 拼接)
progress.update('正在裁剪素材片段...');
// 人物视频总时长,用于计算随机截取起始点
const avatarDuration = selectedAvatarMaterial.duration;
const segments: ComposeSegment[] = shots.map((shot) => {
const duration =
typeof shot.duration === 'number'
? shot.duration
: parseInt(String(shot.duration).replace(/[^0-9]/g, ''), 10) || 5;
if (shot.type === 'empty_shot') {
const matched = materialMatchMap[String(shot.id)];
return {
id: String(shot.id),
type: 'empty_shot' as const,
duration,
source: matched!.url,
startTime: 0,
};
} else {
// 人物出镜:从人物视频中随机截取,确保不超出素材时长
const maxStart = Math.max(0, avatarDuration - duration);
const startTime = maxStart > 0 ? Math.random() * maxStart : 0;
return {
id: String(shot.id),
type: 'segment' as const,
duration,
source: selectedAvatarMaterial.path,
startTime,
};
}
});
if (data && data.completed > 0) {
handleGenerationResult(data);
const composeResult = await composeVideo(projectId, segments);
console.log('[VideoGeneration] 视频合成完成:', composeResult.outputPath);
// Step 2: 保存到 products 目录
progress.update('正在保存最终视频...');
const productRes = await invoke<{ code: number; data?: string; message: string }>(
'save_final_product',
{
projectId,
sourcePath: composeResult.outputPath,
productFilename: `final_${Date.now()}.mp4`,
}
);
if (productRes.code !== 200 || !productRes.data) {
throw new Error(productRes.message || '保存成品失败');
}
} catch {
// ignore
const finalPath = productRes.data;
// 更新项目状态
useProjectStore.setState({ finalVideoPath: finalPath });
await saveMetaToLocalFile({
finalVideoPath: finalPath,
currentStep: 3,
});
progress.success('视频生成完成');
toast.success('视频生成完成');
} catch (error) {
const msg = error instanceof Error ? error.message : '视频生成失败';
console.error('[VideoGeneration] 生成失败:', error);
progress.error(msg);
toast.error(msg);
} finally {
setIsComposing(false);
}
};
@@ -403,9 +349,8 @@ export default function VideoGeneration() {
const elementId = shot.type === 'empty_shot' ? undefined : selectedElementId;
const voiceId = shot.type === 'empty_shot' ? selectedVoiceId : undefined;
if (shot.type === 'segment' && !elementId) {
toast.warning('请先选择形象');
setShowModal(true);
if (shot.type === 'segment' && !selectedAvatarMaterial) {
toast.warning('请先选择人物出镜素材');
return;
}
if (shot.type === 'empty_shot' && !voiceId) {
@@ -483,34 +428,75 @@ export default function VideoGeneration() {
>
<div className="panel-section">
<label className="panel-label"></label>
<div
className={`material-selector ${selectedHumanId ? 'selected' : ''}`}
onClick={() => setShowModal(true)}
>
<div className="material-icon">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
{selectedAvatarMaterial ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: 'var(--spacing-sm) var(--spacing-md)',
border: '1px solid var(--border-color)',
borderRadius: '8px',
background: 'var(--bg-secondary)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '50%',
background: 'var(--primary-light)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--primary)',
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
<div>
<div style={{ fontSize: 'var(--font-sm)', fontWeight: 500 }}>{selectedAvatarMaterial.name}</div>
<div style={{ fontSize: 'var(--font-xs)', color: 'var(--text-secondary)' }}>
{selectedAvatarMaterial.duration.toFixed(1)}s
</div>
</div>
</div>
<button
className="btn btn-ghost btn-sm"
onClick={handleSelectLocalVideo}
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</button>
</div>
<div className="material-info">
<span className="material-name">{selectedInfo ? selectedInfo.name : '选择形象'}</span>
<span className="material-desc">
{selectedInfo
? `克隆形象 | ${selectedInfo.recordTime ? new Date(selectedInfo.recordTime).toLocaleDateString() : ''}`
: '从形象素材库选择'}
</span>
) : (
<div
style={{
padding: 'var(--spacing-md)',
border: '1px dashed var(--border-color)',
borderRadius: '8px',
textAlign: 'center',
color: 'var(--text-secondary)',
fontSize: 'var(--font-sm)',
}}
>
<p></p>
<p style={{ marginTop: '4px', fontSize: 'var(--font-xs)', opacity: 0.7 }}>
</p>
<button
className="btn btn-primary btn-sm"
style={{ marginTop: 'var(--spacing-sm)' }}
onClick={handleSelectLocalVideo}
>
</button>
</div>
</div>
)}
</div>
{/* 镜头列表 - 紧凑型展示 */}
@@ -587,6 +573,50 @@ export default function VideoGeneration() {
<span className="scene-field-label-pill"></span>
{shot.voiceover || '未设置配音文案'}
</p>
{/* 素材匹配状态 - 仅空镜 */}
{shot.type === 'empty_shot' && (
<p
className="scene-card-material"
style={{
fontSize: 'var(--font-xs)',
marginTop: '4px',
color: materialMatchMap[String(shot.id)] ? 'var(--success)' : 'var(--danger)',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<span className="scene-field-label-pill" style={{ fontSize: '10px' }}>
</span>
{materialMatchMap[String(shot.id)] ? (
<>
{materialMatchMap[String(shot.id)]!.duration}s
</>
) : (
<> </>
)}
</p>
)}
{/* 形象素材状态 - 仅人物出镜 */}
{shot.type !== 'empty_shot' && selectedAvatarMaterial && (
<p
className="scene-card-material"
style={{
fontSize: 'var(--font-xs)',
marginTop: '4px',
color: 'var(--text-secondary)',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<span className="scene-field-label-pill" style={{ fontSize: '10px' }}>
</span>
{selectedAvatarMaterial.name}{selectedAvatarMaterial.duration.toFixed(1)}s
</p>
)}
</div>
{/* 重新生成按钮 - 只有已有视频时才显示 */}
{hasVideo && (
@@ -606,7 +636,7 @@ export default function VideoGeneration() {
e.stopPropagation();
handleRegenerateShot(shot);
}}
disabled={isThisShotRegenerating || isGenerating}
disabled={isThisShotRegenerating || isComposing}
style={{
fontSize: 'var(--font-xs)',
padding: '4px 8px',
@@ -659,9 +689,9 @@ export default function VideoGeneration() {
className="btn btn-primary"
style={{ width: '100%', marginTop: '0', flexShrink: 0 }}
onClick={handleGenerate}
disabled={isGenerating || isRegenerating}
disabled={isComposing || isRegenerating}
>
{isGenerating || isRegenerating ? (
{isComposing || isRegenerating ? (
<>
<svg
width="18"
@@ -746,173 +776,6 @@ export default function VideoGeneration() {
</div>
</div>
{/* Avatar Modal */}
<Modal open={showModal} onClose={() => setShowModal(false)} title="选择形象" width="800px">
<div style={{ display: 'flex', flexDirection: 'column', height: '480px' }}>
{clonedAvatars.length === 0 ? (
/* 空列表状态 */
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: 'var(--spacing-3xl) var(--spacing-2xl)',
gap: 'var(--spacing-md)',
}}
>
<div
style={{
width: '64px',
height: '64px',
borderRadius: '50%',
background: 'var(--bg-input)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="var(--text-tertiary)"
strokeWidth="1.5"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
<div style={{ textAlign: 'center' }}>
<p
style={{
fontSize: '15px',
fontWeight: 500,
color: 'var(--text-primary)',
marginBottom: '4px',
}}
>
</p>
<p
style={{
fontSize: '13px',
color: 'var(--text-tertiary)',
}}
>
</p>
</div>
<button
className="btn btn-primary"
style={{ marginTop: '8px' }}
onClick={() => {
setShowModal(false);
navigate('voice-material');
}}
>
</button>
</div>
) : (
/* 非空列表状态 */
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 'var(--spacing-sm)' }}>
{/* 卡片网格 - 可滚动 */}
<div
className="material-grid"
style={{
gridTemplateColumns: 'repeat(4, 1fr)',
flex: 1,
overflowY: 'auto',
padding: 'var(--spacing-sm)',
alignContent: 'start',
alignItems: 'start',
gap: 'var(--spacing-md)',
}}
>
{modalPaginatedAvatars.map(avatar => (
<AvatarSelectCard
key={avatar.id}
avatar={avatar}
isSelected={selectedHumanId === avatar.id}
onClick={() => {
setSelectedAvatarInfo(avatar.id, avatar.elementId, avatar.voiceId);
setShowModal(false);
}}
/>
))}
</div>
{/* 底部分页 + 跳转入口 */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--spacing-sm)',
paddingTop: '8px',
borderTop: '1px solid var(--border-light)',
flexShrink: 0,
}}
>
{/* 分页 */}
{modalTotalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<div className="pagination">
<button
className="pagination-btn nav"
disabled={modalPage === 1}
onClick={() => setModalPage(p => Math.max(1, p - 1))}
>
</button>
{Array.from({ length: modalTotalPages }, (_, i) => i + 1).map(page => (
<button
key={page}
className={`pagination-btn ${modalPage === page ? 'active' : ''}`}
onClick={() => setModalPage(page)}
>
{page}
</button>
))}
<button
className="pagination-btn nav"
disabled={modalPage === modalTotalPages}
onClick={() => setModalPage(p => Math.min(modalTotalPages, p + 1))}
>
</button>
</div>
</div>
)}
{/* 跳转入口 */}
<div style={{ textAlign: 'center' }}>
<button
className="btn btn-ghost btn-sm"
onClick={() => {
setShowModal(false);
navigate('voice-material');
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ marginRight: '4px' }}
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
</div>
</div>
)}
</div>
</Modal>
</div>
);
+1 -59
View File
@@ -7,7 +7,7 @@
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import type { VoiceInfo, AudioMeta, VoiceMaterial, AvatarMaterial } from '../api/modules/voice';
import type { VoiceInfo, AudioMeta, VoiceMaterial } from '../api/modules/voice';
import * as voiceApi from '../api/modules/voice';
interface VoiceState {
@@ -41,10 +41,6 @@ interface VoiceState {
// 素材库(用户上传的克隆音色)
voiceMaterials: VoiceMaterial[];
isLoadingMaterials: boolean;
// 视频素材库
avatarMaterials: AvatarMaterial[];
isLoadingAvatarMaterials: boolean;
}
interface VoiceActions {
@@ -68,12 +64,6 @@ interface VoiceActions {
renameVoiceMaterial: (id: string, name: string) => Promise<void>;
deleteVoiceMaterial: (materialId: string) => Promise<void>;
// 视频素材库操作
loadAvatarMaterials: () => Promise<void>;
addAvatarMaterial: (file: File, name: string) => Promise<AvatarMaterial>;
renameAvatarMaterial: (id: string, name: string) => Promise<void>;
deleteAvatarMaterial: (materialId: string) => Promise<void>;
// 项目音频操作
loadProjectAudios: (projectId: string) => Promise<void>;
saveAudio: (args: {
@@ -108,8 +98,6 @@ const initialState: VoiceState = {
isLoadingAudios: false,
voiceMaterials: [],
isLoadingMaterials: false,
avatarMaterials: [],
isLoadingAvatarMaterials: false,
};
export const useVoiceStore = create<VoiceState & VoiceActions>()(
@@ -277,52 +265,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>()(
}));
},
// ====================== 视频素材库操作 ======================
loadAvatarMaterials: async () => {
set({ isLoadingAvatarMaterials: true });
try {
const materials = await voiceApi.loadAvatarMaterials();
set({ avatarMaterials: materials });
} catch (err) {
console.error('[VoiceStore] 加载视频素材失败:', err);
} finally {
set({ isLoadingAvatarMaterials: false });
}
},
addAvatarMaterial: async (file: File, name: string) => {
const videoUrl = await voiceApi.uploadVideo(file);
const material: AvatarMaterial = {
id: `avatar_${Date.now()}`,
name,
videoUrl,
createdAt: new Date().toISOString(),
};
await voiceApi.saveAvatarMaterial(material);
set(state => ({ avatarMaterials: [material, ...state.avatarMaterials] }));
return material;
},
renameAvatarMaterial: async (id: string, name: string) => {
set(state => {
const updated = state.avatarMaterials.map(m => m.id === id ? { ...m, name } : m);
const target = updated.find(m => m.id === id);
if (target) {
voiceApi.saveAvatarMaterial(target).catch(err => {
console.error('[VoiceStore] 重命名素材失败:', err);
});
}
return { avatarMaterials: updated };
});
},
deleteAvatarMaterial: async (materialId: string) => {
await voiceApi.deleteAvatarMaterial(materialId);
set(state => ({
avatarMaterials: state.avatarMaterials.filter(m => m.id !== materialId),
}));
},
// ====================== 项目音频操作 ======================
loadProjectAudios: async (projectId) => {