feat: 视频生成页面改造、字幕冻结修复及多项前端优化

- 修复字幕切换模板后冻结的 bug:ASS.js 新实例在视频播放中创建时收不到
  play/playing 事件,RAF 循环不会启动。创建实例后手动触发 play 事件。
- VideoGeneration 页面 overhaul:卡片点击预览、左右箭头导航、换一个素材、
  动态按钮文案和占位提示。
- 修复私有音色素材预览播放 trialUrl 的问题,改为播放 sourceUrl。
- 放宽空镜素材匹配逻辑:优先满足时长,fallback 到最近时长并随机选择。
- 隐藏脚本生成页面的时长滑块。
- 修复登录页和侧边栏标题渐变 WebKit 兼容问题。
- 清理旧计划文档、测试文件和临时脚本。
- 更新 Makefile、prompts、materials.json 等配置。
This commit is contained in:
小鱼开发
2026-04-23 23:17:10 +08:00
parent 26db375a84
commit 285257905e
56 changed files with 1554 additions and 6316 deletions
-667
View File
@@ -1,667 +0,0 @@
# 封面制作功能实施方案(Fabric.js 版)
## 一、兼容性说明
Fabric.js 与现有架构**零冲突**
- 纯前端库,无需修改 Rust/后端
- React 组件内直接使用,无兼容问题
- 字体复用项目已有的 `DouyinSans`(嵌入字体)
- PNG 导出通过 Fabric.js `toDataURL()` 直接生成
---
## 二、技术方案
### 方案:Fabric.js Canvas 预览 + PNG 导出
| 维度 | Fabric.js 方案 |
|------|----------------|
| 预览 | Fabric Canvas 实时渲染,所见即所得 |
| 导出 | `canvas.toDataURL('image/png')` → Rust 保存 |
| 文字控制 | 固定位置,禁止拖拽(`selectable: false` |
| 模板切换 | 重建 Fabric 对象,换文字样式/位置 |
| 背景图 | `fabric.Image.fromURL()` 加载,居中裁剪填充 |
---
## 三、详细设计
### 3.1 安装依赖
```bash
cd tauri-app
npm install fabric@6
```
### 3.2 两种固定模板
#### 模板1:双标题居中
```
┌────────────────────────┐
│ │
│ 「主标题文案」 │ ← 72px,金色 #FFD700,粗体,y=35%
│ │
│ 「副标题文案」 │ ← 32px,白色 #FFFFFFy=60%
│ │
│ │
└────────────────────────┘
```
**Fabric 对象结构**
```typescript
[
fabric.Image(bgImage), // 背景图,锁定
fabric.Text(mainTitle, { // 主标题
fontSize: 72,
fill: '#FFD700',
fontWeight: 'bold',
textAlign: 'center',
originX: 'center',
originY: 'center',
left: 540, top: 672, // 1080*0.35*1920/1080... 实际按坐标
selectable: false,
shadow: new fabric.Shadow({...}) // 黑色描边
}),
fabric.Text(subtitle, { // 副标题
fontSize: 32,
fill: '#FFFFFF',
textAlign: 'center',
originX: 'center',
originY: 'center',
left: 540, top: 1152,
selectable: false,
shadow: new fabric.Shadow({...})
})
]
```
#### 模板2:标题 + 标签列表
```
┌────────────────────────┐
│ │
│ 「主标题文案」 │ ← 72px,金色 #FFD700y=30%
│ │
│ │
│ 标签1 标签2 标签3 │ ← 28px,白色,带圆角背景,y=72%
│ 标签4 标签5 │
│ │
└────────────────────────┘
```
**Fabric 对象结构**
```typescript
[
fabric.Image(bgImage), // 背景图
fabric.Text(mainTitle, {...}), // 主标题
fabric.Group([ // 标签组(多个 fabric.Rect + fabric.Text
fabric.Rect({fill: 'rgba(0,0,0,0.5)', rx: 8, ry: 8}),
fabric.Text('标签1', {fill: '#FFFFFF', fontSize: 28}),
...
])
]
```
### 3.3 界面布局
```
┌─────────────────────────────────────────────────────────────┐
│ 封面制作 │
├───────────────────────────────┬─────────────────────────────┤
│ │ │
│ [模板选择] │ │
│ ┌────┐ ┌────┐ │ ┌─────────────┐ │
│ │模板1│ │模板2│ │ │ │ │
│ │缩略图│ │缩略图│ │ │ Fabric │ │
│ └────┘ └────┘ │ │ Canvas │ │
│ │ │ 预览 │ │
│ [背景图] │ │ │ │
│ ┌─────────┐ [选择图片] │ └─────────────┘ │
│ │ 缩略图 │ [清除] │ │
│ └─────────┘ │ │
│ │ │
│ [主标题] │ │
│ ┌───────────────────────┐ │ │
│ │ 输入主标题文案... │ │ │
│ └───────────────────────┘ │ │
│ │ │
│ [副标题/标签] │ │
│ ┌───────────────────────┐ │ │
│ │ 输入副标题或标签... │ │ │
│ └───────────────────────┘ │ │
│ │ │
│ [生成封面图] │ │
│ │ │
└───────────────────────────────┴─────────────────────────────┘
```
---
## 四、核心数据结构
```typescript
// 模板类型
type CoverTemplate = 'dual-title' | 'title-tags';
// 封面配置(保存到 store
interface CoverConfig {
template: CoverTemplate;
backgroundImage: string | null; // 本地图片路径
mainTitle: string;
subtitle: string; // 模板1=副标题,模板2=逗号分隔的标签
}
// Fabric.js 模板定义
interface FabricTemplateDef {
name: CoverTemplate;
width: number; // 1080
height: number; // 1920
mainTitle: {
fontSize: number;
fill: string;
top: number; // y坐标
fontWeight: string;
shadow?: fabric.Shadow;
};
subtitle: {
fontSize: number;
fill: string;
top: number;
};
}
```
---
## 五、核心 Hook 设计
### 5.1 useCoverFabric Hook
**新文件**`src/hooks/useCoverFabric.ts`
```typescript
import { useRef, useCallback, useEffect } from 'react';
import * as fabric from 'fabric';
const CANVAS_WIDTH = 1080;
const CANVAS_HEIGHT = 1920;
export function useCoverFabric() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const fabricCanvasRef = useRef<fabric.Canvas | null>(null);
// 初始化 Fabric Canvas
const initCanvas = useCallback(() => {
if (!canvasRef.current || fabricCanvasRef.current) return;
const canvas = new fabric.Canvas(canvasRef.current, {
width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
backgroundColor: '#1a1a2e',
selection: false, // 禁用框选
interactive: false, // 禁用所有交互(只读预览)
});
fabricCanvasRef.current = canvas;
}, []);
// 渲染封面
const renderCover = useCallback(async (
config: CoverConfig,
previewScale: number = 1
) => {
const canvas = fabricCanvasRef.current;
if (!canvas) return;
canvas.clear();
// 1. 绘制背景图
if (config.backgroundImage) {
await loadBackgroundImage(canvas, config.backgroundImage);
}
// 2. 根据模板添加文字
const template = TEMPLATES[config.template];
// 主标题
const mainText = new fabric.Text(config.mainTitle, {
left: CANVAS_WIDTH / 2,
top: template.mainTitle.top,
fontSize: template.mainTitle.fontSize,
fill: template.mainTitle.fill,
fontWeight: template.mainTitle.fontWeight,
fontFamily: '"DouyinSans", "PingFang SC", sans-serif',
textAlign: 'center',
originX: 'center',
originY: 'center',
selectable: false,
shadow: new fabric.Shadow({
color: 'rgba(0,0,0,0.8)',
blur: 4,
offsetX: 2,
offsetY: 2,
}),
});
canvas.add(mainText);
// 副标题/标签
if (config.template === 'dual-title') {
const subText = new fabric.Text(config.subtitle, {
left: CANVAS_WIDTH / 2,
top: template.subtitle.top,
fontSize: template.subtitle.fontSize,
fill: template.subtitle.fill,
fontFamily: '"DouyinSans", "PingFang SC", sans-serif',
textAlign: 'center',
originX: 'center',
originY: 'center',
selectable: false,
shadow: new fabric.Shadow({
color: 'rgba(0,0,0,0.6)',
blur: 3,
offsetX: 1,
offsetY: 1,
}),
});
canvas.add(subText);
} else {
// 标签列表:解析逗号分隔,自动换行布局
renderTagList(canvas, config.subtitle, template);
}
canvas.renderAll();
}, []);
// 导出 PNG
const exportPng = useCallback((): string => {
const canvas = fabricCanvasRef.current;
if (!canvas) return '';
return canvas.toDataURL({ format: 'png', quality: 1.0 });
}, []);
// 销毁
const destroy = useCallback(() => {
fabricCanvasRef.current?.dispose();
fabricCanvasRef.current = null;
}, []);
return { canvasRef, initCanvas, renderCover, exportPng, destroy };
}
// 加载背景图(居中裁剪填充)
async function loadBackgroundImage(
canvas: fabric.Canvas,
imagePath: string
): Promise<void> {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const fabricImg = new fabric.Image(img);
// 计算缩放和裁剪(cover 模式)
const scale = Math.max(
CANVAS_WIDTH / fabricImg.width!,
CANVAS_HEIGHT / fabricImg.height!
);
fabricImg.scale(scale);
fabricImg.set({
left: (CANVAS_WIDTH - fabricImg.getScaledWidth()) / 2,
top: (CANVAS_HEIGHT - fabricImg.getScaledHeight()) / 2,
selectable: false,
evented: false,
});
canvas.add(fabricImg);
canvas.sendObjectToBack(fabricImg);
resolve();
};
img.src = `asset://localhost/${encodeURIComponent(imagePath)}`;
});
}
// 渲染标签列表
function renderTagList(
canvas: fabric.Canvas,
tagsText: string,
template: FabricTemplateDef
) {
const tags = tagsText.split(/[,]/).map(t => t.trim()).filter(Boolean);
if (tags.length === 0) return;
const tagHeight = 56;
const tagPadding = 20;
const gapX = 16;
const gapY = 16;
const maxLineWidth = CANVAS_WIDTH - 120; // 左右各60px边距
const groups: fabric.Group[] = [];
let currentX = 60;
let currentY = template.subtitle.top;
for (const tag of tags) {
const text = new fabric.Text(tag, {
fontSize: 28,
fill: '#FFFFFF',
fontFamily: '"DouyinSans", "PingFang SC", sans-serif',
left: tagPadding,
top: tagHeight / 2,
originX: 'center',
originY: 'center',
});
const rect = new fabric.Rect({
width: text.width! + tagPadding * 2,
height: tagHeight,
fill: 'rgba(0,0,0,0.5)',
rx: 8,
ry: 8,
});
const group = new fabric.Group([rect, text], {
left: currentX,
top: currentY,
selectable: false,
evented: false,
});
// 换行检查
if (currentX + group.width! > CANVAS_WIDTH - 60 && currentX > 60) {
currentX = 60;
currentY += tagHeight + gapY;
group.set({ left: currentX, top: currentY });
}
currentX += group.width! + gapX;
groups.push(group);
}
// 整体居中
const allTags = new fabric.Group(groups, {
left: CANVAS_WIDTH / 2,
top: template.subtitle.top,
originX: 'center',
originY: 'top',
selectable: false,
evented: false,
});
canvas.add(allTags);
}
// 模板定义
const TEMPLATES: Record<CoverTemplate, FabricTemplateDef> = {
'dual-title': {
name: 'dual-title',
width: 1080,
height: 1920,
mainTitle: { fontSize: 72, fill: '#FFD700', top: 672, fontWeight: 'bold' },
subtitle: { fontSize: 32, fill: '#FFFFFF', top: 1152 },
},
'title-tags': {
name: 'title-tags',
width: 1080,
height: 1920,
mainTitle: { fontSize: 72, fill: '#FFD700', top: 576, fontWeight: 'bold' },
subtitle: { fontSize: 28, fill: '#FFFFFF', top: 1382 },
},
};
```
---
## 六、文件改动清单
| 文件 | 类型 | 说明 |
|------|------|------|
| `package.json` | 修改 | 添加 `fabric@6` 依赖 |
| `CoverDesign.tsx` | 重构 | 使用 Fabric.js 替换现有实现 |
| `CoverDesign.css` | 调整 | 适配新布局,预览区固定尺寸 |
| `src/hooks/useCoverFabric.ts` | **新增** | Fabric.js 渲染 Hook |
| `src/store/projectStore.ts` | 调整 | 更新 CoverConfig 类型 |
---
## 七、CoverDesign.tsx 关键代码
```typescript
import { open } from '@tauri-apps/plugin-dialog';
import { useCoverFabric } from '../../hooks/useCoverFabric';
export default function CoverDesign() {
const { canvasRef, initCanvas, renderCover, exportPng, destroy } = useCoverFabric();
const [config, setConfig] = useState<CoverConfig>({
template: 'dual-title',
backgroundImage: null,
mainTitle: '',
subtitle: '',
});
// 初始化 Fabric Canvas
useEffect(() => {
initCanvas();
return destroy;
}, [initCanvas, destroy]);
// 配置变化时重新渲染
useEffect(() => {
renderCover(config);
}, [config, renderCover]);
// 选择图片
const handleSelectImage = async () => {
const selected = await open({
multiple: false,
filters: [{ name: '图片', extensions: ['jpg', 'jpeg', 'png', 'webp'] }]
});
if (selected) {
setConfig(prev => ({ ...prev, backgroundImage: selected as string }));
}
};
// 生成封面
const handleGenerate = async () => {
const dataUrl = exportPng();
const base64 = dataUrl.split(',')[1];
const result = await invoke('save_project_asset', {
projectId,
filename: `cover_${Date.now()}.png`,
base64Data: base64,
});
if (result.code === 200) {
setCoverPath(result.data);
setCoverConfig(config);
}
};
return (
<div className="step-layout cover-design">
{/* 左侧配置 */}
<div className="step-panel-left">
{/* 模板选择 */}
<div className="panel-section">
<label className="panel-label"></label>
<div className="template-selector">
<button className={config.template === 'dual-title' ? 'active' : ''}
onClick={() => setConfig(p => ({ ...p, template: 'dual-title' }))}>
</button>
<button className={config.template === 'title-tags' ? 'active' : ''}
onClick={() => setConfig(p => ({ ...p, template: 'title-tags' }))}>
+
</button>
</div>
</div>
{/* 背景图 */}
<div className="panel-section">
<label className="panel-label"></label>
<div className="bg-image-control">
{config.backgroundImage && (
<img src={`asset://localhost/${encodeURIComponent(config.backgroundImage)}`}
className="bg-preview-thumb" alt="背景" />
)}
<button className="btn btn-secondary" onClick={handleSelectImage}>
</button>
{config.backgroundImage && (
<button className="btn btn-ghost" onClick={() => setConfig(p => ({ ...p, backgroundImage: null }))}>
</button>
)}
</div>
</div>
{/* 主标题 */}
<div className="panel-section">
<label className="panel-label"></label>
<textarea
className="input"
rows={2}
placeholder="输入主标题文案"
value={config.mainTitle}
onChange={e => setConfig(p => ({ ...p, mainTitle: e.target.value }))}
/>
</div>
{/* 副标题/标签 */}
<div className="panel-section">
<label className="panel-label">
{config.template === 'dual-title' ? '副标题' : '标签列表(用逗号分隔)'}
</label>
<textarea
className="input"
rows={2}
placeholder={config.template === 'dual-title' ? '输入副标题文案' : '标签1, 标签2, 标签3'}
value={config.subtitle}
onChange={e => setConfig(p => ({ ...p, subtitle: e.target.value }))}
/>
</div>
{/* 生成按钮 */}
<button className="btn btn-primary cover-generate-btn"
disabled={!config.mainTitle.trim()}
onClick={handleGenerate}>
</button>
</div>
{/* 右侧预览 */}
<div className="step-panel-right">
<div className="cover-preview-wrapper">
<canvas ref={canvasRef} className="cover-fabric-canvas" />
</div>
</div>
</div>
);
}
```
---
## 八、CSS 调整
```css
/* 预览区固定尺寸,CSS 缩放 */
.cover-preview-wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.cover-fabric-canvas {
width: 270px; /* 1080 / 4 */
height: 480px; /* 1920 / 4 */
background: #1a1a2e;
border-radius: var(--radius-xl);
box-shadow: 0 8px 32px rgb(0 0 0 / 30%);
}
/* 模板选择 */
.template-selector {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-sm);
}
.template-selector button {
padding: var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-card);
cursor: pointer;
transition: all var(--transition-fast);
}
.template-selector button.active {
border-color: var(--primary);
background: var(--primary-light);
color: var(--primary);
}
/* 背景图缩略图 */
.bg-preview-thumb {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: var(--radius-md);
border: 1px solid var(--border-light);
}
.bg-image-control {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
```
---
## 九、Store 类型更新
```typescript
// src/store/projectStore.ts
interface CoverConfig {
template?: 'dual-title' | 'title-tags';
backgroundImage?: string | null;
mainTitle?: string;
subtitle?: string;
// 保留旧字段兼容
caption?: string;
coverStyle?: any;
selectedPreset?: string;
}
```
---
## 十、实施步骤
| 步骤 | 任务 | 预计时间 |
|------|------|----------|
| 1 | `npm install fabric@6` | 5min |
| 2 | 创建 `useCoverFabric.ts` Hook | 2h |
| 3 | 重构 `CoverDesign.tsx` | 2h |
| 4 | 调整 `CoverDesign.css` | 30min |
| 5 | 更新 Store 类型 | 15min |
| 6 | 测试模板切换、文字渲染、导出 | 1h |
| 7 | 字体加载和高清导出调优 | 30min |
**总计**:约 6.5 小时
---
## 十一、注意事项
1. **字体加载**Fabric.js 的 `fontFamily` 需等待字体加载完成,否则首次渲染可能用回退字体
- 解决:`document.fonts.ready.then(() => canvas.renderAll())`
2. **Tauri asset 协议**:背景图路径用 `asset://localhost/${encodeURIComponent(path)}`
3. **Canvas 缩放**Fabric Canvas 保持 1080×1920,外层 CSS 缩放到 270×480,保证高清导出
4. **标签换行**:模板2的标签自动计算宽度,超出一行时换行,整体垂直居中
5. **图片跨域**:Tauri 本地文件无跨域问题,但需设置 `img.crossOrigin = 'anonymous'`
-350
View File
@@ -1,350 +0,0 @@
# 封面制作功能实施方案
## 一、需求概述
用户需求:
1. **背景图**:支持选择本地图片(弹窗选取),替换现有"视频首帧"方案
2. **标题样式**:主标题 + 副标题,位置和样式固定
3. **实时预览**:输入文案后立即预览效果
---
## 二、技术方案
### 方案选择:Canvas 实时预览 + PNG 导出
| 方案 | 优点 | 缺点 |
|------|------|------|
| ASS压制 | 字体渲染专业 | 预览需assjs,渲染有差异 |
| **Canvas预览+导出PNG** | 所见即所得,无差异 | 需额外处理字体 |
| Fabric.js | API丰富 | ~200KB包体积 |
**推荐方案:纯 Canvas 方案**
- 前端使用 Canvas API 绘制封面
- 预览和导出同一套代码,保证一致性
- 导出 PNG 后通过 Rust 复制到本地存储(或直接上传七牛云)
---
## 三、详细设计
### 3.1 封面模板
提供 **2 种固定模板**
#### 模板1:双标题居中
```
┌────────────────────────┐
│ │
│ 主标题(大) │ ← 72px,金色(#FFD700),粗体,y=30%
│ │
│ 副标题(小) │ ← 32px,白色,y=60%
│ │
│ │
└────────────────────────┘
```
#### 模板2:标题+标签列表
```
┌────────────────────────┐
│ │
│ 主标题(大) │ ← 72px,金色(#FFD700),粗体,y=25%
│ │
│ 标签1 标签2 标签3 │ ← 28px,白色,y=75%
│ 标签4 标签5 │
│ │
└────────────────────────┘
```
### 3.2 界面设计
```
┌─────────────────────────────────────────────────────────────┐
│ 封面制作 │
├───────────────────────────────┬─────────────────────────────┤
│ │ │
│ [模板选择] │ │
│ ○ 模板1: 双标题居中 │ ┌─────────────┐ │
│ ○ 模板2: 标题+标签列表 │ │ │ │
│ │ │ 预览区 │ │
│ [背景图] │ │ (Canvas) │ │
│ ┌─────────┐ [选择图片] │ │ │ │
│ │ 缩略图 │ [清除] │ └─────────────┘ │
│ └─────────┘ │ │
│ │ │
│ [主标题] │ │
│ ┌───────────────────────┐ │ │
│ │ 输入主标题文案... │ │ │
│ └───────────────────────┘ │ │
│ │ │
│ [副标题/标签] │ │
│ ┌───────────────────────┐ │ │
│ │ 输入副标题或标签... │ │ │
│ └───────────────────────┘ │ │
│ │ │
│ [生成封面图] │ │
│ │ │
└───────────────────────────────┴─────────────────────────────┘
```
### 3.3 数据结构
```typescript
interface CoverDesignConfig {
template: 'dual-title' | 'title-with-tags'; // 模板类型
background: string | null; // 背景图本地路径
mainTitle: {
text: string;
fontSize: number; // 固定72px
color: string; // 固定金色 #FFD700
fontWeight: 'bold';
position: { y: number }; // y轴百分比
};
subtitle: {
text: string;
fontSize: number; // 模板1=32px, 模板2=28px
color: string; // 固定白色 #FFFFFF
position: { y: number }; // y轴百分比
};
}
```
### 3.4 封面尺寸
- **导出尺寸**1080 × 1920 px9:16竖屏)
- **预览尺寸**270 × 480 pxCSS缩放)
- **背景适配**:使用 `object-fit: cover` 填充,超出部分裁剪
---
## 四、实现步骤
### Step 1: 添加图片选择功能
**文件**`CoverDesign.tsx`
```typescript
import { open } from '@tauri-apps/plugin-dialog';
const handleSelectImage = async () => {
const selected = await open({
multiple: false,
filters: [{ name: '图片', extensions: ['jpg', 'jpeg', 'png', 'webp'] }]
});
if (selected) {
setBackgroundImage(selected as string);
}
};
```
### Step 2: 创建 Canvas 渲染 Hook
**新文件**`src/hooks/useCoverCanvas.ts`
```typescript
export function useCoverCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawCover = useCallback((
ctx: CanvasRenderingContext2D,
config: CoverDesignConfig,
width: number,
height: number
) => {
// 1. 绘制背景
// 2. 绘制主标题
// 3. 绘制副标题
}, []);
const exportAsPng = useCallback(() => {
// 导出 1080x1920 PNG
}, []);
return { canvasRef, drawCover, exportAsPng };
}
```
### Step 3: 重构 CoverDesign 组件
**主要改动**
| 改动点 | 说明 |
|--------|------|
| 移除 `useLocalVideo` | 不再依赖视频首帧 |
| 新增背景图状态 | `backgroundImage: string \| null` |
| 替换预览区 | `<canvas>` 替代 `<video>` + CSS overlay |
| 简化样式预设 | 移除字体大小滑块,改用固定模板 |
| 简化生成逻辑 | Canvas 导出 PNG |
### Step 4: 修改生成导出逻辑
```typescript
const handleGenerate = async () => {
// 1. 获取 Canvas 导出的 PNG (Base64)
const pngDataUrl = canvasRef.current.toDataURL('image/png');
// 2. 转换为 Base64 字符串
const base64 = pngDataUrl.split(',')[1];
// 3. 通过 Rust 命令保存
const result = await invoke('save_project_asset', {
projectId,
filename: `cover_${Date.now()}.png`,
base64Data: base64,
});
};
```
### Step 5: 更新 Store 接口
**文件**`src/store/projectStore.ts`
```typescript
interface CoverConfig {
template?: 'dual-title' | 'title-with-tags';
caption?: string;
subtitle?: string;
coverStyle?: any;
selectedPreset?: string;
backgroundImage?: string | null; // 新增
}
```
---
## 五、文件改动清单
| 文件 | 改动类型 | 说明 |
|------|----------|------|
| `CoverDesign.tsx` | 重构 | 替换为 Canvas 预览方案 |
| `CoverDesign.css` | 调整 | 适配新的布局 |
| `src/hooks/useCoverCanvas.ts` | 新增 | Canvas 渲染 hook |
| `src/store/projectStore.ts` | 调整 | 添加 backgroundImage 字段 |
---
## 六、模板样式定义
### 模板1:双标题居中
```typescript
const TEMPLATE_DUAL_TITLE = {
mainTitle: {
fontSize: 72,
color: '#FFD700',
fontWeight: 'bold',
positionY: 0.30, // 30%
},
subtitle: {
fontSize: 32,
color: '#FFFFFF',
positionY: 0.55,
},
};
```
### 模板2:标题+标签列表
```typescript
const TEMPLATE_TITLE_TAGS = {
mainTitle: {
fontSize: 72,
color: '#FFD700',
fontWeight: 'bold',
positionY: 0.25,
},
subtitle: {
fontSize: 28,
color: '#FFFFFF',
positionY: 0.70,
// 标签自动换行,每行最多显示5个
},
};
```
---
## 七、Canvas 绘制细节
### 7.1 文字渲染
```typescript
ctx.font = `${fontWeight} ${fontSize}px "DouyinSans", "PingFang SC", sans-serif`;
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
```
### 7.2 描边效果
```typescript
ctx.strokeStyle = '#000000';
ctx.lineWidth = strokeWidth;
ctx.strokeText(text, x, y);
```
### 7.3 文字换行(副标题/标签)
```typescript
function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
const words = text.split('');
const lines: string[] = [];
let currentLine = '';
for (const char of words) {
const testLine = currentLine + char;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine) {
lines.push(currentLine);
currentLine = char;
} else {
currentLine = testLine;
}
}
if (currentLine) lines.push(currentLine);
return lines;
}
```
### 7.4 背景图绘制
```typescript
const img = new Image();
img.onload = () => {
// 计算裁剪区域(居中裁剪)
const scale = Math.max(width / img.width, height / img.height);
const scaledWidth = img.width * scale;
const scaledHeight = img.height * scale;
const dx = (width - scaledWidth) / 2;
const dy = (height - scaledHeight) / 2;
ctx.drawImage(img, dx, dy, scaledWidth, scaledHeight);
};
img.src = `asset://localhost/${encodeURIComponent(imagePath)}`;
```
---
## 八、进度安排
| 阶段 | 任务 | 预计时间 |
|------|------|----------|
| Phase 1 | 添加图片选择功能,基础布局调整 | 1h |
| Phase 2 | 实现 Canvas 渲染 Hook | 2h |
| Phase 3 | 重构预览区为 Canvas | 2h |
| Phase 4 | 实现模板切换和文字渲染 | 2h |
| Phase 5 | 导出 PNG 并保存 | 1h |
| Phase 6 | 测试和样式调优 | 1h |
**总计**:约 9 小时
---
## 九、注意事项
1. **字体加载**:确保字体文件已嵌入,Canvas 需等待字体加载完成后再绘制
2. **跨域问题**Tauri 的 `asset://` 协议需要正确配置
3. **高清导出**:导出时使用 `toBlob('image/png', 1.0)` 保证最高质量
4. **性能优化**:防抖处理输入,避免频繁重绘
5. **移动端适配**:预览区使用 CSS `transform: scale()` 缩放
-65
View File
@@ -1,65 +0,0 @@
# "美家卡智剪"项目终版详细执行方案 (v4)
**总体目标:**`/Users/0fun/work/meijiaka-zj` 目录下,以 `/Users/0fun/work/ai-meijiaka` 为模板,创建一个全新的、遵循“本地为王,云端为辅”架构的“美家卡智剪”应用。
---
### **阶段零:项目初始化与代码剥离**
**阶段架构原则:** 这是奠基阶段。我们的目标是创建一个干净、独立、且符合“智剪”项目定位的代码骨架。我们将物理上复制源码,并从逻辑上剥离所有与“智影”云端架构相关的、不再需要的部分。
#### **任务 0.1: 创建新项目目录并复制源码**
* **目标:** 搭建新项目的物理文件结构。
* **架构角色与数据流:** 这是文件系统层面的操作,为后续所有开发工作提供一个隔离的工作区。
* **具体步骤:**
1. **创建目录:** 执行 `mkdir /Users/0fun/work/meijiaka-zj` 命令。
2. **复制源码:** 执行 `rsync` 命令,将 `ai-meijiaka` 的代码干净地复制到 `meijiaka-zj`。此命令会智能地排除无需复制的目录(如 `.git`, `node_modules`, `__pycache__` 等),确保新项目有一个纯净的起点。
```bash
rsync -av --exclude='.git' --exclude='node_modules' --exclude='.venv' --exclude='__pycache__' --exclude='target' --exclude='*.lock' /Users/0fun/work/ai-meijiaka/ /Users/0fun/work/meijiaka-zj/
```
3. **初始化版本控制:** 在新目录 `/Users/0fun/work/meijiaka-zj` 中执行 `git init` 和 `git add . && git commit -m "init: fork from meijiaka-zy"`,为新项目建立独立的版本历史。
#### **任务 0.2: 项目品牌化与架构清理**
* **目标:** 修改新项目中的配置,使其从“智影”变为“智剪”,并移除无用的代码。
* **架构角色与数据流:** 这是对代码库的“净化”操作,确保新架构的纯粹性。
* **具体步骤:**
1. **品牌化修改:**
* **Tauri 配置:** 修改 `/Users/0fun/work/meijiaka-zj/tauri-app/src-tauri/tauri.conf.json`,将 `productName` 和 `title` 从“智影”改为“智剪”,并更新 `identifier`。
* **后端文档:** 修改 `/Users/0fun/work/meijiaka-zj/python-api/app/main.py` 中的 FastAPI 应用标题和描述。
* **文档文件:** 更新根目录和各子目录下的 `README.md` 文件,反映“智剪”项目的新特性。
2. **架构清理 (关键步骤):**
* **移除云端模型:** 在 `/Users/0fun/work/meijiaka-zj/python-api/app/models/` 目录下,删除与云端项目管理相关的 SQLAlchemy 模型,例如 `project.py`, `script.py` 等(但保留 `user.py` 用于可能的设备认证)。
* **移除云端API:** 在 `/Users/0fun/work/meijiaka-zj/python-api/app/api/v1/` 目录下,删除对应的API路由文件。
* **移除数据库迁移:** 清空或删除 `/Users/0fun/work/meijiaka-zj/python-api/alembic/versions/` 目录下的所有数据库迁移脚本,因为新项目没有数据库。
* **移除前端相关状态:** 检查 `/Users/0fun/work/meijiaka-zj/tauri-app/src/store/`,移除或重构那些强依赖云端数据的 Store 逻辑。
---
### **后续阶段 (在 `/Users/0fun/work/meijiaka-zj` 中执行)**
在完成阶段零的初始化后,后续所有阶段的开发工作都将在新的 `/Users/0fun/work/meijiaka-zj` 目录中进行。
#### **第一阶段:后端改造 — 构建无状态AI代理网关**
* **任务 1.1:** 在 `meijiaka-zj/python-api/app/services/` 中创建 `voice_clone_service.py`。
* **任务 1.2:** 在 `meijiaka-zj/python-api/app/services/` 中创建 `tts_service.py`。
* **任务 1.3:** 在 `meijiaka-zj/python-api/app/api/v1/` 中创建 `voice.py`。
* **任务 1.4:** 在 `meijiaka-zj/python-api/app/scheduler/handlers/` 中创建 `tts_handler.py`。
#### **第二阶段:系统能力扩展 — 强化Rust本地核心**
* **任务 2.1:** 在 `meijiaka-zj/tauri-app/src-tauri/src/` 中创建 `storage/voice.rs`。
* **任务 2.2:** 在 `meijiaka-zj/tauri-app/src-tauri/src/` 中创建 `commands/voice.rs`。
* **任务 2.3:** 修改 `meijiaka-zj/tauri-app/src-tauri/src/ffmpeg_cmd.rs`,实现音频替换。
#### **第三阶段:前端应用 — 构建本地化交互界面**
* **任务 3.1:** 改造 `meijiaka-zj/tauri-app/src/store/projectStore.ts` 并创建 `voiceStore.ts`。
* **任务 3.2:** 在 `meijiaka-zj/tauri-app/src/pages/VideoCreation/` 中创建 `VideoEditing.tsx`。
* **任务 3.3:** 在 `meijiaka-zj/tauri-app/src/pages/VideoCreation/` 中创建 `VoiceDubbing.tsx`。
#### **第四阶段:端到端集成与验证**
* **任务 4.1:** 在 `meijiaka-zj` 项目中,完整地运行和测试整个手动剪辑与AI配音的流程。
-293
View File
@@ -1,293 +0,0 @@
# 空镜素材匹配实现方案 v2(已对齐实际素材)
> 状态:待审阅
---
## 一、需求概述
Step 3(视频生成)统一处理所有分镜:
- **segment(人物出镜)**:从本地素材库选择形象视频,裁剪拼接
- **empty_shot(空镜)**:从七牛云素材库匹配,裁剪拼接
- **统一合成**:FFmpeg 裁剪所有素材后按顺序拼接,混音输出,最终视频时长与音频匹配
---
## 二、素材体系
### 2.1 人物出镜素材(本地)
**存储位置**`~/Documents/Meijiaka-zj/avatar_materials/`
**索引**`~/Documents/Meijiaka-zj/avatar_materials_index.json`
```json
{
"materials": [
{
"id": "avt_001",
"name": "男主播正面",
"filename": "host_male.mp4",
"duration": 15.2,
"uploadedAt": "2026-04-22T10:00:00Z"
}
]
}
```
**时长获取**:上传后用 ffprobe 提取,存入索引。
### 2.2 空镜素材(七牛云)
**实际 URL 格式**
```
https://media.liche.cn/meijiaka-zj/material/{slug}/{slug}_{序号}_{时长}s.mp4
```
**示例**
- `https://media.liche.cn/meijiaka-zj/material/ceiling/ceiling_001_5s.mp4`
- `https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_153_4s.mp4`
- `https://media.liche.cn/meijiaka-zj/material/paint/paint_001_5s.mp4`
- `https://media.liche.cn/meijiaka-zj/material/final/final_001_7s.mp4`
**关键发现**:文件名自带时长(`_{N}s.mp4`),无需 ffprobe,直接解析。
**映射表**`tauri-app/src/constants/materialUrls.ts`
```typescript
export interface MaterialInfo {
url: string;
duration: number; // 从文件名解析,如 "5s" → 5
}
export const MATERIAL_URLS: Record<string, MaterialInfo[]> = {
"ceiling": [
{ url: "https://media.liche.cn/meijiaka-zj/material/ceiling/ceiling_001_5s.mp4", duration: 5 },
],
"plumbing": [
{ url: "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_153_4s.mp4", duration: 4 },
],
"paint": [
{ url: "https://media.liche.cn/meijiaka-zj/material/paint/paint_001_5s.mp4", duration: 5 },
],
"final": [
{ url: "https://media.liche.cn/meijiaka-zj/material/final/final_001_7s.mp4", duration: 7 },
],
};
export const KEYWORD_MAP: Record<string, string> = {
"吊顶": "ceiling",
"天花": "ceiling",
"水电": "plumbing",
"水管": "plumbing",
"油漆": "paint",
"涂料": "paint",
"刷漆": "paint",
"完工": "final",
"竣工": "final",
"交付": "final",
};
```
> 新增 slug/关键词随时补充。
---
## 三、匹配规则(含时长检查)
### 3.1 人物出镜素材选择
用户手动选择一个本地素材作为"当前形象"。
系统检查:
```
let maxSegmentDuration = max(所有 segment 分镜的 duration);
if 形象素材.duration < maxSegmentDuration:
警告:形象素材时长不足,最长 segment 需要 X 秒,当前素材只有 Y 秒
```
### 3.2 空镜素材匹配
```typescript
function matchMaterial(scene: string, requiredDuration: number): MaterialInfo | null {
for (const [keyword, slug] of Object.entries(KEYWORD_MAP)) {
if (scene.includes(keyword)) {
const candidates = MATERIAL_URLS[slug]?.filter(m => m.duration >= requiredDuration);
if (candidates && candidates.length > 0) {
return candidates[Math.floor(Math.random() * candidates.length)];
}
}
}
return null;
}
```
**匹配失败场景**
- 无关键词匹配 → "未找到对应素材"
- 素材时长不足 → "素材时长不足(需要 X 秒,素材只有 Y 秒)"
---
## 四、裁剪与合成
### 4.1 裁剪策略
| 场景 | 素材时长 | 分镜时长 | 处理方式 |
|------|---------|---------|---------|
| 等于 | 5s | 5s | 从头截取 0-5s |
| 大于 | 8s | 5s | 随机从 [0, 3s] 开始截取 5s |
| 小于 | 3s | 5s | **匹配阶段已过滤,不会出现** |
**FFmpeg 裁剪**
```bash
ffmpeg -ss {start} -t {duration} -i {input} -c:v libx264 -preset fast -an -y {output}
```
### 4.2 拼接与混音
```bash
# concat 列表文件
file 'clip_001.mp4'
file 'clip_002.mp4'
file 'clip_003.mp4'
# 拼接 + 混音
ffmpeg -f concat -safe 0 -i clips.txt -i audio.mp3 -c:v libx264 -c:a aac -shortest output.mp4
```
`-shortest`:以较短的一方为准(视频/音频)。
### 4.3 时长兜底
拼接前检查:
```
videoTotal = sum(所有分镜 duration)
audioTotal = 音频时长
if |videoTotal - audioTotal| >= 1秒:
提示用户:分镜总时长与音频不匹配,建议调整
else:
正常合成,-shortest 处理微小偏差
```
---
## 五、Step 3(视频生成)交互
### 5.1 页面结构
```
┌─────────────────────────────────────────┐
│ Step 3: 视频生成 │
│ │
│ ┌─ 人物形象选择 ──────────────────────┐ │
│ │ [上传形象视频] │ │
│ │ │ │
│ │ ● 男主播正面 (15.2s) ← 已选 │ │
│ │ │ │
│ │ ✓ 最长 segment 需 8s,素材满足 │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌─ 分镜素材匹配 ──────────────────────┐ │
│ │ │ │
│ │ 分镜 1:人物出镜 (3s) │ │
│ │ 形象素材:男主播正面 │ │
│ │ (15.2s,从 2.1s 截取 3s) │ │
│ │ │ │
│ │ 分镜 2:瓦工贴墙砖 (5s) │ │
│ │ [自动匹配] → ✓ ceiling_001 │ │
│ │ (5s,从头截取) │ │
│ │ │ │
│ │ 分镜 3:防水施工 (4s) │ │
│ │ [自动匹配] → ✗ 素材不足 │ │
│ │ (需要 4splumbing 只有 4s │ │
│ │ 如果刚好等于,也是满足的) │ │
│ │ [手动选择本地文件] │ │
│ │ │ │
│ │ 分镜 4:人物出镜 (3s) │ │
│ │ 形象素材:男主播正面 │ │
│ │ (15.2s,从 8.5s 截取 3s) │ │
│ └──────────────────────────────────────┘ │
│ │
│ [生成视频] │
│ │
│ 进度:裁剪中... 拼接中... 混音中... │
└─────────────────────────────────────────┘
```
### 5.2 状态流转
1. 进入 Step 3 → 自动为所有 empty_shot 执行 `matchMaterial`
2. 绿色:素材满足(duration >= 分镜时长)
3. 红色:素材不足或无匹配 → 可手动选择本地文件替代
4. 点击【生成视频】→ FFmpeg 批量裁剪 + 拼接 + 混音
---
## 六、数据模型
```typescript
interface Segment {
id: string;
type: "segment" | "empty_shot";
scene: string;
voiceover: string;
duration: number;
// 视频生成后填充
videoUrl?: string; // 空镜:七牛云 URLsegmentnull(用本地形象)
videoPath?: string; // 本地路径(形象素材或手动替代文件)
// 素材匹配信息(仅展示用)
materialInfo?: {
source: "auto" | "manual"; // 自动匹配 / 手动选择
url?: string; // 原始 URL
duration: number; // 素材总时长
clipStart: number; // 截取起始点
clipDuration: number; // 截取时长(= 分镜 duration
};
}
```
---
## 七、实施清单
### Phase 1:人物出镜素材库
| # | 文件 | 内容 |
|---|------|------|
| 1 | `src-tauri/src/commands/avatar_material.rs` | IPC:上传、列表、删除 |
| 2 | `src-tauri/src/storage/avatar_material.rs` | 索引读写、ffprobe 提取时长 |
| 3 | `src/api/modules/avatarMaterial.ts` | 前端 API 封装 |
| 4 | `VideoGeneration.tsx` | 形象上传 + 选择组件 |
### Phase 2:空镜素材匹配
| # | 文件 | 内容 |
|---|------|------|
| 5 | `src/constants/materialUrls.ts` | 映射表(已按实际 URL 格式) |
| 6 | `VideoGeneration.tsx` | 自动匹配逻辑 + 状态展示 |
### Phase 3:视频合成
| # | 文件 | 内容 |
|---|------|------|
| 7 | `src-tauri/src/ffmpeg_cmd.rs` | 新增:批量裁剪函数 |
| 8 | `src-tauri/src/video_processing.rs` | 新增:合成主流程(裁剪→拼接→混音) |
| 9 | `src-tauri/src/commands/video_generate.rs` | IPC:生成视频命令 |
| 10 | `VideoGeneration.tsx` | [生成视频] 按钮 + 进度 |
---
## 八、确认事项
- [ ] 人物出镜素材存在本地,支持上传/列表/删除
- [ ] 空镜素材走七牛云,URL 格式 `meijiaka-zj/material/{slug}/{slug}_{序号}_{时长}s.mp4`
- [ ] 时长从文件名解析,无需 ffprobe
- [ ] 匹配时过滤时长不足的素材
- [ ] 素材时长 > 分镜时长时,随机位置截取
- [ ] 合成用 FFmpeg concat + 音频混流
- [ ] 时长差值 >= 1 秒时提示用户
**确认后我开始写。**
-306
View File
@@ -1,306 +0,0 @@
# 视频生成实现方案 v3(Vidu 对口型版)
> 状态:待审阅
---
## 一、流程概述
| Step | 页面 | 产出 |
|------|------|------|
| 1 | ScriptCreation | 分镜列表(含 scene + duration |
| 2 | VoiceDubbing | 配音音频(已存七牛云) |
| **3** | **VideoGeneration** | **最终视频(Vidu 对口型后)** |
| 4 | SubtitleBurning | 字幕压制 |
| 5 | CoverDesign | 封面 |
| 6 | VideoComposite | 最终成品(如需要额外合成) |
---
## 二、Step 3 完整流程
```
┌─────────────────────────────────────────────────────────────┐
│ Step 3: 视频生成 │
│ │
│ 1. 选择形象素材(本地 avatar_materials
│ └─ 上传 / 选择已有素材 │
│ │
│ 2. 匹配空镜素材(七牛云) │
│ └─ 根据 scene 关键词匹配 │
│ └─ 检查时长 >= 分镜 duration │
│ │
│ 3. FFmpeg 裁剪 │
│ └─ segment:从形象素材中截取 duration 秒 │
│ └─ empty_shot:从空镜素材中截取 duration 秒 │
│ └─ 输出:clip_001.mp4, clip_002.mp4, ... │
│ │
│ 4. FFmpeg 拼接(移除音频) │
│ └─ concat 所有 clip → raw_video.mp4(无声) │
│ │
│ 5. 上传临时视频 │
│ └─ raw_video.mp4 → 七牛云临时 URL │
│ │
│ 6. Vidu 对口型 │
│ └─ POST /ent/v2/lip-sync │
│ { video_url: 临时URL, audio_url: Step2音频URL } │
│ └─ 返回 task_id │
│ │
│ 7. 轮询等待 │
│ └─ GET /ent/v2/tasks/{task_id}/creations │
│ └─ state = "success" 时获取 video_url │
│ │
│ 8. 下载最终视频 │
│ └─ 下载到本地 products/{project_id}/ │
│ │
│ 9. 上传永久保存 │
│ └─ 上传七牛云 videos/{project_id}/final.mp4 │
│ │
│ 10. 更新项目状态 │
│ └─ segment.videoPath = 本地路径 │
│ └─ segment.videoUrl = 七牛云永久 URL │
└─────────────────────────────────────────────────────────────┘
```
---
## 三、素材体系
### 3.1 人物出镜素材(本地)
**存储位置**`~/Documents/Meijiaka-zj/avatar_materials/`
**索引**`~/Documents/Meijiaka-zj/avatar_materials_index.json`
```json
{
"materials": [
{
"id": "avt_001",
"name": "男主播正面",
"filename": "host_male.mp4",
"duration": 15.2,
"uploadedAt": "2026-04-22T10:00:00Z"
}
]
}
```
**时长获取**:上传后用 ffprobe 提取。
### 3.2 空镜素材(七牛云)
**URL 格式**`https://media.liche.cn/meijiaka-zj/material/{slug}/{slug}_{序号}_{时长}s.mp4`
**映射表**`tauri-app/src/constants/materialUrls.ts`
```typescript
export interface MaterialInfo {
url: string;
duration: number;
}
export const MATERIAL_URLS: Record<string, MaterialInfo[]> = {
"ceiling": [
{ url: "https://media.liche.cn/meijiaka-zj/material/ceiling/ceiling_001_5s.mp4", duration: 5 },
],
"plumbing": [
{ url: "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_153_4s.mp4", duration: 4 },
],
"paint": [
{ url: "https://media.liche.cn/meijiaka-zj/material/paint/paint_001_5s.mp4", duration: 5 },
],
"final": [
{ url: "https://media.liche.cn/meijiaka-zj/material/final/final_001_7s.mp4", duration: 7 },
],
};
export const KEYWORD_MAP: Record<string, string> = {
"吊顶": "ceiling",
"天花": "ceiling",
"水电": "plumbing",
"水管": "plumbing",
"油漆": "paint",
"涂料": "paint",
"刷漆": "paint",
"完工": "final",
"竣工": "final",
"交付": "final",
};
```
---
## 四、匹配规则(含时长检查)
### 4.1 人物出镜素材选择
用户手动选择一个本地素材作为"当前形象"。
系统检查:
```
let maxSegmentDuration = max(所有 segment 分镜的 duration);
if 形象素材.duration < maxSegmentDuration:
警告:形象素材时长不足
```
### 4.2 空镜素材匹配
```typescript
function matchMaterial(scene: string, requiredDuration: number): MaterialInfo | null {
for (const [keyword, slug] of Object.entries(KEYWORD_MAP)) {
if (scene.includes(keyword)) {
const candidates = MATERIAL_URLS[slug]?.filter(m => m.duration >= requiredDuration);
if (candidates && candidates.length > 0) {
return candidates[Math.floor(Math.random() * candidates.length)];
}
}
}
return null;
}
```
---
## 五、裁剪与拼接
### 5.1 裁剪
每个分镜从素材中截取 `duration` 秒:
| 场景 | 素材时长 | 截取方式 |
|------|---------|---------|
| 等于 | 5s | 从头截取 0-5s |
| 大于 | 8s | 随机从 [0, 3s] 开始截取 |
```bash
ffmpeg -ss {start} -t {duration} -i {input} -c:v libx264 -preset fast -an -y {output}
```
`-an` 确保输出无音频。
### 5.2 拼接(无声)
```bash
# clips.txt
file 'clip_001.mp4'
file 'clip_002.mp4'
...
# 拼接,无音频
ffmpeg -f concat -safe 0 -i clips.txt -c copy -an raw_video.mp4
```
### 5.3 上传临时视频
使用七牛云 SDK 上传 `raw_video.mp4`,获取临时访问 URL。
### 5.4 Vidu 对口型
```bash
POST https://api.vidu.cn/ent/v2/lip-sync
{
"video_url": "https://media.liche.cn/tmp/raw_video_xxx.mp4",
"audio_url": "https://media.liche.cn/audios/project_xxx.mp3"
}
```
**响应**`{ task_id: "xxx", state: "created" }`
**轮询**
```bash
GET https://api.vidu.cn/ent/v2/tasks/{task_id}/creations
```
**成功响应**
```json
{
"state": "success",
"creations": [{ "url": "https://.../final.mp4" }]
}
```
### 5.5 下载与保存
1. 下载 Vidu 返回的 `url` 到本地
2. 本地路径:`~/Documents/Meijiaka-zj/products/{project_id}/final.mp4`
3. 上传七牛云:`videos/{project_id}/final.mp4`
4. 更新项目数据
---
## 六、Step 3 页面交互
```
┌─────────────────────────────────────────┐
│ Step 3: 视频生成 │
│ │
│ ┌─ 人物形象 ──────────────────────────┐ │
│ │ [上传] 或 选择已有 │ │
│ │ ● 男主播正面 (15.2s) │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌─ 空镜匹配 ──────────────────────────┐ │
│ │ 分镜2:瓦工贴墙砖 (5s) │ │
│ │ ✓ ceiling_001 (5s) │ │
│ │ │ │
│ │ 分镜4:防水施工 (4s) │ │
│ │ ✗ 素材不足 (plumbing 只有 4s?) │ │
│ │ [手动选择本地文件] │ │
│ └──────────────────────────────────────┘ │
│ │
│ [生成视频] │
│ │
│ 进度: │
│ 裁剪中... │
│ 拼接中... │
│ 上传中... │
│ Vidu 处理中... (task_id: xxx) │
│ 下载中... │
│ 完成 ✓ │
└─────────────────────────────────────────┘
```
---
## 七、实施清单
### Phase 1:人物出镜素材库
| # | 文件 | 内容 |
|---|------|------|
| 1 | `src-tauri/src/commands/avatar_material.rs` | IPC:上传、列表、删除 |
| 2 | `src-tauri/src/storage/avatar_material.rs` | 索引读写、ffprobe 提取时长 |
| 3 | `src/api/modules/avatarMaterial.ts` | 前端 API 封装 |
| 4 | `VideoGeneration.tsx` | 形象上传 + 选择组件 |
### Phase 2:空镜素材匹配
| # | 文件 | 内容 |
|---|------|------|
| 5 | `src/constants/materialUrls.ts` | 映射表(按实际 URL 格式) |
| 6 | `VideoGeneration.tsx` | 自动匹配逻辑 |
### Phase 3Vidu 合成流水线
| # | 文件 | 内容 |
|---|------|------|
| 7 | `src-tauri/src/ffmpeg_cmd.rs` | 新增:批量裁剪 + 无声拼接 |
| 8 | `python-api/app/services/vidu_service.py` | Vidu API 封装(对口型 + 轮询) |
| 9 | `python-api/app/api/v1/vidu.py` | 后端路由:提交对口型、查询状态 |
| 10 | `src-tauri/src/commands/video_generate.rs` | IPC:生成视频主流程 |
| 11 | `VideoGeneration.tsx` | [生成视频] 按钮 + 进度显示 |
---
## 八、确认事项
- [ ] 人物出镜素材本地管理(上传/选择/时长检查)
- [ ] 空镜素材七牛云映射(关键词 → slug → URL)
- [ ] FFmpeg 裁剪 + 无声拼接
- [ ] 拼接后视频上传七牛云临时 URL
- [ ] Vidu 对口型 API(提交 + 轮询)
- [ ] 最终视频下载到本地 products/ + 上传七牛云永久保存
- [ ] Step 2 音频已在七牛云,直接取 URL
**确认后我开始写。**
-245
View File
@@ -1,245 +0,0 @@
# 空镜素材匹配实现方案
> 状态:待审阅
> 基于讨论结果整理
---
## 一、需求概述
在视频创作流程的 **Step 3(视频生成)** 中,为 `empty_shot`(空镜)分镜自动匹配七牛云上的空镜素材,合成时直接用 URL 拼接,无需下载到本地。
---
## 二、步骤映射(确认版)
| Step | 页面 | 内容 | 素材匹配相关 |
|------|------|------|-------------|
| 1 | ScriptCreation | 脚本生成 | AI 输出分镜(含 `scene` 字段) |
| 2 | VoiceDubbing | 音频合成 | 配音生成 |
| **3** | **VideoGeneration** | **视频生成** | **空镜素材匹配在这里** |
| 4 | SubtitleBurning | 字幕压制 | — |
| 5 | CoverDesign | 封面制作 | — |
| 6 | VideoComposite | 视频合成 | FFmpeg 直接用 URL 拼接 |
---
## 三、七牛云存储规范
- **Bucket**:复用现有 `media-liche`
- **Key 格式**`materials/{slug}/vid_{nnn}.mp4`
- **文件命名**:全英文,禁止中文
| slug | 场景 |
|------|------|
| `tiling` | 瓦工贴砖 |
| `waterproofing` | 防水施工 |
| `plumbing` | 水电施工 |
| `putty` | 腻子打磨 |
| `living_room` | 客厅全景 |
| `kitchen` | 厨房 |
| `bedroom` | 卧室 |
| `bathroom` | 卫生间 |
| `ceiling` | 吊顶 |
| `flooring` | 地板铺设 |
| `cabinet` | 橱柜安装 |
| `demolition` | 拆旧 |
| `masonry` | 砌墙 |
| `door_window` | 门窗安装 |
> 新增 slug 随时补充。
---
## 四、映射表设计
### 4.1 文件位置
`tauri-app/src/constants/materialUrls.ts`
### 4.2 数据结构
```typescript
// slug → URL 列表
export const MATERIAL_URLS: Record<string, string[]> = {
"tiling": [
"https://media.liche.cn/materials/tiling/vid_001.mp4",
"https://media.liche.cn/materials/tiling/vid_002.mp4",
],
"waterproofing": [
"https://media.liche.cn/materials/waterproofing/vid_001.mp4",
],
// ...
};
// 中文关键词 → slug 映射
export const KEYWORD_MAP: Record<string, string> = {
"贴砖": "tiling",
"瓦工": "tiling",
"瓷砖": "tiling",
"防水": "waterproofing",
"水电": "plumbing",
"腻子": "putty",
"客厅": "living_room",
"厨房": "kitchen",
"卧室": "bedroom",
"卫生间": "bathroom",
"吊顶": "ceiling",
"地板": "flooring",
"橱柜": "cabinet",
"拆旧": "demolition",
"砌墙": "masonry",
"门窗": "door_window",
};
```
### 4.3 匹配函数
```typescript
export function matchMaterial(scene: string): string | null {
for (const [keyword, slug] of Object.entries(KEYWORD_MAP)) {
if (scene.includes(keyword)) {
const urls = MATERIAL_URLS[slug];
if (urls && urls.length > 0) {
return urls[Math.floor(Math.random() * urls.length)];
}
}
}
return null;
}
```
- **匹配策略**:关键词包含(`scene.includes(keyword)`
- **多素材**:同一 slug 下有多个 URL 时随机选一个
- **无匹配**:返回 `null`
---
## 五、数据流
```
Step 1 脚本生成
↓ 产出分镜列表(含 scene 字段)
Step 2 音频合成
Step 3 视频生成
├─ segment 分镜(人物出镜)
│ └─ 现有逻辑:选择形象 → 生成数字人视频
└─ empty_shot 分镜(空镜)
└─ [匹配素材] 按钮
↓ 调用 matchMaterial(scene)
↓ 返回 URL → 写入 segment.videoUrl
Step 6 视频合成
↓ FFmpeg 读取 segment.videoUrl(支持 http/https
↓ 拼接输出成品
```
---
## 六、Step 3 前端交互
`VideoGeneration.tsx``empty_shot` 分镜卡片中增加:
```
┌─────────────────────────────────┐
│ 空镜:瓦工贴墙砖、贴地砖 │
│ │
│ [匹配空镜素材] │
│ ↓ │
│ ✓ 已匹配:vid_001.mp4 │
│ [▶ 预览] [重新匹配] │
│ │
│ (匹配失败时) │
│ ✗ 未匹配到素材 │
│ [手动输入 URL] │
└─────────────────────────────────┘
```
### 状态流转
| 状态 | 显示 | 操作 |
|------|------|------|
| 初始 | `[匹配空镜素材]` 按钮 | 点击执行匹配 |
| 匹配成功 | 显示文件名 + 预览按钮 + 重新匹配 | 点击预览播放 |
| 匹配失败 | `未匹配到素材` + 手动输入 | 可手动填 URL |
### 写入字段
匹配成功后写入 `segment.videoUrl`
```typescript
// 类型扩展
interface Segment {
id: string;
type: "segment" | "empty_shot";
scene: string;
voiceover: string;
duration: number;
videoUrl?: string; // 【新增】素材 URL 或数字人视频 URL
videoPath?: string; // 本地路径(数字人视频用)
}
```
---
## 七、视频合成适配
`VideoComposite.tsx` / Rust 层 FFmpeg 合成时:
```rust
// 输入来源判断
for segment in segments {
let input = if segment.type == "segment" {
// 人物出镜:用本地数字人视频路径
segment.video_path.clone()
} else if let Some(url) = &segment.video_url {
// 空镜:直接用七牛云 URLFFmpeg 支持 http 输入)
url.clone()
} else {
// 无素材:黑屏兜底(生成纯色视频)
generate_black_screen(segment.duration)
};
inputs.push(input);
}
```
---
## 八、更新流程
素材新增/变更时:
1. 你上传素材到七牛云 `materials/{slug}/`
2. 把 slug 和完整 URL 列表发给我
3. 我更新 `materialUrls.ts` 中的 `MATERIAL_URLS`
4. 如有新增关键词,同步更新 `KEYWORD_MAP`
5. 提交代码,发版后生效
> 不上线索索管理后台、不建数据库、不做动态配置。MVP 阶段手动维护映射表。
---
## 九、实施清单
| # | 文件 | 动作 | 内容 |
|---|------|------|------|
| 1 | `tauri-app/src/constants/materialUrls.ts` | 新建 | 映射表 + 匹配函数 |
| 2 | `tauri-app/src/api/types.ts` | 修改 | `Segment` 类型加 `videoUrl` 字段 |
| 3 | `tauri-app/src/pages/VideoCreation/VideoGeneration.tsx` | 修改 | empty_shot 卡片加匹配按钮和状态 |
| 4 | `tauri-app/src/store/projectStore.ts` | 修改 | `saveSegmentsToLocalFile` 支持 `videoUrl` 持久化 |
| 5 | `tauri-app/src-tauri/src/video_processing.rs` | 修改 | FFmpeg 合成支持 http URL 输入 |
---
## 十、确认事项
请审阅以上方案,确认或提出修改:
- [ ] 七牛云路径规范 `materials/{slug}/vid_{nnn}.mp4`
- [ ] 前端映射表文件位置 `src/constants/materialUrls.ts`
- [ ] 关键词包含匹配策略
- [ ] Step 3 交互设计(按钮位置、状态显示)
- [ ] 匹配失败时支持手动输入 URL
- [ ] FFmpeg 直接用 URL 合成(不下载本地)
- [ ] 素材更新方式:你发给我 → 我改代码
**全部确认后,我开始按清单写代码。**
-443
View File
@@ -1,443 +0,0 @@
# 空镜素材智能匹配方案
> 文档版本:v1.0
> 创建时间:2026-04-22
> 状态:待实现
## 一、需求背景
### 1.1 业务场景
在视频创作流程中,脚本生成阶段会产生分镜脚本,每个分镜包含:
- **画面描述**:AI 生成的口播场景描述(如 "厨房台面特写"
- **配音文字**:该分镜的口播文案
当前痛点:画面描述只是文字,没有对应的空镜素材,每个分镜只能配音,无法展示实际画面。
### 1.2 解决思路
建立**空镜素材库**,AI 生成脚本时同时输出**标签**,根据标签从素材库匹配对应视频素材,实现:
- 口播 + 空镜画面 的完整分镜效果
- 自动替换/拼接空镜素材,减少人工选材工作量
---
## 二、功能需求
### 2.1 标签体系
| 层级 | 示例 | 说明 |
|------|------|------|
| 父标签 | `室内` | 大场景分类 |
| 子标签 | `客厅``厨房``卫生间``卧室``书房` | 具体房间 |
| 扩展标签 | `台面特写``整体空间``角落细节` | 拍摄角度/风格 |
**标签格式**`父标签/子标签``父标签/子标签/扩展标签`
**示例**
```
室内/客厅
室内/厨房/开放式
室内/卫生间/干湿分离
室外/阳台
```
### 2.2 素材管理
| 需求 | 描述 |
|------|------|
| 素材上传 | 运营人员上传空镜视频到七牛云 |
| 标签标注 | 上传时指定素材的标签(支持多标签) |
| 素材索引 | 本地维护标签 → 素材文件 的映射关系 |
| 素材检索 | 根据标签快速查找匹配素材 |
### 2.3 脚本生成增强
| 需求 | 描述 |
|------|------|
| 标签输出 | AI 生成脚本时,在画面描述后输出对应标签 |
| 标签格式 | 统一使用 `【标签】室内/客厅` 格式 |
| 兼容旧格式 | 已有脚本无需修改,缺失标签的分镜跳过素材匹配 |
**示例输出**
```json
{
"segments": [
{
"id": "seg_001",
"scene": "开场:展示整洁的厨房整体空间",
"voiceover": "大家好,今天我们来聊聊厨房装修的注意事项...",
"duration": 15,
"tag": "室内/厨房/整体空间"
},
{
"id": "seg_002",
"scene": "特写:台面材质细节",
"voiceover": "首先,台面的材质选择非常重要...",
"duration": 12,
"tag": "室内/厨房/台面特写"
}
]
}
```
### 2.4 素材匹配流程
| 步骤 | 操作 | 说明 |
|------|------|------|
| 1 | 解析标签 | 从分镜数据中提取 `tag` 字段 |
| 2 | 查询索引 | 在本地索引中查找对应标签的素材列表 |
| 3 | 随机选择 | 从匹配素材中随机选择一个 |
| 4 | 获取 URL | 拼接七牛云访问 URL |
| 5 | 下载素材 | 下载到本地 `shots/` 目录 |
| 6 | 记录关联 | 将 `videoPath` 写入分镜数据 |
### 2.5 素材路径设计
**七牛云存储结构**(使用英文路径):
```
materials/
├── indoor/
│ ├── living_room/
│ │ ├── vid_001.mp4
│ │ ├── vid_002.mp4
│ │ └── ...
│ ├── kitchen/
│ │ ├── open/
│ │ │ ├── vid_010.mp4
│ │ │ └── ...
│ │ └── closed/
│ └── bathroom/
├── outdoor/
│ ├── balcony/
│ └── garden/
└── ...
```
**本地索引结构**`materials_index.json`):
```json
{
"version": "1.0",
"lastUpdated": "2026-04-22T10:00:00Z",
"tags": {
"室内/客厅": {
"displayName": "室内/客厅",
"qiniuDir": "materials/indoor/living_room",
"videos": [
{
"id": "vid_001",
"filename": "vid_001.mp4",
"qiniuKey": "materials/indoor/living_room/vid_001.mp4",
"duration": 8.5,
"size": 1024000,
"uploadedAt": "2026-04-20T08:30:00Z"
}
]
},
"室内/厨房/开放式": {
"displayName": "室内/厨房/开放式",
"qiniuDir": "materials/indoor/kitchen/open",
"videos": [...]
}
}
}
```
---
## 三、技术方案
### 3.1 架构概览
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ 脚本生成 │────▶│ 素材匹配服务 │────▶│ 七牛云存储 │
│ (AI 输出标签) │ │ (本地索引查询) │ │ (英文路径) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
┌──────────────────┐
│ 本地素材索引 │
│ (materials_index) │
└──────────────────┘
```
### 3.2 核心模块
| 模块 | 位置 | 职责 |
|------|------|------|
| `MaterialIndex` | `python-api/app/services/material_index.py` | 素材索引管理(加载/查询/更新) |
| `MaterialMatcher` | `python-api/app/services/material_matcher.py` | 素材匹配逻辑 |
| `QiniuMaterialService` | `python-api/app/services/qiniu_material.py` | 七牛云素材相关操作 |
| `material_router` | `python-api/app/api/v1/material.py` | 素材管理 API |
### 3.3 关键设计决策
| 决策 | 方案 | 理由 |
|------|------|------|
| 标签中文,路径英文 | 中文标签映射到英文目录 | URL 可读性、FFmpeg 兼容性 |
| 本地索引 + 七牛云 | 双层架构 | 减少 API 调用、快速检索 |
| 随机选择素材 | `random.choice()` | 避免每次生成视频都用相同素材 |
| 索引热更新 | 定时刷新 + 手动刷新 | 支持运营动态增删素材 |
---
## 四、数据模型
### 4.1 分镜数据扩展
```typescript
// 前端 types.ts
interface Segment {
id: string;
type: "segment" | "empty_shot";
scene: string; // 画面描述
voiceover: string; // 配音文案
duration: number; // 预估时长(秒)
tag?: string; // 【新增】素材标签,如 "室内/客厅"
videoPath?: string; // 【新增】匹配到的素材本地路径
videoUrl?: string; // 【新增】七牛云原 URL
status: "pending" | "matched" | "downloaded" | "failed";
}
```
### 4.2 素材索引 Schema
```json
// materials_index.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["version", "lastUpdated", "tags"],
"properties": {
"version": {
"type": "string",
"description": "索引文件版本"
},
"lastUpdated": {
"type": "string",
"format": "date-time",
"description": "最后更新时间"
},
"tags": {
"type": "object",
"description": "标签到素材的映射",
"additionalProperties": {
"type": "object",
"required": ["displayName", "qiniuDir", "videos"],
"properties": {
"displayName": {"type": "string"},
"qiniuDir": {"type": "string"},
"videos": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "filename", "qiniuKey"],
"properties": {
"id": {"type": "string"},
"filename": {"type": "string"},
"qiniuKey": {"type": "string"},
"duration": {"type": "number"},
"size": {"type": "integer"},
"uploadedAt": {"type": "string"}
}
}
}
}
}
}
}
}
```
---
## 五、API 设计
### 5.1 素材管理 API
| 方法 | 路径 | 描述 |
|------|------|------|
| GET | `/api/v1/materials` | 获取素材索引 |
| GET | `/api/v1/materials/tags` | 获取所有标签列表 |
| GET | `/api/v1/materials/match?tag=室内/客厅` | 根据标签匹配素材 |
| POST | `/api/v1/materials/sync` | 同步/刷新素材索引(管理员) |
| POST | `/api/v1/materials/upload` | 上传新素材并标注标签 |
### 5.2 匹配流程 API
| 方法 | 路径 | 描述 |
|------|------|------|
| POST | `/api/v1/materials/batch-match` | 批量匹配分镜素材 |
| GET | `/api/v1/materials/download/{id}` | 下载素材到本地 |
**批量匹配请求**
```json
POST /api/v1/materials/batch-match
{
"segments": [
{"id": "seg_001", "tag": "室内/客厅"},
{"id": "seg_002", "tag": "室内/厨房/台面特写"}
]
}
```
**批量匹配响应**
```json
{
"matched": [
{"segmentId": "seg_001", "videoId": "vid_001", "url": "https://..."},
{"segmentId": "seg_002", "videoId": "vid_015", "url": "https://..."}
],
"unmatched": ["seg_003"],
"summary": {
"total": 3,
"matched": 2,
"unmatched": 1
}
}
```
---
## 六、实施步骤
### Phase 1:基础设施(1天)
- [ ] 创建 `python-api/app/services/material_index.py`
- [ ] 创建 `python-api/app/services/material_matcher.py`
- [ ] 创建 `python-api/app/services/qiniu_material.py`
- [ ] 初始化示例 `materials_index.json`
### Phase 2API 层(1天)
- [ ] 创建 `python-api/app/api/v1/material.py` 路由
- [ ] 注册路由到 `router.py`
- [ ] 编写 API 文档和单元测试
### Phase 3:脚本生成集成(1天)
- [ ] 更新 `python-api/app/ai/prompts/system/script.txt` 添加标签输出要求
- [ ] 更新 `python-api/app/services/script_service.py` 支持素材匹配
- [ ] 测试端到端流程
### Phase 4:前端集成(1天)
- [ ] 更新 `tauri-app/src/api/types.ts` 添加 tag 字段
- [ ] 更新 `SegmentAdapter` 支持 tag 映射
- [ ] 前端显示素材匹配状态
### Phase 5:运营工具(待定)
- [ ] 开发素材上传 + 标签标注 Web 页面
- [ ] 开发素材索引管理后台
---
## 七、文件结构
```
meijiaka-zj/
├── python-api/
│ └── app/
│ ├── services/
│ │ ├── material_index.py # 【新建】素材索引管理
│ │ ├── material_matcher.py # 【新建】素材匹配器
│ │ └── qiniu_material.py # 【新建】七牛云素材操作
│ ├── api/v1/
│ │ ├── material.py # 【新建】素材管理 API
│ │ └── router.py # 【修改】注册 material 路由
│ └── schemas/
│ └── material.py # 【新建】Pydantic 模型
├── tauri-app/
│ └── src/
│ ├── api/
│ │ ├── modules/
│ │ │ └── material.ts # 【新建】素材 API 模块
│ │ └── types.ts # 【修改】添加 Segment.tag
│ └── pages/
│ └── VideoCreation/
│ └── ScriptCreation.tsx # 【修改】显示标签
├── docs/
│ └── material-matching-plan.md # 【新建】本文档
└── materials/
└── materials_index.json # 【新建】素材索引文件
```
---
## 八、配置项
### 8.1 环境变量
```bash
# .env 添加
QINIU_MATERIALS_ENABLED=true # 是否启用素材匹配
QINIU_MATERIALS_DOMAIN=https://materials.xxx.com # 素材专用域名
MATERIALS_INDEX_PATH=./materials_index.json # 本地索引文件路径
```
### 8.2 标签-目录映射表
| 中文标签 | 七牛云目录 | 英文路径 |
|----------|-----------|----------|
| 室内/客厅 | materials/indoor/living_room | materials/indoor/living_room |
| 室内/厨房 | materials/indoor/kitchen | materials/indoor/kitchen |
| 室内/厨房/开放式 | materials/indoor/kitchen/open | materials/indoor/kitchen/open |
| 室内/卫生间 | materials/indoor/bathroom | materials/indoor/bathroom |
| 室内/卧室 | materials/indoor/bedroom | materials/indoor/bedroom |
| 室外/阳台 | materials/outdoor/balcony | materials/outdoor/balcony |
---
## 九、注意事项
### 9.1 编码问题
- **七牛云 Key**:使用 UTF-8 编码,中文 key 技术上支持但建议避免
- **本地路径**:下载后确保路径处理兼容中文文件名
- **FFmpeg**:操作本地文件时优先使用绝对路径和引号包裹
### 9.2 容错处理
| 场景 | 处理方式 |
|------|----------|
| 标签无匹配素材 | 记录 `unmatched`,不影响后续分镜 |
| 七牛云下载失败 | 标记状态为 `failed`,允许重试 |
| 索引文件缺失 | 自动重建或返回空列表 |
### 9.3 性能考虑
- 素材索引加载到内存,避免每次请求都读文件
- 使用 LRU 缓存最近访问的素材 URL
- 下载操作异步执行,不阻塞主流程
---
## 十、附录
### 10.1 七牛云 Bucket 配置建议
```
Bucket 名称:media-materials
存储区域:华东 zone
访问域名:materials.xxx.comCDN 加速)
```
### 10.2 素材上传规范
| 字段 | 要求 |
|------|------|
| 格式 | MP4、H.264 编码 |
| 时长 | 5-30 秒 |
| 分辨率 | 1920x1080 或 1280x720 |
| 文件名 | `vid_{序号}.mp4` |
| 标签 | 上传后填写,支持多标签 |
### 10.3 参考文档
- [七牛云 Python SDK 指南](../docs/qiniu-kodo-python-sdk-guide.md)
- [火山引擎字幕 API](../docs/volcengine-video-caption-api.md)
-101
View File
@@ -1,101 +0,0 @@
# 美家卡-智剪 (Meijiaka Smart Cut) 项目开发实施方案
基于您的最新反馈与确认,本项目将以《golden-purring-crown.md》(方案A)为主要交互蓝本进行落地,明确采用 **手动匹配分镜视频**、**完全本地化数据存储** 和 **沿用现有架构新建仓库** 的策略。
## 目标与改动背景
**项目背景**:衍生自现有的「美家卡智影」,新项目「美家卡智剪」侧重针对用户已有的视频素材,利用 AI 进行配音、并完成拼接与后期制作。
核心工作流程(6 步):
1. **脚本生成** (基于主题生成具有预估时长的分镜与旁白)
2. **视频剪辑 (新)** (用户手动为**每一个单分镜**导入对应长度的视频素材短片)
3. **音色配音 (新)** (用户本地维护音色特征,使用大模型 TTS 为所有分镜批量生成口播音频)
4. **字幕压制** (自动打轴并挂载 ASS 字幕,复用智影功能)
5. **封面制作** (根据首分镜首帧和文字生成封面,复用智影功能)
6. **视频合成** (所有片段首尾拼接成短视频,并将原有环境音替换/混音为合成音频,复用智影功能)
---
## 核心设计决策 (User Confirmed)
1. **交互模式**:不采用长视频自动切割算法。**必须采用单一分镜独立手动导入视频的交互**。
2. **数据存储**:**纯本地文件系统**。所有业务数据(项目元数据、分镜配置、克隆好的本地音色记录等)全部保存在用户本地磁盘路径下,**不保存在云端数据库中**。
3. **架构剥离**:通过拷贝文件系统级别进行剥离 (`rsync ai-meijiaka -> meijiaka-zj`),保留现有混合路由和本地缓存设计。
---
## Proposed Changes
### 1. 架构剥离与仓库初始化
在同级目录下快速搭建衍生仓库,移除不相干的缓存依赖。
#### [NEW] `meijiaka-zj/` (新建本地项目根目录)
- 配置应用标识词修正( `产品名: 美家卡智剪`, `Bundle Identifier: cn.meijiaka.ai-video-editor` 等)。
- 修改并初始化 git 记录。
---
### 2. 后端 API (Python FastAPI)
不再新建数据库表结构,将所有新 API 的核心转为与本地文件、大语言模型 API 之间的交互代理,由 AsyncEngine 发起。
#### [NEW] `python-api/app/scheduler/handlers/tts_handler.py`
创建用于处理批量语音生成的并发 Dispatcher。
#### [NEW] `python-api/app/services/voice_clone_service.py` & `tts_service.py`
包装调用 `KlingAIProvider`
- 创建克隆音色的调用逻辑(由于无数据库,云端成功后的声纹特征及 `voice_id` 将通过 API 抛回前端并由 Tauri 存入本地 JSON 集合中)。
- 提供语音合成和查询能力的端点。
#### [NEW] `python-api/app/api/v1/voice.py`
仅暴露无状态/代理转发类型的路由给前端:克隆状态查询、提交合成等。无 DB 依赖。
---
### 3. Rust 系统能力扩展 (src-tauri)
由于采用本地存储,需要在 Rust 层扩展音频文件和声纹文件的安全存储指令。
#### [NEW] `tauri-app/src-tauri/src/storage/voice.rs`
新增声音本地缓存与描述管理,目录范例:`~/Documents/Meijiaka/voices/` (用于存储 voice meta.json 和相关的 reference audio)。
#### [NEW] `tauri-app/src-tauri/src/commands/voice.rs`
由 Tauri 提供存储IPC API给前端:读取本地音色列表、写入新克隆的音色等。
#### [MODIFY] `tauri-app/src-tauri/src/ffmpeg_cmd.rs`
**[重要机制更新]**: 实现目标音频覆盖处理,提供类似 `replace_audio_in_video` 的函数,依靠 `-c:v copy -c:a aac -shortest -map 0:v:0 -map 1:a:0` 剥离原声并在对应的短视频片段上压入新的 TTS 朗读声音。
---
### 4. 前端应用层 (tauri-app / React)
调整原应用状态数据,创建本地数据绑定。
#### [MODIFY] `tauri-app/src/store/projectStore.ts`
扩展原 `SmartCutShot` 阶段参数,支持记录新增加的独立视频源地址 (`mediaPath`) 和单独段落的合成语音地址 (`audioPath`)。
#### [NEW] `tauri-app/src/store/voiceStore.ts`
`src-tauri` 通过 IPC 交互:
- 从本地加载用户维护在 `voices/` 下的所有自定义音色。
- 处理前端的缓存与显示。
#### [NEW] `tauri-app/src/pages/VideoCreation/VideoEditing.tsx` (Step 2)
重定义分镜视频导入步骤:
- 为左侧每一个生成的分镜文案展示独立的 Upload/Select Box。
- 用户可以点选或拖动,调用系统弹窗将 `mp4` 一对一绑定给自己心仪的旁白节点。
#### [NEW] `tauri-app/src/pages/VideoCreation/VoiceDubbing.tsx` (Step 3)
批量克隆与TTS应用页面:
- 渲染本地和预定义的云端默认音库。
- 前端批量发起所有含旁白分镜的异步合成任务,获取 URL 后调用 Rust 保留至项目对应的 `audio/` 子目录中。
---
## Verification Plan
### Manual Verification (端到端走通测试)
- **环境**: 在新目录 `meijiaka-zj` 启动前后端服务。
- **Step 1**: 使用纯业务旁白的模版生成分镜文案。
- **Step 2**: 对列表中独立出现的 3 个分镜卡片,依次上传/拖入 3 个独立的 `.mp4` 文件以测试前端映射逻辑。
- **Step 3(关键测试)**: 选择一个克隆音色发起全局合成。观察 `tts_slots` 运转状况。完毕后查验对应项目的物理存储路径内正确生成了 `.mp3` 音轨。
- **Step 6**: 打包合成,测试 `ffmpeg_cmd.rs` 中音频替代逻辑是否执行无误,输出画面不掉帧、声音是合成口音的短片。
-833
View File
@@ -1,833 +0,0 @@
# 美家卡智剪 — 产品技术方案
> 基于「美家卡智影」架构的 AI 辅助短视频剪辑产品方案
> 版本: v2.0 | 日期: 2026-04-20
---
## 一、产品定位
| 维度 | 美家卡智影(现有) | 美家卡智剪(新项目) |
|------|-------------------|---------------------|
| **核心能力** | AI 数字人视频生成 | AI 音色克隆 + 语音合成 + 素材智能剪辑 |
| **视频来源** | KlingAI 生成数字人视频 | 用户导入长视频素材 |
| **声音来源** | KlingAI 预设/自定义音色 + 数字人 | 用户克隆音色 / 预设音色 + TTS |
| **目标场景** | 口播视频、营销视频从无到有 | 已有长素材快速剪辑成片、声音克隆配音 |
| **核心差异** | 「生成式」创作 | 「剪辑式」创作 + AI 声音 |
### 一句话定义
> **美家卡智剪** = 导入长视频 + AI 文案分镜 + 自动切割 + 音色克隆 + 语音合成 + 字幕压制 + 封面合成 + 视频导出
---
## 二、核心流程设计(6 步)
```
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ Step 1 → Step 2 → Step 3 → Step 4 → S5 → S6 │
│ 脚本生成 → 视频剪辑 → 音色配音 → 字幕压制 → 封面 → 合成 │
│ │
│ ├─ AI文案 ├─ 导入长视频 ├─ 音色克隆 ├─ 自动打轴 │
│ ├─ 粘贴文案 ├─ 自动切割 ├─ 预设音色 ├─ ASS字幕 │
│ ├─ 智能分镜 │ (按分镜时长) ├─ 分镜TTS ├─ FFmpeg压制 │
│ │ │ ├─ 试听/调整 │
│ │ │ │ │
│ [改造] [全新] [全新] [复用] [复用] │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### 各步骤详细说明
---
#### Step 1 — 脚本生成(Script Generation
**文案输入(3 种方式):**
1. **AI 生成**:输入主题/关键词,LLM 生成短视频文案
2. **直接粘贴**:用户粘贴已准备好的文案,系统自动分镜
3. **导入文件**:支持 `.txt` / `.docx` / `.srt` 导入
**智能分镜:**
- 按句子/段落自动拆分分镜
- 每个分镜含:`voiceover`(旁白文案)、`duration`(预估时长)
- 支持拖拽调整分镜顺序、合并、拆分
- 文案字数根据目标时长自动约束(15s≈40字 / 30s≈80字 / 60s≈160字)
**输出:**
- `segments[]`:分镜列表,每个分镜含文案和预估时长
- 此步骤与智影 Step 1 基本一致,Prompt 调整为生成纯旁白文案(不含场景描述)
---
#### Step 2 — 视频剪辑(Video Editing
**核心逻辑:导入一个长视频,按分镜时长自动切割。**
**流程:**
1. 用户导入一个长视频文件(`.mp4/.mov`
2. 系统提取视频总时长
3. 按分镜数量和预估时长自动计算切割点
4. 调用 FFmpeg 将长视频切割为 N 个片段
5. 每个片段自动绑定到对应分镜
**自动切割算法:**
```
总视频时长 = T
分镜数 = N
分镜预估时长 = [d1, d2, ..., dN]
预估总时长 = D = d1 + d2 + ... + dN
如果 D <= T:
按比例分配: 每个分镜实际时长 = di * (T / D)
切割点: cumsum([d1*T/D, d2*T/D, ...])
如果 D > T:
提示用户: 文案预估总时长超过视频时长,建议缩短文案或导入更长视频
```
**界面示意:**
```
┌────────────────────────────────────────────┐
│ 分镜列表 │ 素材导入 │
│ ├─ 分镜1 (5s) │ ├─ 📁 点击导入 │
│ ├─ 分镜2 (8s) │ │ 或拖拽视频 │
│ ├─ 分镜3 (7s) │ │ │
│ └─ 分镜4 (5s) │ │ 🎬 素材.mp4 │
│ │ │ 时长: 25s │
│ 预估总时长: 25s │ │ 分辨率: 1080p │
│ │ └────────────────│
│ [自动切割] │ │
└────────────────────────────────────────────┘
切割结果预览:
┌────────────────────────────────────────────┐
│ 分镜1 ←→ 🎬 [00:00 - 00:05] (5s) │
│ 分镜2 ←→ 🎬 [00:05 - 00:13] (8s) │
│ 分镜3 ←→ 🎬 [00:13 - 00:20] (7s) │
│ 分镜4 ←→ 🎬 [00:20 - 00:25] (5s) │
└────────────────────────────────────────────┘
```
**技术实现:**
- 前端:文件选择 → 调用 Rust IPC `import_media` → 保存到项目 `media/` 目录
- Rust`split_video` 命令使用 FFmpeg `-ss` + `-t` 截取片段
- 每个片段保存为 `shot_{index}.mp4`,路径写入 `segment.mediaPath`
---
#### Step 3 — 音色配音(Voice & Dubbing
**音色管理:**
- **预设音色**:接入 KlingAI 官方预设音色(温柔女声、播报男声等)
- **我的音色**:用户克隆的音色列表
- 克隆方式:录音(10-20 秒)或上传音频文件
- 克隆状态:处理中 / 完成 / 失败
- 支持预览、重命名、删除
**语音合成(TTS):**
- 为每个分镜独立选择音色
- 支持统一设置(一键应用到全部分镜)
- 可调节语速(0.8x - 2.0x
- 实时试听、重新生成
**批量合成:**
- 一键合成所有分镜音频
- 后台 Async Engine 并行处理(受槽位限制)
- 实时进度显示
---
#### Step 4 — 字幕压制(Subtitle Burning
**基本复用智影现有逻辑,数据源变化:**
- 原:基于数字人视频的音频流进行自动打轴
- 新:基于 TTS 合成的音频文件进行自动打轴
**流程:**
1. 提交 `subtitle` 任务(`mode: auto_align`
2. 参数:`audioUrl`TTS 音频)+ `audioText`(分镜文案)
3. 返回 `alignmentResult`utterances 时间轴)
4. 用户选择字幕样式(颜色/字号/描边/位置)
5. 调用 Rust IPC `burn_subtitle` 压制 ASS 字幕到视频
**输出:**
- 每个分镜生成 `burnedVideoPath`(素材视频 + TTS 音频 + ASS 字幕)
---
#### Step 5 — 封面制作(Cover Design
**完全复用智影现有逻辑:**
1. 提取第一个分镜视频的首帧作为背景
2. 用户输入封面标题
3. 选择字体样式(抖音美好体等)
4. 调用 Rust IPC `generate_cover_image` 合成封面
---
#### Step 6 — 视频合成(Video Composite
**完全复用智影现有逻辑:**
1. 收集所有分镜的 `burnedVideoPath`
2. 如有封面图,先转为 0.5s 封面视频
3. 调用 Rust IPC `video_composite_synthesis` 拼接所有片段
4. 输出最终成品到 `~/Documents/Meijiaka/products/`
---
## 三、功能模块对比矩阵
| 模块 | 智影(现有) | 智剪(新) | 复用度 |
|------|-------------|-----------|--------|
| **脚本生成** | AI 生成脚本 | AI 生成文案 + 粘贴/导入 | 🔶 改造 |
| **视频生成** | KlingAI 数字人 | 素材导入 + **自动切割** | 🔴 新增 |
| **音色管理** | KlingAI Element 绑定音色 | 独立音色克隆 + 预设库 | 🔶 改造 |
| **语音合成** | 数字人自带口播 | TTS 独立合成音频 | 🔴 新增 |
| **字幕压制** | 自动打轴+FFmpeg | 完全复用 | 🟢 复用 |
| **封面制作** | 首帧+标题+FFmpeg | 完全复用 | 🟢 复用 |
| **视频合成** | FFmpeg concat | 完全复用 | 🟢 复用 |
| **本地存储** | meta.json + segments.json | 扩展字段 | 🔶 改造 |
| **任务调度** | 6 个 Handler | 新增 TTS Handler | 🔶 改造 |
| **用户认证** | JWT + 手机号 | 完全复用 | 🟢 复用 |
| **形象克隆** | Avatar 完整流程 | 简化为音色克隆 | 🔶 改造 |
> 🟢 完全复用 | 🔶 需要改造 | 🔴 全新开发
---
## 四、前端架构方案
### 4.1 页面结构
```
tauri-app/src/pages/
├── VideoCreation/
│ ├── index.tsx # 6步流程容器(复用,调整步骤名)
│ ├── ScriptCreation.tsx # Step 1: 脚本生成(复用改造)
│ ├── VideoEditing.tsx # Step 2: 视频剪辑(全新)
│ ├── VoiceDubbing.tsx # Step 3: 音色配音(全新)
│ ├── SubtitleBurning.tsx # Step 4: 字幕压制(复用)
│ ├── CoverDesign.tsx # Step 5: 封面制作(复用)
│ └── VideoComposite.tsx # Step 6: 视频合成(复用)
```
### 4.2 Store 设计
#### projectStore(改造)
```typescript
interface SmartCutState {
// === Step 1: 脚本与分镜 ===
segments: SmartCutShot[];
topic?: string;
scriptType?: string;
// === Step 3: 音色配音 ===
defaultVoiceId?: string; // 默认音色
// === Step 5+6: 封面与合成 ===
coverPath?: string;
coverConfig?: CoverConfig;
finalVideoPath?: string;
exportedAt?: string;
// === 流程状态 ===
currentStep: number; // 1-6
}
interface SmartCutShot {
id: string;
type: 'segment' | 'empty_shot';
voiceover: string; // 旁白文案
duration: number; // 预估/实际时长
// === Step 2: 视频剪辑后绑定 ===
mediaPath?: string; // 切割后的视频片段路径
mediaStartTime?: number; // 在原视频中的起始时间(秒)
mediaEndTime?: number; // 在原视频中的结束时间(秒)
// === Step 3: 配音配置 ===
ttsConfig?: TTSConfig;
audioPath?: string; // TTS 合成音频本地路径
audioUrl?: string; // TTS 音频远程 URL
// === Step 4: 字幕与后期 ===
alignmentResult?: AlignmentResult;
burnedVideoPath?: string;
burnedAt?: string;
}
interface TTSConfig {
voiceId: string;
voiceName: string;
speed: number; // 0.8 - 2.0
}
```
#### voiceStore(新增)
```typescript
interface VoiceState {
// 预设音色
presetVoices: PresetVoice[];
presetVoicesLoading: boolean;
// 用户克隆音色
clonedVoices: ClonedVoice[];
clonedVoicesLoading: boolean;
// 当前选中的默认音色
selectedVoiceId?: string;
}
interface PresetVoice {
voiceId: string;
voiceName: string;
previewUrl?: string;
provider: string;
}
interface ClonedVoice {
id: string; // vc_xxx
name: string;
providerVoiceId: string; // KlingAI 返回的 voice_id
provider: string;
status: 'processing' | 'succeed' | 'failed';
previewUrl?: string;
createdAt: string;
}
```
### 4.3 新增 Hooks
| Hook | 职责 |
|------|------|
| `useVoiceClone.ts` | 音色克隆:提交克隆、轮询状态、管理列表 |
| `useTTSGeneration.ts` | TTS 批量合成:提交任务、轮询、更新 segment |
| `useMediaImport.ts` | 素材导入:文件选择、调用 Rust IPC |
| `useAutoSplit.ts` | 自动切割:计算切割点、调用 split_video、绑定分镜 |
### 4.4 API 模块
```
tauri-app/src/api/modules/
├── voice.ts # 音色克隆 / 预设音色 / 查询 / 删除
├── tts.ts # TTS 提交 / 查询 / 批量
├── script.ts # 复用,文案生成
├── caption.ts # 复用,字幕相关
└── videoComposite.ts # 复用,视频合成
```
---
## 五、后端架构方案
### 5.1 新增 API 路由
```python
# python-api/app/api/v1/voice.py
@router.post("/voice/clone") # 提交音色克隆任务
@router.get("/voice/clones") # 查询用户克隆音色列表
@router.get("/voice/clones/{id}") # 查询单个克隆任务
@router.delete("/voice/clones/{id}") # 删除克隆音色
@router.get("/voice/presets") # 查询预设音色列表
# python-api/app/api/v1/tts.py
@router.post("/tts") # 提交 TTS 任务
@router.get("/tts/{job_id}") # 查询 TTS 任务状态
@router.post("/tts/batch") # 批量提交 TTS 任务
```
### 5.2 新增 Async Engine Handler
新增 **`tts`** 任务类型:
```python
# app/scheduler/handlers/tts_handler.py
class TTSHandler(AsyncHandler):
"""TTS 语音合成 Handler
为每个分镜的文案生成语音音频。
"""
job_type = "tts"
slot_key = "kling:tts_slots"
max_slots = 10
async def handle(self, job: JobRecord) -> list[StateChange]:
"""处理流程:
1. 从 job.payload 提取 text, voice_id, voice_speed
2. 调用 KlingAI TTS API 生成音频
3. 轮询任务完成
4. 下载音频文件到本地项目目录
5. (可选)上传七牛云持久化
6. 返回结果含 audio_path, audio_url, duration
"""
```
**Redis 配置:**
```
槽位 Key: kling:tts_slots
槽位数: 10
```
### 5.3 新增 Service 层
```python
# app/services/tts_service.py
class TTSService:
"""TTS 语音合成服务"""
async def generate_audio(
self,
text: str,
voice_id: str,
voice_speed: float = 1.0,
output_dir: str | None = None,
) -> TTSResult:
"""生成单条 TTS 音频"""
async def batch_generate(
self,
items: list[TTSRequest],
user_id: str,
) -> list[str]:
"""批量提交 TTS 任务到 Async Engine"""
# app/services/voice_clone_service.py
class VoiceCloneService:
"""音色克隆服务"""
async def create_clone(
self,
voice_name: str,
audio_url: str, # 七牛云音频URL
user_id: str,
) -> VoiceCloneJob:
"""提交音色克隆任务到 KlingAI"""
async def sync_clone_status(
self,
job_id: str,
) -> VoiceCloneStatus:
"""同步查询克隆任务状态(轻量操作,不走Async Engine"""
async def list_clones(self, user_id: str) -> list[ClonedVoice]:
"""查询用户所有克隆音色"""
```
### 5.4 新增数据库模型
```python
# app/models/voice_clone.py
class VoiceClone(Base):
"""用户克隆音色元数据(云端备份)"""
__tablename__ = "voice_clones"
id: Mapped[str] = mapped_column(String(32), primary_key=True) # vc_xxx
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"))
name: Mapped[str] = mapped_column(String(100))
provider: Mapped[str] = mapped_column(String(50), default="klingai")
provider_voice_id: Mapped[str] = mapped_column(String(100))
status: Mapped[str] = mapped_column(String(20)) # processing/succeed/failed
preview_url: Mapped[str | None] = mapped_column(String(500))
fail_reason: Mapped[str | None] = mapped_column(Text)
deleted_at: Mapped[datetime | None]
created_at: Mapped[datetime]
updated_at: Mapped[datetime]
```
> 注:智剪中不需要 Element(形象主体),只需要 Voice(音色),因此独立建表更简洁。
### 5.5 复用已有能力
| 已有能力 | 复用方式 |
|---------|---------|
| `KlingAIProvider.generate_tts()` | 直接调用,封装到 Service 层 |
| `KlingAIProvider.create_custom_voice()` | 直接调用,封装到 VoiceCloneService |
| `KlingAIProvider.list_preset_voices()` | 直接调用 |
| `VolcengineCaptionService` | 完全复用,传入 TTS 音频 URL |
| `SlotManager` + `JobRegistry` | 完全复用 |
| `TokenManager` + `JWTTokenStrategy` | 完全复用 |
| `qiniu_service.upload()` | 复用,支持 audio 类型 |
| 七牛云上传凭证 | 复用 |
---
## 六、Rust 层改造方案
### 6.1 新增 IPC 命令
```rust
// commands/media.rs
#[tauri::command]
async fn import_media(
app: AppHandle,
project_id: String,
source_path: String,
) -> Result<MediaInfo, String>
// commands/video_edit.rs
#[tauri::command]
async fn split_video(
app: AppHandle,
input_path: String,
segments: Vec<SplitSegment>, // [{start, end, output_name}]
) -> Result<Vec<String>, String> // 返回切割后的文件路径列表
```
### 6.2 新增 FFmpeg 命令封装
`ffmpeg_cmd.rs` 中新增:
```rust
/// 按时间范围批量截取视频片段
///
/// 输入一个长视频,按多个时间范围切割为独立文件
pub async fn split_video_segments(
app: &AppHandle,
input: &str,
segments: &[(f64, f64, &str)], // (start, end, output_path)
) -> Result<Vec<String>, FFmpegError>
/// 提取视频元信息(时长、分辨率、码率等)
pub async fn probe_media_info(
input: &str,
) -> Result<MediaInfo, FFmpegError>
```
### 6.3 本地存储路径扩展
```rust
// storage/paths.rs
/// 项目素材目录:~/Documents/Meijiaka/projects/{id}/media/
pub fn get_project_media_dir(project_id: &str) -> PathBuf
/// 项目音频目录:~/Documents/Meijiaka/projects/{id}/audio/
pub fn get_project_audio_dir(project_id: &str) -> PathBuf
/// 项目分镜视频目录:~/Documents/Meijiaka/projects/{id}/shots/
pub fn get_project_shots_dir(project_id: &str) -> PathBuf
```
存储结构:
```
~/Documents/Meijiaka/
├── projects/{project_id}/
│ ├── meta.json
│ ├── segments.json
│ ├── media/ # 导入的原始素材
│ │ └── source.mp4 # 原始长视频
│ ├── shots/ # 自动切割后的分镜视频
│ │ ├── shot_001.mp4
│ │ └── shot_002.mp4
│ ├── audio/ # TTS 生成的音频
│ │ ├── tts_001.mp3
│ │ └── tts_002.mp3
│ └── assets/ # 封面等成品资源
│ └── cover_xxx.png
```
---
## 七、AI 能力集成
### 7.1 音色克隆
**Provider: KlingAI(已具备能力)**
```
API: POST /v1/general/custom-voices
参数:
- voice_name: 音色名称
- voice_url: 音频文件URL5-30秒,干净人声)
限制:
- 音频时长: 5-30 秒
- 格式: MP3 / WAV
- 要求: 单一人声、无杂音、无背景音乐
```
**前端录音方案:**
- 使用 Web Audio API 录制麦克风音频
- 实时波形可视化
- 录制时长控制(10-20 秒最佳)
- 录制完成后上传至七牛云 → 后端提交克隆任务
**状态流转:**
```
用户录音/上传 → 前端上传七牛云 → 后端调用 KlingAI 创建音色
[processing] ← 前端轮询
[succeed] → 保存到 DB → 加入"我的音色"
[failed] → 提示用户重新录制
```
### 7.2 语音合成(TTS
**Provider: KlingAI(已具备能力,需上层封装)**
```
API: POST /v1/audio/tts
参数:
- text: 要合成的文本(旁白文案)
- voice_id: 音色ID(预设或自定义)
- voice_language: zh / en
- voice_speed: 0.8 - 2.0(默认 1.0
返回:
- task_id: 任务ID
- 轮询 GET /v1/audio/tts/{task_id} 获取音频URL
```
**批量处理策略:**
- 每个分镜一个 TTS 任务
- Async Engine 并行处理(最多 10 个并发)
- 前端显示总体进度(已完成 N / 总分镜数 M)
### 7.3 文案生成
**复用现有 ScriptService**,但调整 Prompt
- 原:生成「场景描述 + 旁白 + 时长」的营销脚本
- 新:生成「旁白文案 + 预估时长」的短视频文案
- 支持根据目标时长(15s / 30s / 60s)控制字数
---
## 八、独立新仓库初始化方案
### 8.1 仓库创建
```bash
# 在本地创建新仓库目录
mkdir meijiaka-zj
cd meijiaka-zj
git init
# 复制智影代码(排除依赖和构建产物)
rsync -av \
--exclude='.git' \
--exclude='node_modules' \
--exclude='.venv' \
--exclude='__pycache__' \
--exclude='.mypy_cache' \
--exclude='.ruff_cache' \
--exclude='.pytest_cache' \
--exclude='dist' \
--exclude='target' \
--exclude='*.lock' \
--exclude='.DS_Store' \
../ai-meijiaka/ .
# 初始化提交
git add -A
git commit -m "init: fork from meijiaka-zy"
```
### 8.2 品牌配置修改清单
| 文件 | 修改项 |
|------|--------|
| `tauri-app/src-tauri/tauri.conf.json` | `productName`: 美家卡智影 → 美家卡智剪;`identifier`: `cn.meijiaka.ai-video``cn.meijiaka.ai-video-editor``title`: 美家卡 智影 → 美家卡 智剪 |
| `tauri-app/package.json` | `name`: 可保持不变(内部包名) |
| `python-api/app/main.py` | FastAPI 文档标题、描述更新 |
| `AGENTS.md` | 全文替换「智影」→「智剪」,更新产品描述 |
| `README.md` | 更新为智剪的产品说明 |
### 8.3 项目结构
```
meijiaka-zj/ # 新仓库根目录
├── python-api/ # FastAPI 后端(从智影复制后改造)
│ ├── app/
│ │ ├── api/v1/ # 新增 voice.py, tts.py 路由
│ │ ├── ai/providers/ # 复用 KlingAIProvider
│ │ ├── scheduler/handlers/ # 新增 tts_handler.py
│ │ ├── services/ # 新增 tts_service.py, voice_clone_service.py
│ │ ├── models/ # 新增 voice_clone.py
│ │ └── schemas/ # 新增 voice.py, tts.py
│ ├── config/
│ ├── alembic/
│ ├── pyproject.toml
│ └── ...
├── tauri-app/ # Tauri 前端(从智影复制后改造)
│ ├── src/
│ │ ├── pages/VideoCreation/
│ │ │ ├── ScriptCreation.tsx # Step 1(改造)
│ │ │ ├── VideoEditing.tsx # Step 2(新增)
│ │ │ ├── VoiceDubbing.tsx # Step 3(新增)
│ │ │ ├── SubtitleBurning.tsx # Step 4(复用)
│ │ │ ├── CoverDesign.tsx # Step 5(复用)
│ │ │ └── VideoComposite.tsx # Step 6(复用)
│ │ ├── store/
│ │ │ ├── projectStore.ts # 改造
│ │ │ └── voiceStore.ts # 新增
│ │ ├── api/modules/
│ │ │ ├── voice.ts # 新增
│ │ │ └── tts.ts # 新增
│ │ └── hooks/
│ │ ├── useVoiceClone.ts # 新增
│ │ ├── useTTSGeneration.ts # 新增
│ │ └── useAutoSplit.ts # 新增
│ ├── src-tauri/src/
│ │ ├── commands/media.rs # 新增
│ │ ├── ffmpeg_cmd.rs # 新增函数
│ │ └── storage/paths.rs # 新增路径
│ └── ...
├── docs/ # 文档
│ └── meijiaka-zhijian-proposal.md
└── scripts/ # 工具脚本
```
---
## 九、实施路线图
### Phase 1: 基础架构(2 周)
**目标**:搭建新项目骨架,打通基础能力
| 任务 | 说明 |
|------|------|
| ① 仓库初始化 | 复制智影代码,修改品牌配置,建立独立仓库 |
| ② 数据模型改造 | 新增 `voice_clones` 表,改造 `segments` Schema |
| ③ TTS API 封装 | 新增 `tts_service.py``voice.py` / `tts.py` 路由 |
| ④ 音色克隆 API | 新增 `voice_clone_service.py` |
| ⑤ 前端 Store 改造 | 改造 `projectStore`,新增 `voiceStore` |
| ⑥ 素材导入 IPC | 新增 `import_media``split_video` Rust 命令 |
### Phase 2: 核心流程(2 周)
**目标**:完成 6 步核心流程 MVP
| 任务 | 说明 |
|------|------|
| ⑦ Step 1 脚本生成 | 复用现有逻辑,Prompt 调整为纯旁白文案 |
| ⑧ Step 2 视频剪辑 | 素材导入 UI + 自动切割逻辑 + 分镜绑定 |
| ⑨ Step 3 音色配音 | 音色克隆 UI + TTS 合成 UI + 批量任务 |
| ⑩ TTS Async Handler | 实现 `TTSHandler`,接入 Async Engine |
| ⑪ 字幕压制适配 | 基于 TTS 音频的自动打轴 + 字幕压制 |
| ⑫ 封面+合成 | 复用现有逻辑,验证端到端流程 |
### Phase 3: 打磨优化(1 周)
**目标**:提升用户体验,修复问题
| 任务 | 说明 |
|------|------|
| ⑬ 切割算法优化 | 智能检测场景切换点,避免在人物说话中间切割 |
| ⑭ 批量操作优化 | 统一音色、批量重新合成 |
| ⑮ 错误处理 | 视频格式不支持、TTS 失败、文案超长等异常 |
| ⑯ 性能优化 | 大视频导入、多任务并发 |
| ⑰ 测试验收 | 全流程测试,修复 bug |
### 总工期预估:**5 周**
```
Week 1-2: Phase 1 — 基础架构
Week 3-4: Phase 2 — 核心流程 MVP
Week 5: Phase 3 — 打磨优化 + 测试
```
---
## 十、技术风险与应对
| 风险 | 影响 | 应对方案 |
|------|------|---------|
| KlingAI TTS 并发限制 | 批量合成慢 | Async Engine 槽位控制 + 前端进度管理 |
| KlingAI 音色克隆失败率高 | 用户体验差 | 前端引导用户录制规范音频(安静环境、清晰人声) |
| 文案总时长 > 视频时长 | 无法完整配音 | Step 2 导入时校验,超长则提示用户调整文案或换视频 |
| 自动切割点落在不自然位置 | 画面割裂 | V2 引入场景切换检测,在关键帧处切割 |
| 大视频文件导入卡顿 | 前端无响应 | Tauri 后端异步处理导入,前端仅显示进度 |
| 视频格式兼容性 | 某些格式无法处理 | FFmpeg 统一标准化转码,支持主流格式 |
| TTS 文本过长 | KlingAI 限制 | 分镜文案字数控制(建议单分镜 < 200 字) |
---
## 十一、长期演进方向
| 版本 | 功能 |
|------|------|
| **V1.0**(MVP)| 长视频导入 + 自动切割 + 音色克隆 + TTS + 字幕 + 封面 + 合成 |
| **V1.5** | 智能切割(基于场景切换检测) |
| **V2.0** | 多轨道编辑(背景音乐、音效、转场) |
| **V2.5** | AI 视频摘要(长视频自动提取精彩片段) |
| **V3.0** | 多音色对话(支持多人配音、角色音色) |
---
## 附录
### A. 关键术语对照
| 智影术语 | 智剪对应 | 说明 |
|---------|---------|------|
| `elementId` | `voiceId` | 从数字人形象ID变为音色ID |
| `videoUrl` | `mediaPath` | 从AI生成视频变为切割后的素材片段 |
| `Avatar` | `VoiceClone` | 从形象克隆简化为音色克隆 |
| `humanId` | — | 移除,不再需要 |
| `scene` | — | 可选保留,用于V2智能匹配 |
### B. 需要改造的文件清单
**后端(python-api):**
```
新增:
app/api/v1/voice.py
app/api/v1/tts.py
app/services/tts_service.py
app/services/voice_clone_service.py
app/scheduler/handlers/tts_handler.py
app/models/voice_clone.py
app/schemas/voice.py
app/schemas/tts.py
改造:
app/scheduler/main.py # 注册 TTSHandler
app/api/v1/router.py # 添加 voice/tts 路由
app/schemas/segment.py # 扩展 Segment Schema
app/ai/prompts/script/*.txt # 调整 Prompt 为纯旁白文案
```
**前端(tauri-app):**
```
新增:
src/pages/VideoCreation/VideoEditing.tsx
src/pages/VideoCreation/VoiceDubbing.tsx
src/store/voiceStore.ts
src/api/modules/voice.ts
src/api/modules/tts.ts
src/hooks/useVoiceClone.ts
src/hooks/useTTSGeneration.ts
src/hooks/useAutoSplit.ts
改造:
src/pages/VideoCreation/index.tsx # 调整为6步
src/pages/VideoCreation/ScriptCreation.tsx # 移除场景描述字段
src/store/projectStore.ts # 扩展数据模型
src/api/types.ts # 更新类型定义
```
**Rustsrc-tauri):**
```
新增:
src/commands/media.rs
src/ffmpeg_cmd.rs 中的 split_video_segments / probe_media_info
src/storage/paths.rs 中的 media/audio/shots 路径
改造:
src/lib.rs # 注册新命令
```
---
*本方案基于「美家卡智影」现有架构设计,最大化复用已有能力,降低开发成本与风险。*
-243
View File
@@ -1,243 +0,0 @@
# 迁移方案:废弃云端 `mjk_avatars` 表,数字人元数据全量迁移到本地存储
> **状态:方案已调整(2026-04-17** — 原方案中提到的 Celery 架构已完全移除,形象克隆现由 `app/scheduler/handlers/avatar_handler.py`Async Engine Scheduler)统一调度。云端仍保留 `avatars` 表作为形象克隆的持久化记录。
## 方案目标
| 目标 | 说明 |
|------|------|
| ✅ 贯彻设计理念 | 真正做到**轻量云 + 全本地业务数据**,云端只记日志不存业务数据 |
| ✅ 统一接口日志 | 所有接口请求统一记录到 `mjk_interface_request_logs`,按接口统计积分消耗 |
| ✅ 简化后端代码 | 删除大量 CRUD、状态管理、定时任务代码,后端更干净 |
| ✅ 用户掌控数据 | 所有数字人元数据存在用户本地,云端只记克隆请求的消耗积分 |
---
## 存储结构变化
### 变化前(现状)
```
云端 PostgreSQL: mjk_avatars
└─ 存储所有数字人元数据 (name/voice_id/element_id/status 等)
前端本地:
└─ 只做缓存,从云端同步
```
### 变化后(目标)
```
云端 PostgreSQL:
├─ mjk_interface_request_logs ← 只记:avatar_clone 请求 + 消耗积分 + 状态
└─ mjk_avatars ← 废弃,不再写入新数据(存量可保留可删除)
用户本地磁盘:
└─ ~/Documents/Meijiaka/avatars/{avatar_id}/
├─ meta.json ← 完整数字人元数据(JSON)
└─ source.mp4 ← 原始上传视频
```
---
## 本地存储结构定义
### 目录结构
```
~/Documents/Meijiaka/
└── avatars/
└── {avatar_id}/ # avatar_id = avt_{16位随机hex}
├── meta.json # 元数据(JSON 格式)
└── source.mp4 # 原始上传视频
```
### `meta.json` 结构
```json
{
"id": "avt_xxxxxxxxxxxxxxxx",
"name": "我的数字人",
"voiceId": "klingai-voice-id-string",
"elementId": 12345678,
"voiceTaskId": "kling-task-id-string",
"elementTaskId": "kling-task-id-string",
"videoUrl": "https://domain.com/path/to/source.mp4",
"trialUrl": "https://domain.com/path/to/trial.wav",
"status": "succeed",
"failReason": null,
"createdAt": "2026-04-16T10:00:00.000Z",
"updatedAt": "2026-04-16T10:05:00.000Z"
}
```
---
## 代码改动清单
### 后端 Python
| 操作 | 文件 | 改动说明 |
|------|------|----------|
| 🆕 新增 | `app/models/interface_request_logs.py` | SQLAlchemy 模型 `InterfaceRequestLogs` |
| 🆕 新增 | `app/crud/interface_request_logs.py` | CRUDcreate / update |
| ✏️ 修改 | `app/models/__init__.py` | 删除 `Avatar` 导入,新增 `InterfaceRequestLogs` |
| ✏️ 修改 | `app/api/v1/avatar.py` | 完全重写<br>• 保留:`POST /clone` / `GET /tasks/{id}` / `GET /clone/stream` / `POST /tasks/{id}/retry` / `DELETE /{id}` <br>• 删除:`GET /library` / `PATCH /{id}` / `/health` |
| ✏️ 修改 | `app/scheduler/handlers/avatar_handler.py` | 精简:删除所有对 `mjk_avatars` 读写,只记接口日志,进度放 Redis Registry |
| ❌ 删除 | `app/models/avatar.py` | 模型废弃,删除 |
| ❌ 删除 | `app/crud/avatar.py` | CRUD 废弃,删除 |
| ❌ 删除 | `app/tasks/avatar_clone.py` | 逻辑已合并到 avatar_handler,删除 |
### Rust Tauri`tauri-app/src-tauri/src/persistence.rs`
新增以下 IPC 命令:
```rust
/// 列出所有本地数字人(按创建时间倒序)
#[tauri::command]
pub fn list_avatars(app: AppHandle) -> Result<Vec<AvatarMeta>, String>;
/// 保存数字人元数据
#[tauri::command]
pub fn save_avatar(app: AppHandle, avatar_id: String, meta: AvatarMeta) -> Result<(), String>;
/// 获取单个数字人元数据
#[tauri::command]
pub fn get_avatar(app: AppHandle, avatar_id: String) -> Result<Option<AvatarMeta>, String>;
/// 删除数字人(删除整个本地目录)
#[tauri::command]
pub fn delete_avatar(app: AppHandle, avatar_id: String) -> Result<(), String>;
/// 更新数字人名称
#[tauri::command]
pub fn update_avatar_name(app: AppHandle, avatar_id: String, name: String) -> Result<(), String>;
```
`lib.rs` 注册新命令。
### 前端 TypeScript
| 模块 | 改动 |
|------|------|
| **Avatar 列表** | 原:`GET /avatar/library` 从后端获取 → 现在:调用 Tauri IPC 从本地读取 |
| **创建克隆** | 流程变化:<br>1. 前端生成 `avatar_id`<br>2. `POST /avatar/clone` → 获取 `task_id`<br>3. 前端创建本地目录 + 写入初始 `meta.json` (`status=pending`)<br>4. SSE 监听进度<br>5. 完成后 → 前端把 `voice_id`/`element_id` 写入本地 `meta.json`<br>6. 完成 |
| **删除 Avatar** | 流程变化:<br>1. 前端调用 `DELETE /avatar/{avatar_id}`(后端负责删除 Kling 远程资源)<br>2. 后端记删除日志到接口日志<br>3. 前端调用 IPC 删除本地目录 |
| **重命名 Avatar** | 原:调用后端 PATCH → 现在:前端直接修改本地 `meta.json`,无需请求后端 |
| **选择数字人生成视频** | 用法不变:从本地读取 `voice_id`/`element_id` → 传给后端视频生成接口 |
---
## 工作流对比
### 改动前(当前)
```
用户提交克隆
→ POST /clone → 后端写 mjk_avatars (status=pending) → 派发任务
→ Async Engine Scheduler (avatar_handler) 每一步都更新 `avatars` 表
→ 前端 SSE 轮询读 `avatars` 表拿进度
→ 完成后 Scheduler 更新 status=succeed 写入 voice_id/element_id
→ 前端从 `avatars` 表读结果 → 缓存到本地
→ 列表从 `avatars` 表读取
```
### 改动后(目标)
```
用户提交克隆
→ 前端生成 avatar_id → 创建本地 meta.json (status=pending)
→ POST /clone → 后端:
1. 在 mjk_interface_request_logs 插入记录
interface_type=avatar_clone, status=pending, started_at=now, cost_credits=X
2. 注册到 Async Engine Scheduler (Redis Registry)
3. 返回 {task_id, avatar_id}
→ Async Engine Scheduler (avatar_handler) 执行:
1. 调用 Kling 创建音色 → 轮询 → 获取 voice_id
2. 调用 Kling 创建主体 → 轮询 → 获取 element_id
3. 更新 Redis Registry 状态为 completed,写入结果
4. 更新接口日志: status=success/failed, finished_at=now
→ 前端 SSE 从 TaskCache 获取结果
→ 完成后前端将 voice_id/element_id 写入本地 meta.json
→ 列表展示直接从本地读取,不请求后端
```
---
## 接口日志记录规则
`mjk_interface_request_logs``avatar_clone` 的记录:
| 时机 | 操作 | 字段值 |
|------|------|--------|
| 刚收到请求 | 插入新记录 | `interface_type=avatar_clone`, `status=pending`, `started_at=NOW`, `cost_credits` = 克隆一次所需积分 |
| 任务完成成功 | 更新记录 | `status=success`, `finished_at=NOW` |
| 任务失败 | 更新记录 | `status=failed`, `finished_at=NOW`, `error_message=错误原因` |
> 积分在请求创建时即扣除,因为无论成功失败,KlingAI 开始处理后会计费。
---
## 存量数据迁移策略
### 渐进迁移(对用户友好)
1. **保留云端表**`mjk_avatars` 保留不删除,存量数据继续存在
2. **前端自动迁移**:用户首次打开形象库时:
- 前端检查:如果后端有数据但本地没有 → 提示用户"将云端数字人同步到本地"
- 用户确认后,前端逐个拉取数据写入本地
- 同步完成后,后续只使用本地数据
3. **下线旧表**:稳定运行一段时间后,可在维护窗口物理删除 `mjk_avatars`
### 回滚方案
- 迁移过程中如果出问题,随时切回原逻辑(表保留,代码只需恢复删除部分)
---
## 优缺点总结
| 优点 | 说明 |
|------|------|
| ✅ 完全符合需求 | 云端只存接口请求记录和消耗积分,不存用户业务数据 |
| ✅ 云端存储成本极低 | 只有接口日志,每条几KB,用户增长成本可控 |
| ✅ 后端代码大幅简化 | 删除了整个 Avatar CRUD、状态机管理、定时任务恢复,代码减少约 300 行 |
| ✅ 用户完全掌控数据 | 所有数字人元数据存储在用户本地磁盘 |
| ✅ 形象库展示更快 | 本地读取文件比查询数据库快很多 |
| ✅ 兼容存量数据 | 渐进迁移,可回滚 |
| 缺点 | 说明 | 应对 |
|------|------|------|
| 用户换电脑需要迁移 | 用户需要自行迁移数据,或重新克隆 | 后续可增加导出/导入功能解决 |
| 本地硬盘损坏数据丢失 | 这是"全本地"设计的必然结果 | 符合项目初始"轻量云+全本地"设计理念,用户自担数据安全 |
---
## 执行步骤(按顺序)
1. **数据库**
- 生成 Alembic 迁移:所有表重命名加 `mjk_` 前缀 + 新建 `mjk_interface_request_logs`
- 修改所有 Python 模型中的 `__tablename__`
2. **后端代码**
- 新建 `interface_request_logs` 模型和 CRUD
- 重写 `app/api/v1/avatar.py`
- 精简 `app/tasks/avatar_tasks.py`
- 删除废弃文件
3. **Rust Tauri**
-`persistence.rs` 新增 avatar 相关 IPC 命令
-`lib.rs` 注册命令
4. **前端代码**
- 修改形象库:从本地读取
- 修改创建流程:完成后写入本地
- 修改删除流程:删除云端 Kling 资源后删除本地
- 修改重命名:直接本地修改
5. **测试验证**
- 创建克隆 → 检查本地文件生成 → 检查接口日志写入
- 列表展示 → 删除 → 重命名 全流程测试
---
## 相关文档
- [数据库设计规范](./database-design.md) - 完整的数据库命名规范和表结构
- [视频生成流程](./video-generation-flow.md) - 完整视频生成流程说明
---
*版本:v1.0*
*创建日期:2026-04-16*
-263
View File
@@ -1,263 +0,0 @@
# MiniMax 语音接口接入方案
> 目标:用 MiniMax 语音能力(TTS + 克隆 + 设计)替换现有 Kling TTS
---
## 一、架构总览
```
┌─────────────────┐ HTTP ┌─────────────────────┐ HTTP ┌──────────────┐
│ tauri-app │ ────────────→ │ python-api │ ────────────→ │ MiniMax API │
│ │ │ │ │ │
│ VoiceDubbing │ synthesize │ MiniMaxTTSService │ /v1/t2a_v2 │ TTS 同步 │
│ VoiceMaterial │ clone/query │ MiniMaxVoiceClone │ /v1/voice_ │ 语音克隆 │
│ │ │ │ clone │ │
└─────────────────┘ └─────────────────────┘ └──────────────┘
```
**接入原则:**
- 最小侵入:复用现有 `voices.json``VoiceMaterial` 类型、进度弹窗
- 数据兼容:MiniMax `voice_id` 直接存入 `voiceId` 字段(字符串,无格式冲突)
- 流程对齐:上传 → 提交克隆 → 轮询 → ready,与现有 Kling 克隆流程完全一致
---
## 二、后端接入(python-api
### 2.1 新增配置项(`app/config.py`
```python
MINIMAX_API_KEY: str = "" # Bearer token
MINIMAX_BASE_URL: str = "https://api.minimax.io" # 国际站
```
`.env` 新增:
```bash
MINIMAX_API_KEY=sk-api-xxxx
MINIMAX_BASE_URL=https://api.minimax.io
```
### 2.2 新增 Provider`app/ai/providers/minimax_provider.py`
封装 MiniMax HTTP API,提供:
| 方法 | 对应接口 | 用途 |
|------|---------|------|
| `tts_sync(text, voice_id, speed, ...)` | POST `/v1/t2a_v2` | 同步 TTS |
| `tts_async_create(text, voice_id, ...)` | POST `/v1/t2a_async_create` | 异步长文本 TTS |
| `tts_async_query(task_id)` | GET `/v1/t2a_async_query` | 查询异步任务 |
| `clone_voice(audio_url, voice_name)` | POST `/v1/voice_clone` | 提交克隆 |
| `query_clone_task(task_id)` | GET `/v1/voice_clone` | 查询克隆任务 |
| `design_voice(description, voice_name)` | POST `/v1/voice_design` | 音色设计 |
| `upload_file(file_bytes, mime_type)` | POST `/v1/files/upload` | 上传文件 |
**关键设计:**
- 使用 `httpx.AsyncClient` 异步调用
- Token 直接走 `Authorization: Bearer` Header,无需额外鉴权逻辑
- 错误统一抛 `Exception(f"MiniMax API error: {message}")`
### 2.3 新增 Service`app/services/minimax_tts_service.py`
提供业务层封装,与现有 `TTSService` 接口对齐:
```python
class MiniMaxTTSService:
async def synthesize_sync(self, text, voice_id, speed=1.0) -> str:
"""同步 TTS,返回音频 URL"""
# 调用 provider.tts_sync,返回 audio_url
async def synthesize_async(self, text, voice_id, speed=1.0) -> dict:
"""异步长文本 TTS,返回 task_id"""
# 调用 provider.tts_async_create
async def query_async_task(self, task_id) -> dict:
"""查询异步任务状态"""
# 调用 provider.tts_async_query
async def clone_voice(self, audio_url: str, voice_name: str) -> str:
"""提交克隆,返回 task_id"""
async def query_clone_task(self, task_id: str) -> dict:
"""查询克隆状态,返回 {status, voice_id, trial_url}"""
async def design_voice(self, description: str, voice_name: str) -> str:
"""音色设计,返回 task_id(或 voice_id"""
```
### 2.4 修改 API 路由(`app/api/v1/voice.py`
**现有接口替换:**
| 现有端点 | 修改内容 |
|---------|---------|
| `POST /voice/synthesize` | 内部调用 `MiniMaxTTSService.synthesize_sync()` 替代 Kling |
| `POST /voice/clone/submit` | 调用 `MiniMaxTTSService.clone_voice()` |
| `GET /voice/clone/query/{task_id}` | 调用 `MiniMaxTTSService.query_clone_task()` |
| `POST /voice/upload` | 保持现有七牛上传逻辑(克隆音频先传七牛再给 MiniMax) |
**请求/响应 Schema 不变**:前端无需修改字段名。
**注意:** MiniMax 同步 TTS 返回的音频 URL 有效期 24 小时。如果前端需要长期保存,仍需走「下载 blob → 上传七牛 → 本地保存」的流程(复用现有 VoiceDubbing 逻辑)。
### 2.5 预设音色更新
现有 `TTSService.PRESET_VOICES` 中硬编码的 Kling 字符串 voice_id 全部废弃。
替换为 MiniMax 系统预设音色(需从 MiniMax 平台获取真实 voice_id 列表)。
**方案 A(推荐):启动时动态拉取**
- 服务启动时调用 MiniMax `GET /v1/voices/preset` 获取官方音色列表
- 缓存到内存,前端请求 `/voice/voices` 时返回
**方案 B(硬编码常用音色)**
- 先硬编码 5-10 个常用中文音色(需从 MiniMax 平台获取真实 voice_id
- 后续再扩展为动态拉取
> 建议先用方案 B 快速跑通,再迭代为方案 A。
---
## 三、前端适配(tauri-app
### 3.1 需要修改的文件
| 文件 | 修改内容 |
|------|---------|
| `src/api/modules/voice.ts` | `synthesizeTTS` 参数不变(text/voiceId/speed),无需改调用方 |
| `src/store/voiceStore.ts` | 预设音色加载逻辑适配 MiniMax 返回格式 |
| `src/pages/VideoCreation/VoiceDubbing.tsx` | 生成流程复用现有逻辑(synthesizeTTS → 下载 → 上传七牛 → 保存) |
| `src/pages/ContentManagement/VoiceMaterialLibrary.tsx` | 克隆/轮询/列表逻辑不变,只需确保后端 Schema 兼容 |
### 3.2 VoiceMaterial 数据兼容
现有 `VoiceMaterial` 结构:
```ts
interface VoiceMaterial {
id: string; // 克隆任务ID
name: string;
voiceId: string; // MiniMax voice_id(字符串,直接兼容)
sourceUrl: string; // 七牛云原始音频URL
trialUrl?: string; // 试听URL
status: 'pending' | 'processing' | 'ready' | 'failed';
createdAt: string;
}
```
**无需改动**MiniMax 的 `voice_id` 也是字符串,格式完全兼容。
### 3.3 生成配音流程(VoiceDubbing
现有流程(Kling):
```
synthesizeTTS → 返回 audio_url → fetch 下载 blob → uploadAudio(七牛) → saveAudio(本地)
```
替换后(MiniMax):
```
synthesizeTTS → 返回 audio_url → fetch 下载 blob → uploadAudio(七牛) → saveAudio(本地)
```
**前端完全不变**,只需确保后端 `/voice/synthesize` 返回的 `audio_url` 是 MiniMax 的 URL24小时有效)。
### 3.4 音色克隆流程(VoiceMaterialLibrary
现有流程(Kling):
```
上传音频 → 七牛 → submitCloneTask → 轮询 queryCloneTask → ready → 保存 voices.json
```
替换后(MiniMax):
```
上传音频 → 七牛 → submitCloneTask → 轮询 queryCloneTask → ready → 保存 voices.json
```
**前端完全不变**,只需后端 `/voice/clone/submit``/voice/clone/query/{task_id}` 内部换成 MiniMax。
---
## 四、异步任务调度(Scheduler
### 4.1 现状
当前使用自定义 Async EngineRedis 槽位调度)管理 Kling 克隆任务轮询。
### 4.2 方案选择
**方案 A:复用现有 Async Engine(推荐)**
- 新增 `MiniMaxCloneHandler`,占用 2 个槽位(与 AvatarHandler 同级)
- 优点:统一状态机、统一日志、统一并发控制
- 缺点:需要新增 Handler 和 Redis key
**方案 B:前端直接轮询**
- 克隆提交后,前端自己 `setInterval` 轮询 `/voice/clone/query/{task_id}`
- 优点:无需改 scheduler
- 缺点:用户关闭页面后轮询中断,任务状态可能丢失
**推荐方案 A**,保持后端统一调度。
### 4.3 MiniMaxCloneHandler 设计
```python
class MiniMaxCloneHandler(AsyncHandler):
slots = 3
redis_key = "minimax:clone_slots"
async def handle(self, job: JobRecord):
result = await minimax_service.query_clone_task(job.provider_task_id)
if result.status == "succeed":
return StateChange.complete(voice_id=result.voice_id, trial_url=result.trial_url)
elif result.status == "failed":
return StateChange.fail(error=result.error_message)
else:
return StateChange.noop() # 继续轮询
```
---
## 五、开发顺序
### Phase 1:基础 Provider + TTS1-2h
1. 新增 `MiniMaxProvider`HTTP 封装)
2. 新增 `MiniMaxTTSService`
3. 修改 `/voice/synthesize` 路由,替换 Kling TTS
4. 配置 `.env` + `config.py`
5. **验证**VoiceDubbing 生成配音是否正常
### Phase 2:语音克隆(2-3h
1. 新增 `/voice/clone/submit` + `/voice/clone/query` 后端逻辑
2. 新增 `MiniMaxCloneHandler` 到 Scheduler
3. **验证**VoiceMaterialLibrary 上传音频 → 克隆 → ready 完整链路
### Phase 3:预设音色(1h
1. 替换 `PRESET_VOICES` 为 MiniMax 系统音色
2. 修改 `/voice/voices` 返回格式(如有变化)
3. **验证**:系统预设音色列表正常,选中后 TTS 可用
### Phase 4:收尾(1h
1. 清理 Kling TTS 相关代码(标记废弃或删除)
2. 更新 `AGENTS.md` 文档
3. 端到端测试
---
## 六、风险与注意点
| 风险 | 应对 |
|------|------|
| MiniMax 预设音色 voice_id 未知 | 方案 B 先硬编码,后续动态拉取 |
| TTS 返回 URL 24h 过期 | 复用现有「下载→七牛→本地」流程,无需改前端 |
| 克隆音色 7 天过期 | 前端列表中标记「临时音色」,提示用户定期使用 |
| 异步长文本 TTS 暂时不需要 | Phase 1 只接同步 TTS,异步后续按需扩展 |
| 现有 Kling 视频/形象克隆仍保留 | 只替换语音相关,Kling Video/Element 不动 |
---
## 七、需要你确认的
1. **预设音色**:MiniMax 平台里有哪些你想用的系统预设音色?我可以先硬编码 5-10 个。
2. **服务区域**:用 `api.minimax.io`(国际)还是 `api.minimaxi.com`(国内)?
3. **异步 TTS**:当前场景单次旁白 ≤1000 字,同步 TTS 够用了,异步长文本暂时不接,对吗?
4. **音色设计**:是否需要「根据文字描述生成虚拟音色」的能力?还是只接「上传音频克隆」?
5. **删除 Kling 代码**:接入完成后是否彻底删除 Kling TTS/克隆代码,还是保留做 fallback?
-425
View File
@@ -1,425 +0,0 @@
# 后端语义治理与架构重构计划
> **范围**`python-api/app/` 全目录
> **目标**:根治需求调整与 Celery→Async Scheduler 迁移导致的命名腐败、语义漂移、类型漂移问题
> **原则**:长期稳定优先,不计短期开发成本
> **状态**:计划中(待团队评审后执行)
---
## 一、问题诊断
经过深度代码扫描,当前代码存在六大类语义腐败,按严重程度排序:
### P0:调度器核心模型的 `params: dict[str, Any]` 类型漂移
- **症状**:所有 Handler 从裸字典里 `params.get("shots")`,且因 Redis 序列化反复写 `if isinstance(shots, str): shots = json.loads(shots)`
- **风险**:这是 Scheduler 取代 Celery 后最脆弱的环节,任何字段名改动都会导致运行时崩溃
- **关键文件**`app/scheduler/models.py`, `app/scheduler/registry.py`, `app/scheduler/handlers/video_handler.py`
### P0`shot/segment/scene` 的三重命名 + 四处重复定义
- **症状**`ScriptShot`Schema)、`ShotData`API)、`ShotTask`Service)、`ShotUnit`(Scheduler)四者并存,字段名 `scene`/`scene_desc``type`/`shot_type` 混用
- **风险**:任何分镜字段改动必须改 4 个类,极易遗漏
- **关键文件**`app/schemas/script.py`, `app/api/v1/video.py`, `app/services/kling_video_service.py`, `app/scheduler/models.py`
### P0:Kling 供应商语义大规模泄漏到业务层和 API 层
- **症状**`element_id`Kling 主体 ID)、`kling_task_id`、Omni prompt 语法 `<<<element_1>>>` 直接出现在 API Schema、Scheduler 模型、数据库模型中
- **风险**:一旦更换视频供应商,影响面会穿透所有层级
- **关键文件**`app/api/v1/video.py`, `app/api/v1/tasks.py`, `app/models/avatar.py`, `app/scheduler/handlers/video_handler.py`
### P1`task` / `task_id` 的五重语义混用
- **症状**FastAPI BackgroundTask、Scheduler Task、Kling API Task、AnyToCopy Task、Volcengine Task 都叫 `task`
- **风险**:日志堆栈中无法区分层级,调试极其困难
- **关键文件**`app/api/v1/tasks.py`, `app/scheduler/`, `app/ai/providers/klingai_provider.py`, `app/services/`
### P1:历史残留命名与双轨运行
- **症状**`# 兼容旧字段``video_task_id``image_task_id``cache_err` 等 Celery 时代残留;`script` 任务仍走 BackgroundTask,其他任务走 Scheduler
- **风险**:双轨运行导致统一监控、重试、日志无法覆盖全部任务类型
- **关键文件**`app/scheduler/handlers/video_handler.py`, `app/scheduler/handlers/image_handler.py`, `app/api/v1/tasks.py`
### P2CRUD 层裸字典更新
- **症状**`avatar_crud.update(db, db_obj=avatar, obj_in={"status": "element_pending"})`
- **风险**:拼写错误、状态值非法无法被静态检查捕获
- **关键文件**`app/crud/base.py`, `app/crud/avatar.py`, `app/scheduler/handlers/avatar_handler.py`
---
## 二、架构目标:六层语义治理
我们将整个后端严格划分为 **6 个语义层级**,每一层只使用属于该层的术语:
```
┌─────────────────────────────────────────────────────────┐
│ Layer 6: Presentation (API Schema / 前端适配层) │
│ 术语: Segment, Human, Job, Script, Cover │
├─────────────────────────────────────────────────────────┤
│ Layer 5: Application (API 路由 / BackgroundJob) │
│ 术语: Segment, Human, Job, Project │
├─────────────────────────────────────────────────────────┤
│ Layer 4: Orchestration (Scheduler / SlotManager) │
│ 术语: Job, JobRecord, Slot, Handler │
├─────────────────────────────────────────────────────────┤
│ Layer 3: Domain (Service / 业务逻辑) │
│ 术语: Segment, Human, VideoComposition, Caption │
├─────────────────────────────────────────────────────────┤
│ Layer 2: Adapter (Provider Client / 供应商适配) │
│ 术语: KlingJob, KlingElement, VolcJob, ProviderTaskId │
├─────────────────────────────────────────────────────────┤
│ Layer 1: Infrastructure (DB / Redis / HTTP / FileSys) │
│ 术语: 仅使用底层技术术语 │
└─────────────────────────────────────────────────────────┘
```
### 核心禁令
1. `element``omni``kling_task_id` 等**供应商术语**禁止出现在 Layer 3 以上
2. `shot` 禁止出现在 Layer 3 以上(Kling 术语,业务层统一叫 `segment`
3. `task` 禁止出现在 Layer 4Scheduler 内部统一叫 `job`
4. `dict[str, Any]` 禁止出现在跨层传递的接口中
---
## 三、重构阶段(Phase 1-5
每个 Phase 独立成组,建议按顺序执行。每个 Phase 完成后必须跑通 `pytest``make lint`
---
### Phase 1Schema 统一 + 状态机 Enum 化
**目标**:建立"单一真相源",消除 shot/segment/scene 的四重定义
**预估工时**3-4 天
**影响面**:全项目基础类型
#### Task 1.1:新建统一术语 Schema
- [ ] 新建 `app/schemas/segment.py`
```python
class Segment(BaseModel):
id: str
type: Literal["segment", "empty_shot"]
scene: str # 统一为 scene,删除 scene_desc
voiceover: str
duration: int | None = None
human_id: str | None = None # 业务术语,对应 Kling 的 element_id
status: SegmentStatus = SegmentStatus.PENDING
provider_task_id: str | None = None
video_url: str | None = None
local_path: str | None = None
query_fail_count: int = 0
```
- [ ] 新建 `app/schemas/enums.py`,定义以下 Enum
- `JobStatus`: pending, running, completed, failed
- `SegmentStatus`: pending, submitted, completed, failed
- `AvatarCloneStatus`: pending, voice_processing, voice_failed, element_pending, element_processing, element_failed, succeed
- `KlingTaskStatus`: submitted, processing, succeed, failed(仅限 Provider 层使用)
- [ ] 新建 `app/schemas/job.py`,定义 `JobParams` Union
- `VideoJobParams`(含 `segments: list[Segment]`
- `ImageJobParams`
- `SubtitleJobParams`
- `CopyJobParams`
- `AvatarCloneJobParams`
#### Task 1.2:删除重复定义
- [ ] 删除 `app/scheduler/models.py` 中的 `ShotUnit`
- [ ] 删除/重构 `app/services/kling_video_service.py` 中的 `ShotTask`(字段迁移到 `Segment`
- [ ] 删除 `app/api/v1/video.py` 中的 `ShotData`,改为引用 `Segment`
- [ ] 将 `app/schemas/script.py` 中的 `ScriptShot` 改为 `Segment` 的别名或类型适配器
#### Task 1.3:字段名统一
- [ ] 批量将 `scene_desc` 重命名为 `scene`(覆盖 `kling_video_service.py`, `video_handler.py` 等)
- [ ] 批量将 `shot_type` 重命名为 `type`(在业务层/Schema 层;Provider 层保留 `shot_type` 仅用于 Kling API 调用)
- [ ] `app/api/v1/tasks.py` 中的 `shots: list[dict]` 改为 `segments: list[Segment]`
#### Task 1.4:状态字符串 Enum 化
- [ ] `app/scheduler/models.py` 中 `TaskRecord.status` 类型改为 `JobStatus`
- [ ] `app/services/kling_video_service.py` 中 `VideoGenerationJob.status` 类型改为 `JobStatus`
- [ ] `app/models/avatar.py` 中 `Avatar.status` 类型改为 `AvatarCloneStatus`
- [ ] `app/ai/providers/klingai_provider.py` 中所有 Kling 状态字符串操作改为 `KlingTaskStatus`
#### 验收标准
- [ ] `grep -rn "class ShotUnit\|class ShotTask\|class ShotData\|class ScriptShot" app/` 返回为空(除了注释或别名声明)
- [ ] `grep -rn "scene_desc" app/ | grep -v "__pycache__"` 返回为空
- [ ] `mypy app/schemas/` 无类型错误
- [ ] `pytest` 通过
---
### Phase 2Scheduler 层全面"去 task 化"
**目标**:消除 `task` 的五重语义混用,建立 `Job` 专属语义域
**预估工时**3-4 天
**影响面**`app/scheduler/` 目录及引用方
#### Task 2.1:核心模型与 Registry 重命名
- [ ] `app/scheduler/models.py``TaskRecord` → `JobRecord`
- [ ] `app/scheduler/registry.py``TaskRegistry` → `JobRegistry`
- 所有 `task_id` 参数/字段 → `job_id`
- 所有 `task_type` 参数/字段 → `job_type`
- `running_task_ids` → `running_job_ids`
- [ ] `app/scheduler/engine.py``AsyncEngine` 中所有 `task` → `job`
#### Task 2.2Registry 承担全部序列化职责
- [ ] 在 `JobRegistry.get()` 中统一完成 JSON 反序列化
- 解析 `params` 字段时,根据 `job_type` 路由到正确的 `JobParams` Pydantic 模型
- 保证 Handler 拿到的 `job.params` 永远是强类型对象
- [ ] 删除 `video_handler.py` 和 `image_handler.py` 中所有的 `isinstance(shots, str): json.loads` 逻辑
- [ ] 删除 `_download_and_upload` 中的手动 JSON 类型判断
#### Task 2.3`StateChange` 取代裸字典
- [ ] 在 `app/scheduler/models.py` 中定义:
```python
@dataclass(frozen=True)
class StateChange:
job_id: str
field: str
value: Any
```
- [ ] 修改 `app/scheduler/engine.py`
- `_apply_changes(self, changes: list[dict[str, Any]])` → `_apply_changes(self, changes: list[StateChange])`
- 序列化逻辑移入 `StateChange.to_redis_command()` 方法
- [ ] 修改 `app/scheduler/handlers/base.py``tick()` 返回类型改为 `list[StateChange]`
- [ ] 修改所有 Handler`changes.append({"task_id": ..., "field": ...})` → `changes.append(StateChange(job_id=..., field=..., value=...))`
#### Task 2.4API 层适配
- [ ] `app/api/v1/tasks.py` 中:内部变量名 `task_id` 在引用 Scheduler 时改为 `job_id`API URL `/tasks/{task_id}` 保持不变,仅内部变量和注释调整)
- [ ] `app/api/v1/avatar.py` 中:引用 `TaskRegistry` 的地方改为 `JobRegistry`
#### 验收标准
- [ ] `grep -rn "TaskRecord\|TaskRegistry\|running_task_ids" app/scheduler/` 返回为空
- [ ] `grep -rn "isinstance(.*shots.*str)" app/scheduler/handlers/` 返回为空
- [ ] `grep -rn '"task_id":' app/scheduler/handlers/` 返回为空(仅 `StateChange` dataclass 内部可保留)
- [ ] `pytest` 通过
---
### Phase 3:建立"供应商防火墙"Adapter 层隔离)
**目标**:将 Kling/Volc 术语彻底下压到 Provider 层,业务层以上使用纯业务语义
**预估工时**4-5 天
**影响面**API Schema、DB 模型、Scheduler 模型、Provider 层
#### Task 3.1API 层删除 Kling 术语泄漏
- [ ] `app/api/v1/video.py`
- `element_id: int | None = Field(None, description="Kling主体ID")` → `human_id: int | None = Field(None, description="数字人主体ID")`
- [ ] `app/api/v1/tasks.py`
- 同上,所有 `element_id` → `human_id`
- `VideoParams` 中的 `shots` → `segments`
#### Task 3.2DB 模型增加供应商抽象
- [ ] `app/models/avatar.py`
- `element_id: Mapped[int | None]` → `provider_element_id: Mapped[int | None]`
- `voice_task_id` → `provider_voice_job_id`
- `element_task_id` → `provider_element_job_id`
- 新增 `provider: Mapped[str] = mapped_column(default="kling")`(为未来多供应商做准备)
- [ ] 生成 Alembic 迁移脚本(字段重命名 + 新增字段)
#### Task 3.3Scheduler 模型供应商抽象
- [ ] `app/scheduler/models.py`Phase 2 后的 `JobRecord` 及 `Segment`):
- `kling_task_id` → `provider_task_id`
- 如需追溯供应商,增加 `provider: str = "kling"`
#### Task 3.4Provider 返回值模型化
- [ ] 新建 `app/ai/providers/kling_dto.py`
- `KlingVideoResult`
- `KlingImageResult`
- `KlingVoiceResult`
- `KlingElementResult`
- [ ] 修改 `app/ai/providers/klingai_provider.py`
- 所有返回裸 `dict[str, Any]` 的方法改为返回对应的 `Kling*Result`
- 状态字段类型改为 `KlingTaskStatus`
#### Task 3.5Prompt 语法迁移到 Provider 层
- [ ] 删除 `app/scheduler/handlers/video_handler.py` 中的:
- `_build_omni_prompt()`
- `_build_empty_shot_video_prompt()`
- [ ] 在 `app/ai/providers/klingai_provider.py` 中新建 `KlingPromptBuilder` 类:
```python
class KlingPromptBuilder:
@staticmethod
def omni_segment(scene: str, voiceover: str, element_id: str | None = None) -> str
@staticmethod
def empty_shot(scene: str, voiceover: str) -> str
```
- [ ] `video_handler.py` 调用时只传业务字段(`scene`, `voiceover`, `human_id`),由 Provider 层负责映射为 Kling 语法
#### Task 3.6Service 层适配器映射
- [ ] `app/services/kling_video_service.py`
- 删除 `avatar_id` 废弃字段
- `human_id` → 在调用 Provider 时映射为 `element_id`
- [ ] `app/services/qiniu_service.py`
- `file_type="avatar"` → `file_type="clone_video"` 或 `"human_video"`
#### 验收标准
- [ ] `grep -rn "element_id" app/api/ app/schemas/ app/scheduler/models.py | grep -v "provider_element_id"` 返回为空
- [ ] `grep -rn "kling_task_id" app/api/ app/schemas/ app/scheduler/models.py` 返回为空
- [ ] `grep -rn "<<<element_1>>>\|<<<voice_1>>>" app/scheduler/ app/services/` 返回为空(仅在 Provider 层保留)
- [ ] Alembic 迁移脚本可正常升级/降级
- [ ] `pytest` 通过
---
### Phase 4:清理历史残留 + 消除双轨运行
**目标**:删除所有 Celery 时代残留,将 `script` 任务纳入 Scheduler 统一调度
**预估工时**2-3 天
**影响面**Handler、API 路由、历史命名
#### Task 4.1:删除兼容旧字段代码
- [ ] `app/scheduler/handlers/video_handler.py`
- 删除 `shot["video_task_id"] = kling_task_id # 兼容旧字段`
- 删除初始化 shots 时的 `"video_task_id": None`
- [ ] `app/scheduler/handlers/image_handler.py`
- 删除 `params["image_task_id"] = kling_task_id`
- [ ] `app/services/kling_video_service.py`
- 删除 `avatar_id` 字段
#### Task 4.2:修正历史残留命名
- [ ] `app/core/redis_client.py`:删除文档字符串中的 `RateLimiter` 字样
- [ ] `app/api/v1/tasks.py`
- `cache entry` → `registry entry`
- `cache_err` → `registry_err`
- `Failed to update cache` → `Failed to update registry`
- [ ] `app/core/token_manager.py``_background_tasks` → `_refresh_tasks`
- [ ] 删除 `app/services/dto.py`
#### Task 4.3:将 `script` 任务迁移到 Scheduler
- [ ] 新建 `app/scheduler/handlers/script_handler.py`
- 将 `app/api/v1/tasks.py` 中 `_run_script_task` 的逻辑迁移至此
- 继承 `AsyncHandler``name = "script"`,不占用 Slot(或占用独立 `script_slots`
- [ ] 修改 `app/api/v1/tasks.py`
- `script` 任务改为 `registry.create(job_type="script", ...)`
- 删除 `BackgroundTasks` 相关参数和 `_run_script_task` 函数
- [ ] 修改 `app/scheduler/main.py`:注册 `ScriptHandler`
#### 验收标准
- [ ] `grep -rn "兼容旧字段\|video_task_id\|image_task_id" app/scheduler/ app/services/` 返回为空
- [ ] `grep -rn "cache_err\|cache entry" app/api/v1/tasks.py` 返回为空
- [ ] `app/services/dto.py` 不存在
- [ ] `app/api/v1/tasks.py` 中无 `BackgroundTasks` 导入和使用
- [ ] `pytest` 通过
---
### Phase 5CRUD 层强类型化
**目标**:消灭 CRUD 层的裸字典更新
**预估工时**2 天
**影响面**CRUD Base、Avatar CRUD、Scheduler Handler
#### Task 5.1CRUD Base 类型约束
- [ ] `app/crud/base.py`
- `obj_in: dict[str, Any]` → `obj_in: CreateSchemaType | UpdateSchemaType`
- 保留 `dict` 仅作为 `update` 的 fallback,但所有业务调用方优先使用 Schema
#### Task 5.2Avatar Schema 定义
- [ ] 新建 `app/schemas/avatar.py`
```python
class AvatarCreate(BaseModel):
name: str
video_url: str
status: AvatarCloneStatus = AvatarCloneStatus.PENDING
class AvatarUpdate(BaseModel):
name: str | None = None
status: AvatarCloneStatus | None = None
provider_voice_job_id: str | None = None
provider_element_job_id: str | None = None
provider_element_id: int | None = None
fail_reason: str | None = None
```
- [ ] `app/crud/avatar.py`:改为 `class CRUDAvatar(CRUDBase[Avatar, AvatarCreate, AvatarUpdate])`
#### Task 5.3Handler 调用方改造
- [ ] `app/scheduler/handlers/avatar_handler.py`
- 所有 `_update_avatar(avatar_id, {"status": "..."})` 改为 `_update_avatar(avatar_id, AvatarUpdate(status=AvatarCloneStatus.XXX))`
- 删除裸字典辅助函数 `_update_avatar` 中的 `**obj_in` 展开,改用 `obj_in.model_dump(exclude_unset=True)`
#### 验收标准
- [ ] `grep -rn 'obj_in=\{' app/scheduler/handlers/avatar_handler.py` 返回为空
- [ ] `mypy app/crud/` 无类型错误
- [ ] `pytest` 通过
---
## 四、自动化防护网(Phase 5 之后部署)
### 4.1 预提交钩子:禁词检查
在 `.pre-commit-config.yaml` 或 `Makefile` 中增加 `lint-semantic`
```makefile
lint-semantic:
@echo "Checking semantic boundaries..."
@! grep -rn "element_id" app/api/ app/schemas/ app/scheduler/models.py | grep -v "provider_element_id" || (echo "ERROR: element_id leaked to upper layers"; exit 1)
@! grep -rn "kling_task_id" app/api/ app/schemas/ app/scheduler/models.py || (echo "ERROR: kling_task_id leaked to upper layers"; exit 1)
@! grep -rn "scene_desc" app/ | grep -v "__pycache__" || (echo "ERROR: scene_desc not fully renamed"; exit 1)
@! grep -rn "TaskRecord\|TaskRegistry\|running_task_ids" app/scheduler/ || (echo "ERROR: Scheduler task naming not fully migrated"; exit 1)
@! grep -rn "<<<element_1>>>\|<<<voice_1>>>" app/scheduler/ app/services/ || (echo "ERROR: Kling prompt syntax leaked"; exit 1)
@echo "Semantic check passed"
```
### 4.2 mypy 渐进严格化
- [ ] 在 `pyproject.toml` 中为 `app/scheduler/` 和 `app/schemas/` 单独开启 `strict = true`
- [ ] 逐步扩展至 `app/api/` 和 `app/services/`
### 4.3 AGENTS.md 术语表(Glossary
在 `AGENTS.md` 中新增"统一术语表"章节(见下文),所有 AI Agent 修改代码前必须查阅。
---
## 五、风险与回滚策略
| 风险 | 影响 | mitigation |
|------|------|-------------|
| Phase 1 删除 `ScriptShot` 影响前端类型生成 | 中 | `ScriptShot` 保留为 `Segment` 的 `TypeAlias` 一个 Sprint,待前端适配后删除 |
| Phase 2 `JobRegistry` 重命名导致 API 层引用遗漏 | 高 | 使用 IDE 全局重构(PyCharm/Ruff/Rename Symbol),执行后跑全量 `pytest` |
| Phase 3 DB 字段重命名需要数据迁移 | 中 | Alembic 脚本必须包含 `op.alter_column` 的 `existing_type` 和 `existing_nullable` |
| Phase 4 `script` 迁出 BackgroundTask 后响应时间变长 | 低 | 脚本生成仍立即返回 `job_id`,前端通过轮询 `/tasks/{job_id}` 获取结果,接口契约不变 |
| 多 Phase 并行开发导致冲突 | 高 | **严禁并行**:必须按 1→2→3→4→5 顺序执行,每个 Phase 合并到主分支后再开始下一个 |
---
## 六、作为 GitHub Project Task List 的格式
若导入 GitHub Project,建议按以下结构建 5 个 Milestone
```markdown
### Milestone 1: Schema Unification
- [ ] #101 Create `app/schemas/segment.py` with `Segment` model
- [ ] #102 Create `app/schemas/enums.py` with `JobStatus`, `SegmentStatus`, `AvatarCloneStatus`, `KlingTaskStatus`
- [ ] #103 Create `app/schemas/job.py` with `JobParams` Union
- [ ] #104 Remove `ShotUnit` from `app/scheduler/models.py`
- [ ] #105 Remove `ShotTask` from `app/services/kling_video_service.py`
- [ ] #106 Remove `ShotData` from `app/api/v1/video.py`
- [ ] #107 Rename `scene_desc` → `scene` across codebase
- [ ] #108 Migrate all `status` strings to Enums
### Milestone 2: Scheduler De-tasking
- [ ] #201 Rename `TaskRecord` → `JobRecord`
- [ ] #202 Rename `TaskRegistry` → `JobRegistry`
- [ ] #203 Registry auto-deserializes `JobParams` models
- [ ] #204 Replace `dict` changes with `StateChange` dataclass
- [ ] #205 Update all Handlers to return `list[StateChange]`
### Milestone 3: Vendor Firewall
- [ ] #301 API layer: `element_id` → `human_id`
- [ ] #302 DB model: add `provider` field, rename task/element IDs
- [ ] #303 Scheduler model: `kling_task_id` → `provider_task_id`
- [ ] #304 Provider DTOs: `KlingVideoResult`, `KlingImageResult`, etc.
- [ ] #305 Move `KlingPromptBuilder` to Provider layer
- [ ] #306 Alembic migration for avatar table changes
### Milestone 4: Cleanup & Unification
- [ ] #401 Remove legacy compatibility fields (`video_task_id`, `image_task_id`)
- [ ] #402 Fix historical naming (`cache_err`, `RateLimiter` docstrings, etc.)
- [ ] #403 Delete `app/services/dto.py`
- [ ] #404 Migrate `script` task from BackgroundTask to Scheduler
### Milestone 5: CRUD Strong Typing
- [ ] #501 Create `AvatarCreate` / `AvatarUpdate` schemas
- [ ] #502 Type-constrain CRUDBase
- [ ] #503 Refactor `avatar_handler.py` to use `AvatarUpdate` instead of raw dicts
- [ ] #504 Add `lint-semantic` to Makefile / pre-commit
- [ ] #505 Update `AGENTS.md` with Glossary and layer rules
```
---
## 七、相关文档
- [统一异步调度器设计文档](./unified-async-scheduler.md)
- [数据库设计文档](./database-design.md)
- [AGENTS.md](../AGENTS.md)(术语表与分层禁令)
-342
View File
@@ -1,342 +0,0 @@
# 视频生成交互流程设计
## 一、正常流程(批量生成)
```
┌─────────────────────────────────────────────────────────────────┐
│ Step 0: 检查前置条件 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 点击【生成视频】按钮 │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 检查本地状态 │ │
│ │ 如果正在生成中 │───► 提示"已有任务进行中,请等待完成" │
│ └─────────────────┘ 或者"是否取消当前任务?" │
│ │ │
│ ▼ 无进行中任务 │
│ ┌─────────────────┐ │
│ │ 检查是否选形象 │ │
│ │ 未选择 │───► 弹出形象选择弹窗 │
│ └─────────────────┘ │
│ │ │
│ ▼ 已选择 │
│ 继续下一步 │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Step 1: 确认弹窗 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ 开始生成视频 │ │
│ ├─────────────────────────────────────────┤ │
│ │ │ │
│ │ 将生成 8 个分镜视频 │ │
│ │ 预计耗时:约 15-20 分钟 │ │
│ │ │ │
│ │ ⚠️ 生成过程中请勿关闭应用 │ │
│ │ 您可以最小化窗口,但不要关闭 │ │
│ │ │ │
│ │ ┌────────────┐ ┌──────────────────┐ │ │
│ │ │ 取消 │ │ 开始生成 │ │ │
│ │ └────────────┘ └──────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Step 2: 进入生成状态(界面锁定) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 【生成】按钮变为【生成中...】且 disabled │
│ │
│ 顶部显示全局状态栏: │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ 🎬 视频生成中 ━━━━━━━━⏳━━━━ 预计还需 12 分钟 [?] ││
│ └─────────────────────────────────────────────────────────────┘│
│ │
│ 显示模态弹窗(不可关闭): │
│ ┌─────────────────────────────────────────┐ │
│ │ 视频生成 │ │
│ ├─────────────────────────────────────────┤ │
│ │ │ │
│ │ ┌─────────────┐ │ │
│ │ │ 状态标签 │ │ │
│ │ │ 任务已开启 │ │ │
│ │ └─────────────┘ │ │
│ │ │ │
│ │ 正在为空镜生成参考图片... │ │
│ │ │ │
│ │ 预计还需 12 分钟 │ │
│ │ │ │
│ │ [最小化到后台] │ │
│ │ │ │
│ └─────────────────────────────────────────┘ │
│ │
│ 界面锁定状态: │
│ - 禁用:生成按钮、新建项目、添加/删除分镜 │
│ - 可浏览:但不能修改任何内容 │
│ - 可退出应用:但会提示"任务将后台继续,确定退出?" │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Step 3: 状态流转 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 状态标签流转: │
│ │
│ 分镜(omni-video): │
│ 任务已开启 ──► 排队生成中 ──► 任务已完成 │
│ │
│ 空镜(文生图+图生视频): │
│ 任务已开启 ──► 生成参考图片... ──► 排队生成中 ──► 任务已完成 │
│ │
│ 详细描述文字实时更新(SSE 推送): │
│ - "正在初始化任务..." │
│ - "正在为空镜生成参考图片..." │
│ - "图片生成完成,开始生成视频..." │
│ - "正在生成视频,请稍候..." │
│ - "已完成 3/8 个分镜" │
│ - "整理生成结果..." │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Step 4: 完成 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 模态弹窗更新: │
│ ┌─────────────────────────────────────────┐ │
│ │ 视频生成 │ │
│ ├─────────────────────────────────────────┤ │
│ │ │ │
│ │ ┌─────────────┐ │ │
│ │ │ 任务已完成 │ │ │
│ │ └─────────────┘ │ │
│ │ │ │
│ │ 成功生成 8 个视频 │ │
│ │ │ │
│ │ ┌──────────────────────────────────┐ │ │
│ │ │ 确定 │ │ │
│ │ └──────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────┘ │
│ │
│ 用户点击【确定】: │
│ 1. 关闭弹窗 │
│ 2. 解锁界面 │
│ 3. 自动滚动到第一个有视频的分镜 │
│ 4. 播放第一个视频 │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## 二、单个重新生成流程
```
┌─────────────────────────────────────────────────────────────────┐
│ 差异点 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 入口:分镜卡片上的【重新生成】按钮 │
│ │
│ 确认弹窗简化: │
│ ┌─────────────────────────────────────────┐ │
│ │ 重新生成视频 │ │
│ ├─────────────────────────────────────────┤ │
│ │ 将重新生成分镜 3 的视频 │ │
│ │ 预计耗时:约 3-5 分钟 │ │
│ │ │ │
│ │ ⚠️ 生成过程中请勿关闭应用 │ │
│ │ │ │
│ │ [取消] [确定] │ │
│ └─────────────────────────────────────────┘ │
│ │
│ 完成后:自动选中该分镜并播放 │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## 三、异常流程
### 3.1 用户尝试关闭应用
```
用户点击关闭窗口(或 Cmd+Q / Alt+F4
┌─────────────────────────────────────────┐
│ ⚠️ 确认关闭 │
├─────────────────────────────────────────┤
│ │
│ 视频生成任务仍在进行中 │
│ │
│ 如果选择关闭: │
│ - 任务将在后台继续运行 │
│ - 生成完成后会推送系统通知 │
│ - 下次打开应用可查看结果 │
│ │
│ [取消] [最小化到托盘] [关闭应用] │
│ │
└─────────────────────────────────────────┘
```
### 3.2 应用崩溃/强制退出后恢复
```
用户重新打开应用
┌─────────────────────────────────────────┐
│ 📋 恢复未完成任务 │
├─────────────────────────────────────────┤
│ │
│ 检测到上次有未完成的视频生成任务 │
│ │
│ 项目:厨房改造方案 │
│ 进度:已完成 5/8 个分镜 │
│ 状态:仍在后台处理中 │
│ │
│ [查看进度] [我知道了] │
│ │
└─────────────────────────────────────────┘
点击【查看进度】:
- 跳转到视频生成页面
- 自动恢复进度弹窗显示
- 继续监听 SSE/轮询
```
### 3.3 生成失败
```
┌─────────────────────────────────────────┐
│ ❌ 生成失败 │
├─────────────────────────────────────────┤
│ │
│ 视频生成过程中发生错误 │
│ │
│ 错误信息:Kling API 超时 │
│ │
│ 已生成的视频已保存 │
│ 失败的分镜:分镜3、分镜7 │
│ │
│ [返回查看] [重试失败项] │
│ │
└─────────────────────────────────────────┘
点击【重试失败项】:
- 只重新生成失败的那几个分镜
- 复用现有参数
```
### 3.4 网络断开
```
SSE 连接断开
状态栏显示:"网络异常,正在重连...1/3"
自动重连 SSE(最多 3 次)
├─► 重连成功:继续接收进度
└─► 重连失败:切换到轮询模式
每 5 秒轮询一次状态
网络恢复后:自动切回 SSE
```
## 四、本地状态管理
```typescript
// localStorage: meijiaka_generation_state
interface GenerationState {
// 任务标识
jobId: string;
projectId: string;
// 任务状态
status: 'pending' | 'generating' | 'completed' | 'failed';
// 任务信息(用于恢复显示)
shots: Array<{
id: string;
type: 'segment' | 'empty_shot';
}>;
totalShots: number;
// 时间戳
startedAt: number;
lastUpdatedAt: number;
// 结果(完成后填写)
results?: Array<{
shotId: string;
status: 'completed' | 'failed';
videoPath?: string;
errorMessage?: string;
}>;
// 错误信息
errorMessage?: string;
}
```
## 五、状态流转图
```
┌─────────────┐
│ IDLE │
└──────┬──────┘
│ 点击生成
┌─────────────┐
┌───────────────►│ CONFIRM │◄───────────────┐
│ │ 确认弹窗 │ │
│ └──────┬──────┘ │
│ 取消 │ 确认 │
│ ▼ │
│ ┌─────────────┐ │
│ │ GENERATING │────────────────┤
│ │ 生成中 │ 应用崩溃/关闭 │
│ └──────┬──────┘ │
│ │ │
│ ┌────────────┼────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │SUCCESS │ │ FAILED │ │ TIMEOUT │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ ▼ └────────────┘ │
│ ┌─────────┐ │ │
└───┤ RESULT │◄──────────────┘ │
│ 结果弹窗 │ │
└────┬────┘ │
│ │
▼ │
┌─────────┐ │
│ IDLE │───────────────────────────────┘
└─────────┘ 下次启动检测恢复
```
## 六、关键决策点
| 决策 | 选择 | 理由 |
|------|------|------|
| 生成中能否关闭应用 | ✅ 可以,但提示后台继续 | 用户有急事时需要关闭 |
| 生成中能否切换项目 | ❌ 不能 | 避免状态混乱 |
| 生成中能否修改脚本 | ❌ 不能 | 避免参数不一致 |
| 失败后能否重试 | ✅ 可以,只重试失败的 | 减少重复等待 |
| 是否需要系统通知 | ✅ 需要(第二阶段) | 用户最小化后能感知完成 |