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:
@@ -2,15 +2,15 @@
|
||||
|
||||
## 项目概述
|
||||
|
||||
美家卡智剪是一个 AI 驱动的**短视频剪辑**桌面应用,采用 **Tauri + React + FastAPI** 混合架构。本项目由「美家卡智影」Fork 而来,从"数字人生成"转向"AI 辅助长视频剪辑"方向:用户导入长视频素材,AI 根据脚本自动分镜切割,配合语音克隆/TTS 生成配音,最终合成带字幕的成品短视频。
|
||||
美家卡智剪是一个 AI 驱动的**短视频剪辑**桌面应用,采用 **Tauri + React + FastAPI** 混合架构。本项目由「美家卡智影」Fork 而来,从"数字人生成"转向"AI 辅助长视频剪辑"方向:用户导入长视频素材或提供口播文案,AI 自动分镜并生成配音,最终合成带字幕的成品短视频。
|
||||
|
||||
### 核心功能流程(6 步)
|
||||
|
||||
1. **脚本生成** (Step 1) - AI 生成或粘贴口播文案,自动拆分为带预估时长的分镜
|
||||
2. **视频粗剪** (Step 2) - 导入长视频素材,按脚本时长自动切割为片段
|
||||
3. **语音配音** (Step 3) - AI 声音克隆(KlingAI)+ TTS 合成,为每段生成配音音频
|
||||
2. **音频合成** (Step 2) - AI 声音克隆(KlingAI / MiniMax / Vidu)+ TTS 合成,为每段生成配音音频
|
||||
3. **视频生成** (Step 3) - 导入长视频素材并按脚本时长自动切割为片段,或为空镜匹配素材视频
|
||||
4. **字幕压制** (Step 4) - 基于音频自动对齐时间轴,ASS 字幕渲染并压制到视频
|
||||
5. **封面制作** (Step 5) - 提取视频首帧并叠加标题样式生成封面
|
||||
5. **封面制作** (Step 5) - 使用 Fabric.js 设计封面,提取视频首帧并叠加标题样式
|
||||
6. **视频合成** (Step 6) - FFmpeg 拼接视频片段,替换原声为 TTS 音频,导出成品
|
||||
|
||||
### 技术架构
|
||||
@@ -18,8 +18,8 @@
|
||||
- **前端桌面壳**: Tauri 2.x + React 19 + TypeScript 5.8 + Vite 7
|
||||
- **后端服务**: FastAPI (Python 3.13+) + PostgreSQL 15 + Redis 7
|
||||
- **任务调度**: 自定义 Async Engine(基于 Redis 槽位管理),**非 Celery**
|
||||
- **本地处理**: 嵌入式 FFmpeg,Rust 层封装视频/音频处理命令
|
||||
- **AI 服务**: 火山方舟(LLM/字幕)、可灵 AI(TTS/声音克隆)、OpenAI 兼容接口
|
||||
- **本地处理**: 嵌入式 FFmpeg + ffprobe,Rust 层封装视频/音频处理命令
|
||||
- **AI 服务**: 火山方舟(LLM/字幕)、可灵 AI(TTS/声音克隆/视频生成)、MiniMax(TTS)、Vidu(TTS/对口型)、OpenAI 兼容接口
|
||||
|
||||
## 项目结构
|
||||
|
||||
@@ -27,19 +27,19 @@
|
||||
meijiaka-zj/
|
||||
├── python-api/ # FastAPI 后端服务(AI 代理 + 认证 + 任务调度)
|
||||
│ ├── app/
|
||||
│ │ ├── api/v1/ # API 路由: auth, system, caption, voice
|
||||
│ │ ├── api/v1/ # API 路由: auth, system, tasks, script, caption, voice, upload, vidu
|
||||
│ │ ├── ai/ # AI 模型路由、Provider、提示词模板
|
||||
│ │ ├── core/ # 安全、配置加载、Token管理器、Redis客户端、异常处理
|
||||
│ │ ├── crud/ # 数据访问层(users, avatar)
|
||||
│ │ ├── db/ # 数据库配置(PostgreSQL + asyncpg + SQLAlchemy 2.0)
|
||||
│ │ ├── models/ # SQLAlchemy 模型(当前仅 users)
|
||||
│ │ ├── schemas/ # Pydantic 校验模型
|
||||
│ │ ├── services/ # AI 服务代理、字幕/视频/TTS/声音克隆服务
|
||||
│ │ ├── services/ # AI 服务代理、字幕/视频/TTS/声音克隆/ASS生成服务
|
||||
│ │ ├── scheduler/ # Async Engine 异步任务调度
|
||||
│ │ ├── config.py # Pydantic Settings 配置管理
|
||||
│ │ └── main.py # FastAPI 入口(含生命周期管理)
|
||||
│ ├── config/ # AI 模型配置文件(ai_models.yaml),支持热重载
|
||||
│ ├── alembic/ # 数据库迁移(当前无迁移文件)
|
||||
│ ├── alembic/ # 数据库迁移(当前无迁移文件,dev 模式自动建表)
|
||||
│ ├── tests/ # 测试目录(当前仅 test_kling_tts.py)
|
||||
│ ├── pyproject.toml # Python 依赖和工具配置
|
||||
│ ├── requirements.lock # uv 锁定依赖版本(如缺失需执行 make update-lock)
|
||||
@@ -55,8 +55,7 @@ meijiaka-zj/
|
||||
│ │ │ ├── generated/ # OpenAPI 自动生成类型(只读)
|
||||
│ │ │ ├── modules/ # API 模块封装(HTTP + IPC)
|
||||
│ │ │ ├── client.ts # HTTP 客户端(自动 camelCase↔snake_case)
|
||||
│ │ │ ├── types.ts # 手写核心类型
|
||||
│ │ │ └── ipc.ts # Tauri IPC 调用封装
|
||||
│ │ │ └── types.ts # 手写核心类型
|
||||
│ │ ├── components/ # 可复用组件
|
||||
│ │ ├── pages/ # 页面组件
|
||||
│ │ │ ├── VideoCreation/ # 6 步视频创作流程
|
||||
@@ -71,23 +70,50 @@ meijiaka-zj/
|
||||
│ ├── src-tauri/ # Rust 后端源码
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── lib.rs # Tauri 应用入口,命令注册
|
||||
│ │ │ ├── main.rs # 二进制入口
|
||||
│ │ │ ├── ffmpeg_cmd.rs # FFmpeg 命令封装
|
||||
│ │ │ ├── video_processing.rs # 视频合成业务逻辑
|
||||
│ │ │ ├── storage/ # 本地存储引擎(原子写入、文件锁、路径净化)
|
||||
│ │ │ ├── commands/ # IPC 命令按领域拆分
|
||||
│ │ │ ├── api_proxy.rs # Python API 代理转发
|
||||
│ │ │ └── utils.rs # 通用工具函数
|
||||
│ │ │ ├── auth.rs # 认证命令
|
||||
│ │ │ ├── avatar_cache.rs # 头像视频缓存管理
|
||||
│ │ │ ├── utils.rs # 通用工具函数
|
||||
│ │ │ ├── commands/ # IPC 命令按领域拆分
|
||||
│ │ │ │ ├── mod.rs
|
||||
│ │ │ │ ├── asset.rs
|
||||
│ │ │ │ ├── auth_state.rs
|
||||
│ │ │ │ ├── avatar.rs
|
||||
│ │ │ │ ├── avatar_material.rs
|
||||
│ │ │ │ ├── product.rs
|
||||
│ │ │ │ ├── project.rs
|
||||
│ │ │ │ ├── video_compose.rs
|
||||
│ │ │ │ └── voice.rs
|
||||
│ │ │ └── storage/ # 本地存储引擎
|
||||
│ │ │ ├── mod.rs
|
||||
│ │ │ ├── engine.rs # 原子写入、文件锁、路径净化
|
||||
│ │ │ ├── paths.rs # 集中化路径计算
|
||||
│ │ │ ├── project.rs
|
||||
│ │ │ ├── auth.rs
|
||||
│ │ │ ├── avatar.rs
|
||||
│ │ │ ├── avatar_material.rs
|
||||
│ │ │ ├── voice.rs
|
||||
│ │ │ └── cache.rs
|
||||
│ │ ├── Cargo.toml
|
||||
│ │ ├── tauri.conf.json
|
||||
│ │ └── binaries/ # 嵌入式 FFmpeg
|
||||
│ │ ├── binaries/ # 嵌入式 FFmpeg / ffprobe
|
||||
│ │ └── fonts/ # 封面字体(DouyinSansBold.ttf)
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.ts
|
||||
│ ├── tsconfig.json
|
||||
│ └── eslint.config.js
|
||||
│ ├── vitest.config.ts
|
||||
│ ├── eslint.config.js
|
||||
│ ├── .prettierrc
|
||||
│ └── .stylelintrc.json
|
||||
│
|
||||
├── docs/ # 项目文档
|
||||
├── docs/ # 项目文档(设计文档、API 参考、实施计划)
|
||||
├── scripts/ # 数据修复/迁移脚本(非部署脚本)
|
||||
├── package.json # 根级 package.json(monorepo 占位)
|
||||
├── .python-version # 3.13
|
||||
├── CLAUDE.md # 开发者速查手册
|
||||
└── AGENTS.md # 本文件
|
||||
```
|
||||
|
||||
@@ -105,9 +131,10 @@ meijiaka-zj/
|
||||
| ORM | SQLAlchemy | 2.0 (异步) | 数据模型 |
|
||||
| 缓存/调度 | Redis + Async Engine | 5.2+ / 自定义 | 异步任务槽位调度 |
|
||||
| AI SDK | OpenAI / volcengine | 1.58+ / 5.0+ | LLM / 字幕调用 |
|
||||
| TTS/语音 | KlingAI API | - | 语音合成、声音克隆 |
|
||||
| TTS/语音 | KlingAI / MiniMax / Vidu | - | 语音合成、声音克隆 |
|
||||
| 认证 | python-jose + passlib | 3.4+ / 1.7+ | JWT 认证 |
|
||||
| HTTP 客户端 | httpx + aiohttp | 0.28+ / 3.13+ | 异步 HTTP |
|
||||
| 对象存储 | qiniu | 7.13+ | 七牛云 Kodo |
|
||||
| 包管理/构建 | uv | - | 虚拟环境、依赖锁定、Docker 构建 |
|
||||
|
||||
**后端架构说明**:
|
||||
@@ -115,7 +142,7 @@ meijiaka-zj/
|
||||
- 云端仅存储:用户账户信息
|
||||
- 业务数据(项目/脚本/媒体/成品)全部本地存储
|
||||
- 任务调度使用**自定义 Async Engine**(基于 Redis 的槽位管理),**非 Celery**
|
||||
- 当前活跃 API 模块仅 4 个:`auth`, `system`, `caption`, `voice`
|
||||
- 当前活跃 API 模块:**8 个**:`auth`, `system`, `tasks`, `script`, `caption`, `voice`, `upload`, `vidu`
|
||||
- 调度器已注册 7 个 Handler:`Video`, `Avatar`, `Image`, `Subtitle`, `Copy`, `Script`, `TTS`
|
||||
|
||||
### 前端 (tauri-app)
|
||||
@@ -124,7 +151,7 @@ meijiaka-zj/
|
||||
|------|------|------|------|
|
||||
| 桌面框架 | Tauri | 2.x | 桌面应用壳 |
|
||||
| UI 框架 | React | 19.1+ | 用户界面 |
|
||||
| 路由 | 自定义 NavigationContext | - | 页面切换(react-router-dom 已安装但未用于主流程) |
|
||||
| 路由 | 自定义 NavigationContext | - | 页面切换(react-router-dom 已安装但主流程未使用) |
|
||||
| 状态管理 | Zustand | 5.x | 全局状态 + Immer 中间件 |
|
||||
| 数据获取 | SWR | 2.x | 请求缓存 |
|
||||
| 虚拟列表 | @tanstack/react-virtual | 3.x | 大数据列表渲染 |
|
||||
@@ -132,23 +159,30 @@ meijiaka-zj/
|
||||
| 测试 | Vitest + @testing-library | 4.x | 单元测试 |
|
||||
| 类型生成 | openapi-typescript | 7.x | 从 OpenAPI 生成 TS 类型 |
|
||||
| 字幕渲染 | assjs | 0.1.x | ASS 字幕前端渲染 |
|
||||
| 封面设计 | fabric | 6.x | Canvas 封面编辑 |
|
||||
|
||||
### Rust 后端 (src-tauri/src)
|
||||
|
||||
| 模块 | 用途 |
|
||||
|------|------|
|
||||
| lib.rs | Tauri 应用入口,命令注册(含 FFmpeg / 音频处理 / 视频合成命令) |
|
||||
| ffmpeg_cmd.rs | FFmpeg 命令封装(首帧提取、字幕压制、封面合成、音频替换/混音/标准化) |
|
||||
| video_processing.rs | 视频合成业务逻辑 |
|
||||
| storage/engine.rs | 本地存储引擎(原子写入、文件锁、路径净化) |
|
||||
| storage/paths.rs | 集中化路径计算 |
|
||||
| commands/project.rs | 项目本地存储 IPC 命令 |
|
||||
| commands/asset.rs | 资源文件保存 IPC 命令 |
|
||||
| commands/auth_state.rs | 认证状态文件持久化 |
|
||||
| commands/voice.rs | 音频文件保存/列表/删除 IPC 命令 |
|
||||
| commands/product.rs | 成品视频保存/列表/删除/重命名 IPC 命令 |
|
||||
| api_proxy.rs | Python API 代理转发 |
|
||||
| avatar_cache.rs | 头像视频缓存管理 |
|
||||
| `lib.rs` | Tauri 应用入口,命令注册,全局错误处理 |
|
||||
| `main.rs` | 二进制入口 |
|
||||
| `ffmpeg_cmd.rs` | FFmpeg 命令封装(首帧提取、字幕压制、封面合成、音频替换/混音/标准化) |
|
||||
| `video_processing.rs` | 视频合成业务逻辑 |
|
||||
| `api_proxy.rs` | Python API HTTP 代理转发 |
|
||||
| `auth.rs` | 认证相关 IPC 命令 |
|
||||
| `avatar_cache.rs` | 头像视频缓存管理、海报生成 |
|
||||
| `utils.rs` | 项目目录辅助函数 |
|
||||
| `storage/engine.rs` | 本地存储引擎(原子写入、文件锁、路径净化) |
|
||||
| `storage/paths.rs` | 集中化路径计算(`~/Documents/Meijiaka-zj/` 为根目录) |
|
||||
| `commands/project.rs` | 项目本地存储 IPC 命令 |
|
||||
| `commands/asset.rs` | 资源文件保存 IPC 命令 |
|
||||
| `commands/auth_state.rs` | 认证状态文件持久化 |
|
||||
| `commands/voice.rs` | 音频文件保存/列表/删除 IPC 命令 |
|
||||
| `commands/product.rs` | 成品视频保存/列表/删除/重命名 IPC 命令 |
|
||||
| `commands/avatar.rs` | 头像列表本地存储 |
|
||||
| `commands/avatar_material.rs` | 人物出镜素材 CRUD |
|
||||
| `commands/video_compose.rs` | 视频拼接、文件上传/下载 |
|
||||
|
||||
## 开发环境搭建
|
||||
|
||||
@@ -169,7 +203,7 @@ docker-compose up -d db redis
|
||||
# 安装依赖(使用 uv)
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
# 启动开发服务器(注意:Docker API 会占用 8080 端口)
|
||||
# 启动开发服务器(Docker API 会占用 8081 端口)
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# 另开终端启动 Async Engine Scheduler(必须同时启动,否则任务不会执行)
|
||||
@@ -177,15 +211,14 @@ python -m app.scheduler.main
|
||||
```
|
||||
|
||||
后端服务地址:
|
||||
- API: http://localhost:8080/api/v1
|
||||
- 文档: http://localhost:8080/docs
|
||||
- 健康检查: http://localhost:8080/health
|
||||
- API: http://localhost:8081/api/v1(Docker 映射端口)
|
||||
- 文档: http://localhost:8081/docs
|
||||
- 健康检查: http://localhost:8081/health
|
||||
|
||||
**Docker Compose 服务组成**(4 个服务):
|
||||
- `db`: PostgreSQL 15
|
||||
- `redis`: Redis 7
|
||||
- `api`: FastAPI 开发服务器(端口 8080→8000)
|
||||
**Docker Compose 服务组成**(2 个服务 + 外部网络依赖):
|
||||
- `api`: FastAPI 开发服务器(端口 **8081**→8000)
|
||||
- `scheduler`: Async Engine 统一调度器,处理所有第三方异步任务
|
||||
- 依赖外部网络 `meijiaka-network` 和外部 PostgreSQL/Redis 服务
|
||||
|
||||
### 2. 启动 Tauri 前端
|
||||
|
||||
@@ -293,11 +326,15 @@ npm run gen:api # 从 OpenAPI 生成 TypeScript 类型
|
||||
- `replace_audio_track` // 音频替换
|
||||
- `mix_audio_tracks` // 音频混音
|
||||
- `standardize_audio` // 音频标准化
|
||||
- `compose_video` // 视频拼接(Phase 2)
|
||||
- `save_project_meta*` / `load_project_meta*` // 本地文件系统
|
||||
- `save_project_segments*` / `load_project_segments*`
|
||||
- `save_project_asset` / `get_video_save_path` / `get_image_save_path`
|
||||
- `save_final_product` / `list_local_products` / `delete_local_product` / `rename_local_product`
|
||||
- `save_audio` / `list_project_audios` / `delete_audio`
|
||||
- `load_voice_materials` / `save_voice_material` / `delete_voice_material_cmd`
|
||||
- `load_avatar_materials` / `upload_avatar_material` / `rename_avatar_material` / `delete_avatar_material`
|
||||
- `query_avatar_cache` / `cache_avatar_video` / `save_avatar_poster`
|
||||
- 头像缓存相关 API
|
||||
|
||||
**添加新 API 流程**:
|
||||
@@ -311,19 +348,28 @@ npm run gen:api # 从 OpenAPI 生成 TypeScript 类型
|
||||
|
||||
```
|
||||
app/ai/
|
||||
├── model_router.py # 模型路由器(自动降级)
|
||||
├── model_router.py # 模型路由器(自动降级、YAML 配置热重载)
|
||||
├── providers/
|
||||
│ ├── base.py # Provider 抽象基类
|
||||
│ ├── generic_llm_provider.py # 通用 OpenAI 兼容 Provider
|
||||
│ ├── generic_llm_provider.py # 通用 OpenAI 兼容 Provider(含 Mock)
|
||||
│ ├── volcengine_provider.py # 火山方舟官方 SDK
|
||||
│ └── klingai_provider.py # KlingAI(可灵 AI)
|
||||
│ ├── klingai_provider.py # KlingAI(可灵 AI)
|
||||
│ ├── minimax_provider.py # MiniMax
|
||||
│ └── vidu_provider.py # Vidu
|
||||
└── prompts/ # 提示词模板(禁止硬编码)
|
||||
├── loader.py
|
||||
├── system/ # 系统提示词(按 category/subcategory 组织)
|
||||
├── user/ # 用户提示词模板
|
||||
├── polish/ # 润色提示词
|
||||
└── cover/ # 封面生成提示词
|
||||
```
|
||||
|
||||
支持的 AI 平台:
|
||||
- **火山方舟** (字节跳动) - LLM、字幕服务
|
||||
- **OpenAI** - GPT 系列(可选)
|
||||
- **可灵 AI** (快手) - TTS 语音合成、声音克隆
|
||||
- **可灵 AI** (快手) - TTS 语音合成、声音克隆、视频/图片生成
|
||||
- **MiniMax** - TTS 语音合成、声音克隆、视频生成
|
||||
- **Vidu** - TTS 语音合成、声音克隆、对口型(lip-sync)
|
||||
- **文心一言** (百度) - 可选
|
||||
- **通义千问** (阿里云) - 可选
|
||||
|
||||
@@ -353,7 +399,7 @@ API (POST /tasks/{type}) → Redis JobRegistry → AsyncEngine tick loop → Han
|
||||
| ScriptHandler | 10 | `script:slots` | LLM 脚本生成(含 AnyToCopy 视频文案提取) |
|
||||
| SubtitleHandler | 5 | `volc:subtitle_slots` | 火山引擎字幕/自动对齐 |
|
||||
| CopyHandler | 5 | `anytocopy:slots` | AnyToCopy 视频文案提取 |
|
||||
| TTSHandler | - | `kling:tts_slots` | Kling TTS 语音合成 |
|
||||
| TTSHandler | 10 | `kling:tts_slots` | Kling TTS 语音合成 |
|
||||
| AvatarHandler | 2 | `kling:avatar_slots` | Kling 形象克隆 |
|
||||
|
||||
### TokenManager(API 认证 Token 管理)
|
||||
@@ -394,10 +440,13 @@ Rust 层实现了 defense-in-depth 的本地存储系统:`src-tauri/src/storag
|
||||
- `with_file_lock()` — 通过 `fs2` 实现独占文件锁
|
||||
- `read_json<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 后的活跃重构中
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
# 封面制作功能实施方案(Fabric.js 版)
|
||||
|
||||
## 一、兼容性说明
|
||||
|
||||
Fabric.js 与现有架构**零冲突**:
|
||||
- 纯前端库,无需修改 Rust/后端
|
||||
- React 组件内直接使用,无兼容问题
|
||||
- 字体复用项目已有的 `DouyinSans`(嵌入字体)
|
||||
- PNG 导出通过 Fabric.js `toDataURL()` 直接生成
|
||||
|
||||
---
|
||||
|
||||
## 二、技术方案
|
||||
|
||||
### 方案:Fabric.js Canvas 预览 + PNG 导出
|
||||
|
||||
| 维度 | Fabric.js 方案 |
|
||||
|------|----------------|
|
||||
| 预览 | Fabric Canvas 实时渲染,所见即所得 |
|
||||
| 导出 | `canvas.toDataURL('image/png')` → Rust 保存 |
|
||||
| 文字控制 | 固定位置,禁止拖拽(`selectable: false`) |
|
||||
| 模板切换 | 重建 Fabric 对象,换文字样式/位置 |
|
||||
| 背景图 | `fabric.Image.fromURL()` 加载,居中裁剪填充 |
|
||||
|
||||
---
|
||||
|
||||
## 三、详细设计
|
||||
|
||||
### 3.1 安装依赖
|
||||
|
||||
```bash
|
||||
cd tauri-app
|
||||
npm install fabric@6
|
||||
```
|
||||
|
||||
### 3.2 两种固定模板
|
||||
|
||||
#### 模板1:双标题居中
|
||||
|
||||
```
|
||||
┌────────────────────────┐
|
||||
│ │
|
||||
│ 「主标题文案」 │ ← 72px,金色 #FFD700,粗体,y=35%
|
||||
│ │
|
||||
│ 「副标题文案」 │ ← 32px,白色 #FFFFFF,y=60%
|
||||
│ │
|
||||
│ │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
**Fabric 对象结构**:
|
||||
```typescript
|
||||
[
|
||||
fabric.Image(bgImage), // 背景图,锁定
|
||||
fabric.Text(mainTitle, { // 主标题
|
||||
fontSize: 72,
|
||||
fill: '#FFD700',
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
left: 540, top: 672, // 1080*0.35*1920/1080... 实际按坐标
|
||||
selectable: false,
|
||||
shadow: new fabric.Shadow({...}) // 黑色描边
|
||||
}),
|
||||
fabric.Text(subtitle, { // 副标题
|
||||
fontSize: 32,
|
||||
fill: '#FFFFFF',
|
||||
textAlign: 'center',
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
left: 540, top: 1152,
|
||||
selectable: false,
|
||||
shadow: new fabric.Shadow({...})
|
||||
})
|
||||
]
|
||||
```
|
||||
|
||||
#### 模板2:标题 + 标签列表
|
||||
|
||||
```
|
||||
┌────────────────────────┐
|
||||
│ │
|
||||
│ 「主标题文案」 │ ← 72px,金色 #FFD700,y=30%
|
||||
│ │
|
||||
│ │
|
||||
│ 标签1 标签2 标签3 │ ← 28px,白色,带圆角背景,y=72%
|
||||
│ 标签4 标签5 │
|
||||
│ │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
**Fabric 对象结构**:
|
||||
```typescript
|
||||
[
|
||||
fabric.Image(bgImage), // 背景图
|
||||
fabric.Text(mainTitle, {...}), // 主标题
|
||||
fabric.Group([ // 标签组(多个 fabric.Rect + fabric.Text)
|
||||
fabric.Rect({fill: 'rgba(0,0,0,0.5)', rx: 8, ry: 8}),
|
||||
fabric.Text('标签1', {fill: '#FFFFFF', fontSize: 28}),
|
||||
...
|
||||
])
|
||||
]
|
||||
```
|
||||
|
||||
### 3.3 界面布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 封面制作 │
|
||||
├───────────────────────────────┬─────────────────────────────┤
|
||||
│ │ │
|
||||
│ [模板选择] │ │
|
||||
│ ┌────┐ ┌────┐ │ ┌─────────────┐ │
|
||||
│ │模板1│ │模板2│ │ │ │ │
|
||||
│ │缩略图│ │缩略图│ │ │ Fabric │ │
|
||||
│ └────┘ └────┘ │ │ Canvas │ │
|
||||
│ │ │ 预览 │ │
|
||||
│ [背景图] │ │ │ │
|
||||
│ ┌─────────┐ [选择图片] │ └─────────────┘ │
|
||||
│ │ 缩略图 │ [清除] │ │
|
||||
│ └─────────┘ │ │
|
||||
│ │ │
|
||||
│ [主标题] │ │
|
||||
│ ┌───────────────────────┐ │ │
|
||||
│ │ 输入主标题文案... │ │ │
|
||||
│ └───────────────────────┘ │ │
|
||||
│ │ │
|
||||
│ [副标题/标签] │ │
|
||||
│ ┌───────────────────────┐ │ │
|
||||
│ │ 输入副标题或标签... │ │ │
|
||||
│ └───────────────────────┘ │ │
|
||||
│ │ │
|
||||
│ [生成封面图] │ │
|
||||
│ │ │
|
||||
└───────────────────────────────┴─────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、核心数据结构
|
||||
|
||||
```typescript
|
||||
// 模板类型
|
||||
type CoverTemplate = 'dual-title' | 'title-tags';
|
||||
|
||||
// 封面配置(保存到 store)
|
||||
interface CoverConfig {
|
||||
template: CoverTemplate;
|
||||
backgroundImage: string | null; // 本地图片路径
|
||||
mainTitle: string;
|
||||
subtitle: string; // 模板1=副标题,模板2=逗号分隔的标签
|
||||
}
|
||||
|
||||
// Fabric.js 模板定义
|
||||
interface FabricTemplateDef {
|
||||
name: CoverTemplate;
|
||||
width: number; // 1080
|
||||
height: number; // 1920
|
||||
mainTitle: {
|
||||
fontSize: number;
|
||||
fill: string;
|
||||
top: number; // y坐标
|
||||
fontWeight: string;
|
||||
shadow?: fabric.Shadow;
|
||||
};
|
||||
subtitle: {
|
||||
fontSize: number;
|
||||
fill: string;
|
||||
top: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、核心 Hook 设计
|
||||
|
||||
### 5.1 useCoverFabric Hook
|
||||
|
||||
**新文件**:`src/hooks/useCoverFabric.ts`
|
||||
|
||||
```typescript
|
||||
import { useRef, useCallback, useEffect } from 'react';
|
||||
import * as fabric from 'fabric';
|
||||
|
||||
const CANVAS_WIDTH = 1080;
|
||||
const CANVAS_HEIGHT = 1920;
|
||||
|
||||
export function useCoverFabric() {
|
||||
const canvasRef = useRef<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'`
|
||||
@@ -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 px(9:16竖屏)
|
||||
- **预览尺寸**:270 × 480 px(CSS缩放)
|
||||
- **背景适配**:使用 `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()` 缩放
|
||||
@@ -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) │ │
|
||||
│ │ [自动匹配] → ✗ 素材不足 │ │
|
||||
│ │ (需要 4s,plumbing 只有 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; // 空镜:七牛云 URL;segment:null(用本地形象)
|
||||
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 秒时提示用户
|
||||
|
||||
**确认后我开始写。**
|
||||
@@ -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 3:Vidu 合成流水线
|
||||
|
||||
| # | 文件 | 内容 |
|
||||
|---|------|------|
|
||||
| 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
|
||||
|
||||
**确认后我开始写。**
|
||||
@@ -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 {
|
||||
// 空镜:直接用七牛云 URL(FFmpeg 支持 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 合成(不下载本地)
|
||||
- [ ] 素材更新方式:你发给我 → 我改代码
|
||||
|
||||
**全部确认后,我开始按清单写代码。**
|
||||
@@ -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 2:API 层(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.com(CDN 加速)
|
||||
```
|
||||
|
||||
### 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)
|
||||
@@ -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="匹配成功",
|
||||
)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
|
||||
# 关闭时清理
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Generated
+1237
-36
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
Generated
+18
@@ -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"
|
||||
|
||||
@@ -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),无法避免重复
|
||||
|
||||
@@ -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 客户端
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -10,3 +10,4 @@ pub mod avatar;
|
||||
pub mod product;
|
||||
pub mod project;
|
||||
pub mod voice;
|
||||
pub mod video_compose;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//! 提供路径净化、原子写入、文件锁三大核心能力,
|
||||
//! 所有本地 JSON/二进制文件的读写必须通过此层。
|
||||
|
||||
#![allow(unused_imports)]
|
||||
|
||||
mod engine;
|
||||
mod paths;
|
||||
|
||||
|
||||
@@ -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; // 脚本生成时的脚本类型
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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) ======================
|
||||
|
||||
/** 保存音频文件到本地 */
|
||||
|
||||
@@ -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: '我的作品' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 空镜素材映射表(已迁移到后端)
|
||||
* ==============================
|
||||
*
|
||||
* 空镜素材配置和匹配逻辑已迁移到后端,通过 `/api/v1/materials/match` 接口获取。
|
||||
* 本文件保留类型定义和导出,实际调用在 `api/modules/materials.ts` 中。
|
||||
*/
|
||||
|
||||
export interface MaterialInfo {
|
||||
url: string;
|
||||
duration: number;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
// 优先使用七牛持久 URL,fallback 到 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>
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user