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

- 后端: 空镜素材迁移到 config/materials.json,duration从文件名_{N}s_自动解析
- 后端: 新增 POST /api/v1/materials/match 接口,后端做关键词匹配
- 前端: VideoGeneration 空镜匹配改为调用后端接口
- 前端: 人物出镜素材改为本地文件选择器直接选取,不走素材库
- 前端: 视频生成流程简化,移除Vidu对口型和七牛云上传
- Rust: 视频合成支持从随机起始时间截取人物素材片段
- Rust: 修复ffprobe参数错误(添加-show_entries format=duration)
This commit is contained in:
小鱼开发
2026-04-22 18:49:20 +08:00
parent 5154af777c
commit 4e06f4abe2
46 changed files with 6785 additions and 1396 deletions
+667
View File
@@ -0,0 +1,667 @@
# 封面制作功能实施方案(Fabric.js 版)
## 一、兼容性说明
Fabric.js 与现有架构**零冲突**
- 纯前端库,无需修改 Rust/后端
- React 组件内直接使用,无兼容问题
- 字体复用项目已有的 `DouyinSans`(嵌入字体)
- PNG 导出通过 Fabric.js `toDataURL()` 直接生成
---
## 二、技术方案
### 方案:Fabric.js Canvas 预览 + PNG 导出
| 维度 | Fabric.js 方案 |
|------|----------------|
| 预览 | Fabric Canvas 实时渲染,所见即所得 |
| 导出 | `canvas.toDataURL('image/png')` → Rust 保存 |
| 文字控制 | 固定位置,禁止拖拽(`selectable: false` |
| 模板切换 | 重建 Fabric 对象,换文字样式/位置 |
| 背景图 | `fabric.Image.fromURL()` 加载,居中裁剪填充 |
---
## 三、详细设计
### 3.1 安装依赖
```bash
cd tauri-app
npm install fabric@6
```
### 3.2 两种固定模板
#### 模板1:双标题居中
```
┌────────────────────────┐
│ │
│ 「主标题文案」 │ ← 72px,金色 #FFD700,粗体,y=35%
│ │
│ 「副标题文案」 │ ← 32px,白色 #FFFFFFy=60%
│ │
│ │
└────────────────────────┘
```
**Fabric 对象结构**
```typescript
[
fabric.Image(bgImage), // 背景图,锁定
fabric.Text(mainTitle, { // 主标题
fontSize: 72,
fill: '#FFD700',
fontWeight: 'bold',
textAlign: 'center',
originX: 'center',
originY: 'center',
left: 540, top: 672, // 1080*0.35*1920/1080... 实际按坐标
selectable: false,
shadow: new fabric.Shadow({...}) // 黑色描边
}),
fabric.Text(subtitle, { // 副标题
fontSize: 32,
fill: '#FFFFFF',
textAlign: 'center',
originX: 'center',
originY: 'center',
left: 540, top: 1152,
selectable: false,
shadow: new fabric.Shadow({...})
})
]
```
#### 模板2:标题 + 标签列表
```
┌────────────────────────┐
│ │
│ 「主标题文案」 │ ← 72px,金色 #FFD700y=30%
│ │
│ │
│ 标签1 标签2 标签3 │ ← 28px,白色,带圆角背景,y=72%
│ 标签4 标签5 │
│ │
└────────────────────────┘
```
**Fabric 对象结构**
```typescript
[
fabric.Image(bgImage), // 背景图
fabric.Text(mainTitle, {...}), // 主标题
fabric.Group([ // 标签组(多个 fabric.Rect + fabric.Text
fabric.Rect({fill: 'rgba(0,0,0,0.5)', rx: 8, ry: 8}),
fabric.Text('标签1', {fill: '#FFFFFF', fontSize: 28}),
...
])
]
```
### 3.3 界面布局
```
┌─────────────────────────────────────────────────────────────┐
│ 封面制作 │
├───────────────────────────────┬─────────────────────────────┤
│ │ │
│ [模板选择] │ │
│ ┌────┐ ┌────┐ │ ┌─────────────┐ │
│ │模板1│ │模板2│ │ │ │ │
│ │缩略图│ │缩略图│ │ │ Fabric │ │
│ └────┘ └────┘ │ │ Canvas │ │
│ │ │ 预览 │ │
│ [背景图] │ │ │ │
│ ┌─────────┐ [选择图片] │ └─────────────┘ │
│ │ 缩略图 │ [清除] │ │
│ └─────────┘ │ │
│ │ │
│ [主标题] │ │
│ ┌───────────────────────┐ │ │
│ │ 输入主标题文案... │ │ │
│ └───────────────────────┘ │ │
│ │ │
│ [副标题/标签] │ │
│ ┌───────────────────────┐ │ │
│ │ 输入副标题或标签... │ │ │
│ └───────────────────────┘ │ │
│ │ │
│ [生成封面图] │ │
│ │ │
└───────────────────────────────┴─────────────────────────────┘
```
---
## 四、核心数据结构
```typescript
// 模板类型
type CoverTemplate = 'dual-title' | 'title-tags';
// 封面配置(保存到 store
interface CoverConfig {
template: CoverTemplate;
backgroundImage: string | null; // 本地图片路径
mainTitle: string;
subtitle: string; // 模板1=副标题,模板2=逗号分隔的标签
}
// Fabric.js 模板定义
interface FabricTemplateDef {
name: CoverTemplate;
width: number; // 1080
height: number; // 1920
mainTitle: {
fontSize: number;
fill: string;
top: number; // y坐标
fontWeight: string;
shadow?: fabric.Shadow;
};
subtitle: {
fontSize: number;
fill: string;
top: number;
};
}
```
---
## 五、核心 Hook 设计
### 5.1 useCoverFabric Hook
**新文件**`src/hooks/useCoverFabric.ts`
```typescript
import { useRef, useCallback, useEffect } from 'react';
import * as fabric from 'fabric';
const CANVAS_WIDTH = 1080;
const CANVAS_HEIGHT = 1920;
export function useCoverFabric() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const fabricCanvasRef = useRef<fabric.Canvas | null>(null);
// 初始化 Fabric Canvas
const initCanvas = useCallback(() => {
if (!canvasRef.current || fabricCanvasRef.current) return;
const canvas = new fabric.Canvas(canvasRef.current, {
width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
backgroundColor: '#1a1a2e',
selection: false, // 禁用框选
interactive: false, // 禁用所有交互(只读预览)
});
fabricCanvasRef.current = canvas;
}, []);
// 渲染封面
const renderCover = useCallback(async (
config: CoverConfig,
previewScale: number = 1
) => {
const canvas = fabricCanvasRef.current;
if (!canvas) return;
canvas.clear();
// 1. 绘制背景图
if (config.backgroundImage) {
await loadBackgroundImage(canvas, config.backgroundImage);
}
// 2. 根据模板添加文字
const template = TEMPLATES[config.template];
// 主标题
const mainText = new fabric.Text(config.mainTitle, {
left: CANVAS_WIDTH / 2,
top: template.mainTitle.top,
fontSize: template.mainTitle.fontSize,
fill: template.mainTitle.fill,
fontWeight: template.mainTitle.fontWeight,
fontFamily: '"DouyinSans", "PingFang SC", sans-serif',
textAlign: 'center',
originX: 'center',
originY: 'center',
selectable: false,
shadow: new fabric.Shadow({
color: 'rgba(0,0,0,0.8)',
blur: 4,
offsetX: 2,
offsetY: 2,
}),
});
canvas.add(mainText);
// 副标题/标签
if (config.template === 'dual-title') {
const subText = new fabric.Text(config.subtitle, {
left: CANVAS_WIDTH / 2,
top: template.subtitle.top,
fontSize: template.subtitle.fontSize,
fill: template.subtitle.fill,
fontFamily: '"DouyinSans", "PingFang SC", sans-serif',
textAlign: 'center',
originX: 'center',
originY: 'center',
selectable: false,
shadow: new fabric.Shadow({
color: 'rgba(0,0,0,0.6)',
blur: 3,
offsetX: 1,
offsetY: 1,
}),
});
canvas.add(subText);
} else {
// 标签列表:解析逗号分隔,自动换行布局
renderTagList(canvas, config.subtitle, template);
}
canvas.renderAll();
}, []);
// 导出 PNG
const exportPng = useCallback((): string => {
const canvas = fabricCanvasRef.current;
if (!canvas) return '';
return canvas.toDataURL({ format: 'png', quality: 1.0 });
}, []);
// 销毁
const destroy = useCallback(() => {
fabricCanvasRef.current?.dispose();
fabricCanvasRef.current = null;
}, []);
return { canvasRef, initCanvas, renderCover, exportPng, destroy };
}
// 加载背景图(居中裁剪填充)
async function loadBackgroundImage(
canvas: fabric.Canvas,
imagePath: string
): Promise<void> {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const fabricImg = new fabric.Image(img);
// 计算缩放和裁剪(cover 模式)
const scale = Math.max(
CANVAS_WIDTH / fabricImg.width!,
CANVAS_HEIGHT / fabricImg.height!
);
fabricImg.scale(scale);
fabricImg.set({
left: (CANVAS_WIDTH - fabricImg.getScaledWidth()) / 2,
top: (CANVAS_HEIGHT - fabricImg.getScaledHeight()) / 2,
selectable: false,
evented: false,
});
canvas.add(fabricImg);
canvas.sendObjectToBack(fabricImg);
resolve();
};
img.src = `asset://localhost/${encodeURIComponent(imagePath)}`;
});
}
// 渲染标签列表
function renderTagList(
canvas: fabric.Canvas,
tagsText: string,
template: FabricTemplateDef
) {
const tags = tagsText.split(/[,]/).map(t => t.trim()).filter(Boolean);
if (tags.length === 0) return;
const tagHeight = 56;
const tagPadding = 20;
const gapX = 16;
const gapY = 16;
const maxLineWidth = CANVAS_WIDTH - 120; // 左右各60px边距
const groups: fabric.Group[] = [];
let currentX = 60;
let currentY = template.subtitle.top;
for (const tag of tags) {
const text = new fabric.Text(tag, {
fontSize: 28,
fill: '#FFFFFF',
fontFamily: '"DouyinSans", "PingFang SC", sans-serif',
left: tagPadding,
top: tagHeight / 2,
originX: 'center',
originY: 'center',
});
const rect = new fabric.Rect({
width: text.width! + tagPadding * 2,
height: tagHeight,
fill: 'rgba(0,0,0,0.5)',
rx: 8,
ry: 8,
});
const group = new fabric.Group([rect, text], {
left: currentX,
top: currentY,
selectable: false,
evented: false,
});
// 换行检查
if (currentX + group.width! > CANVAS_WIDTH - 60 && currentX > 60) {
currentX = 60;
currentY += tagHeight + gapY;
group.set({ left: currentX, top: currentY });
}
currentX += group.width! + gapX;
groups.push(group);
}
// 整体居中
const allTags = new fabric.Group(groups, {
left: CANVAS_WIDTH / 2,
top: template.subtitle.top,
originX: 'center',
originY: 'top',
selectable: false,
evented: false,
});
canvas.add(allTags);
}
// 模板定义
const TEMPLATES: Record<CoverTemplate, FabricTemplateDef> = {
'dual-title': {
name: 'dual-title',
width: 1080,
height: 1920,
mainTitle: { fontSize: 72, fill: '#FFD700', top: 672, fontWeight: 'bold' },
subtitle: { fontSize: 32, fill: '#FFFFFF', top: 1152 },
},
'title-tags': {
name: 'title-tags',
width: 1080,
height: 1920,
mainTitle: { fontSize: 72, fill: '#FFD700', top: 576, fontWeight: 'bold' },
subtitle: { fontSize: 28, fill: '#FFFFFF', top: 1382 },
},
};
```
---
## 六、文件改动清单
| 文件 | 类型 | 说明 |
|------|------|------|
| `package.json` | 修改 | 添加 `fabric@6` 依赖 |
| `CoverDesign.tsx` | 重构 | 使用 Fabric.js 替换现有实现 |
| `CoverDesign.css` | 调整 | 适配新布局,预览区固定尺寸 |
| `src/hooks/useCoverFabric.ts` | **新增** | Fabric.js 渲染 Hook |
| `src/store/projectStore.ts` | 调整 | 更新 CoverConfig 类型 |
---
## 七、CoverDesign.tsx 关键代码
```typescript
import { open } from '@tauri-apps/plugin-dialog';
import { useCoverFabric } from '../../hooks/useCoverFabric';
export default function CoverDesign() {
const { canvasRef, initCanvas, renderCover, exportPng, destroy } = useCoverFabric();
const [config, setConfig] = useState<CoverConfig>({
template: 'dual-title',
backgroundImage: null,
mainTitle: '',
subtitle: '',
});
// 初始化 Fabric Canvas
useEffect(() => {
initCanvas();
return destroy;
}, [initCanvas, destroy]);
// 配置变化时重新渲染
useEffect(() => {
renderCover(config);
}, [config, renderCover]);
// 选择图片
const handleSelectImage = async () => {
const selected = await open({
multiple: false,
filters: [{ name: '图片', extensions: ['jpg', 'jpeg', 'png', 'webp'] }]
});
if (selected) {
setConfig(prev => ({ ...prev, backgroundImage: selected as string }));
}
};
// 生成封面
const handleGenerate = async () => {
const dataUrl = exportPng();
const base64 = dataUrl.split(',')[1];
const result = await invoke('save_project_asset', {
projectId,
filename: `cover_${Date.now()}.png`,
base64Data: base64,
});
if (result.code === 200) {
setCoverPath(result.data);
setCoverConfig(config);
}
};
return (
<div className="step-layout cover-design">
{/* 左侧配置 */}
<div className="step-panel-left">
{/* 模板选择 */}
<div className="panel-section">
<label className="panel-label"></label>
<div className="template-selector">
<button className={config.template === 'dual-title' ? 'active' : ''}
onClick={() => setConfig(p => ({ ...p, template: 'dual-title' }))}>
</button>
<button className={config.template === 'title-tags' ? 'active' : ''}
onClick={() => setConfig(p => ({ ...p, template: 'title-tags' }))}>
+
</button>
</div>
</div>
{/* 背景图 */}
<div className="panel-section">
<label className="panel-label"></label>
<div className="bg-image-control">
{config.backgroundImage && (
<img src={`asset://localhost/${encodeURIComponent(config.backgroundImage)}`}
className="bg-preview-thumb" alt="背景" />
)}
<button className="btn btn-secondary" onClick={handleSelectImage}>
</button>
{config.backgroundImage && (
<button className="btn btn-ghost" onClick={() => setConfig(p => ({ ...p, backgroundImage: null }))}>
</button>
)}
</div>
</div>
{/* 主标题 */}
<div className="panel-section">
<label className="panel-label"></label>
<textarea
className="input"
rows={2}
placeholder="输入主标题文案"
value={config.mainTitle}
onChange={e => setConfig(p => ({ ...p, mainTitle: e.target.value }))}
/>
</div>
{/* 副标题/标签 */}
<div className="panel-section">
<label className="panel-label">
{config.template === 'dual-title' ? '副标题' : '标签列表(用逗号分隔)'}
</label>
<textarea
className="input"
rows={2}
placeholder={config.template === 'dual-title' ? '输入副标题文案' : '标签1, 标签2, 标签3'}
value={config.subtitle}
onChange={e => setConfig(p => ({ ...p, subtitle: e.target.value }))}
/>
</div>
{/* 生成按钮 */}
<button className="btn btn-primary cover-generate-btn"
disabled={!config.mainTitle.trim()}
onClick={handleGenerate}>
</button>
</div>
{/* 右侧预览 */}
<div className="step-panel-right">
<div className="cover-preview-wrapper">
<canvas ref={canvasRef} className="cover-fabric-canvas" />
</div>
</div>
</div>
);
}
```
---
## 八、CSS 调整
```css
/* 预览区固定尺寸,CSS 缩放 */
.cover-preview-wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.cover-fabric-canvas {
width: 270px; /* 1080 / 4 */
height: 480px; /* 1920 / 4 */
background: #1a1a2e;
border-radius: var(--radius-xl);
box-shadow: 0 8px 32px rgb(0 0 0 / 30%);
}
/* 模板选择 */
.template-selector {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-sm);
}
.template-selector button {
padding: var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-card);
cursor: pointer;
transition: all var(--transition-fast);
}
.template-selector button.active {
border-color: var(--primary);
background: var(--primary-light);
color: var(--primary);
}
/* 背景图缩略图 */
.bg-preview-thumb {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: var(--radius-md);
border: 1px solid var(--border-light);
}
.bg-image-control {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
```
---
## 九、Store 类型更新
```typescript
// src/store/projectStore.ts
interface CoverConfig {
template?: 'dual-title' | 'title-tags';
backgroundImage?: string | null;
mainTitle?: string;
subtitle?: string;
// 保留旧字段兼容
caption?: string;
coverStyle?: any;
selectedPreset?: string;
}
```
---
## 十、实施步骤
| 步骤 | 任务 | 预计时间 |
|------|------|----------|
| 1 | `npm install fabric@6` | 5min |
| 2 | 创建 `useCoverFabric.ts` Hook | 2h |
| 3 | 重构 `CoverDesign.tsx` | 2h |
| 4 | 调整 `CoverDesign.css` | 30min |
| 5 | 更新 Store 类型 | 15min |
| 6 | 测试模板切换、文字渲染、导出 | 1h |
| 7 | 字体加载和高清导出调优 | 30min |
**总计**:约 6.5 小时
---
## 十一、注意事项
1. **字体加载**Fabric.js 的 `fontFamily` 需等待字体加载完成,否则首次渲染可能用回退字体
- 解决:`document.fonts.ready.then(() => canvas.renderAll())`
2. **Tauri asset 协议**:背景图路径用 `asset://localhost/${encodeURIComponent(path)}`
3. **Canvas 缩放**Fabric Canvas 保持 1080×1920,外层 CSS 缩放到 270×480,保证高清导出
4. **标签换行**:模板2的标签自动计算宽度,超出一行时换行,整体垂直居中
5. **图片跨域**:Tauri 本地文件无跨域问题,但需设置 `img.crossOrigin = 'anonymous'`
+350
View File
@@ -0,0 +1,350 @@
# 封面制作功能实施方案
## 一、需求概述
用户需求:
1. **背景图**:支持选择本地图片(弹窗选取),替换现有"视频首帧"方案
2. **标题样式**:主标题 + 副标题,位置和样式固定
3. **实时预览**:输入文案后立即预览效果
---
## 二、技术方案
### 方案选择:Canvas 实时预览 + PNG 导出
| 方案 | 优点 | 缺点 |
|------|------|------|
| ASS压制 | 字体渲染专业 | 预览需assjs,渲染有差异 |
| **Canvas预览+导出PNG** | 所见即所得,无差异 | 需额外处理字体 |
| Fabric.js | API丰富 | ~200KB包体积 |
**推荐方案:纯 Canvas 方案**
- 前端使用 Canvas API 绘制封面
- 预览和导出同一套代码,保证一致性
- 导出 PNG 后通过 Rust 复制到本地存储(或直接上传七牛云)
---
## 三、详细设计
### 3.1 封面模板
提供 **2 种固定模板**
#### 模板1:双标题居中
```
┌────────────────────────┐
│ │
│ 主标题(大) │ ← 72px,金色(#FFD700),粗体,y=30%
│ │
│ 副标题(小) │ ← 32px,白色,y=60%
│ │
│ │
└────────────────────────┘
```
#### 模板2:标题+标签列表
```
┌────────────────────────┐
│ │
│ 主标题(大) │ ← 72px,金色(#FFD700),粗体,y=25%
│ │
│ 标签1 标签2 标签3 │ ← 28px,白色,y=75%
│ 标签4 标签5 │
│ │
└────────────────────────┘
```
### 3.2 界面设计
```
┌─────────────────────────────────────────────────────────────┐
│ 封面制作 │
├───────────────────────────────┬─────────────────────────────┤
│ │ │
│ [模板选择] │ │
│ ○ 模板1: 双标题居中 │ ┌─────────────┐ │
│ ○ 模板2: 标题+标签列表 │ │ │ │
│ │ │ 预览区 │ │
│ [背景图] │ │ (Canvas) │ │
│ ┌─────────┐ [选择图片] │ │ │ │
│ │ 缩略图 │ [清除] │ └─────────────┘ │
│ └─────────┘ │ │
│ │ │
│ [主标题] │ │
│ ┌───────────────────────┐ │ │
│ │ 输入主标题文案... │ │ │
│ └───────────────────────┘ │ │
│ │ │
│ [副标题/标签] │ │
│ ┌───────────────────────┐ │ │
│ │ 输入副标题或标签... │ │ │
│ └───────────────────────┘ │ │
│ │ │
│ [生成封面图] │ │
│ │ │
└───────────────────────────────┴─────────────────────────────┘
```
### 3.3 数据结构
```typescript
interface CoverDesignConfig {
template: 'dual-title' | 'title-with-tags'; // 模板类型
background: string | null; // 背景图本地路径
mainTitle: {
text: string;
fontSize: number; // 固定72px
color: string; // 固定金色 #FFD700
fontWeight: 'bold';
position: { y: number }; // y轴百分比
};
subtitle: {
text: string;
fontSize: number; // 模板1=32px, 模板2=28px
color: string; // 固定白色 #FFFFFF
position: { y: number }; // y轴百分比
};
}
```
### 3.4 封面尺寸
- **导出尺寸**1080 × 1920 px9:16竖屏)
- **预览尺寸**270 × 480 pxCSS缩放)
- **背景适配**:使用 `object-fit: cover` 填充,超出部分裁剪
---
## 四、实现步骤
### Step 1: 添加图片选择功能
**文件**`CoverDesign.tsx`
```typescript
import { open } from '@tauri-apps/plugin-dialog';
const handleSelectImage = async () => {
const selected = await open({
multiple: false,
filters: [{ name: '图片', extensions: ['jpg', 'jpeg', 'png', 'webp'] }]
});
if (selected) {
setBackgroundImage(selected as string);
}
};
```
### Step 2: 创建 Canvas 渲染 Hook
**新文件**`src/hooks/useCoverCanvas.ts`
```typescript
export function useCoverCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawCover = useCallback((
ctx: CanvasRenderingContext2D,
config: CoverDesignConfig,
width: number,
height: number
) => {
// 1. 绘制背景
// 2. 绘制主标题
// 3. 绘制副标题
}, []);
const exportAsPng = useCallback(() => {
// 导出 1080x1920 PNG
}, []);
return { canvasRef, drawCover, exportAsPng };
}
```
### Step 3: 重构 CoverDesign 组件
**主要改动**
| 改动点 | 说明 |
|--------|------|
| 移除 `useLocalVideo` | 不再依赖视频首帧 |
| 新增背景图状态 | `backgroundImage: string \| null` |
| 替换预览区 | `<canvas>` 替代 `<video>` + CSS overlay |
| 简化样式预设 | 移除字体大小滑块,改用固定模板 |
| 简化生成逻辑 | Canvas 导出 PNG |
### Step 4: 修改生成导出逻辑
```typescript
const handleGenerate = async () => {
// 1. 获取 Canvas 导出的 PNG (Base64)
const pngDataUrl = canvasRef.current.toDataURL('image/png');
// 2. 转换为 Base64 字符串
const base64 = pngDataUrl.split(',')[1];
// 3. 通过 Rust 命令保存
const result = await invoke('save_project_asset', {
projectId,
filename: `cover_${Date.now()}.png`,
base64Data: base64,
});
};
```
### Step 5: 更新 Store 接口
**文件**`src/store/projectStore.ts`
```typescript
interface CoverConfig {
template?: 'dual-title' | 'title-with-tags';
caption?: string;
subtitle?: string;
coverStyle?: any;
selectedPreset?: string;
backgroundImage?: string | null; // 新增
}
```
---
## 五、文件改动清单
| 文件 | 改动类型 | 说明 |
|------|----------|------|
| `CoverDesign.tsx` | 重构 | 替换为 Canvas 预览方案 |
| `CoverDesign.css` | 调整 | 适配新的布局 |
| `src/hooks/useCoverCanvas.ts` | 新增 | Canvas 渲染 hook |
| `src/store/projectStore.ts` | 调整 | 添加 backgroundImage 字段 |
---
## 六、模板样式定义
### 模板1:双标题居中
```typescript
const TEMPLATE_DUAL_TITLE = {
mainTitle: {
fontSize: 72,
color: '#FFD700',
fontWeight: 'bold',
positionY: 0.30, // 30%
},
subtitle: {
fontSize: 32,
color: '#FFFFFF',
positionY: 0.55,
},
};
```
### 模板2:标题+标签列表
```typescript
const TEMPLATE_TITLE_TAGS = {
mainTitle: {
fontSize: 72,
color: '#FFD700',
fontWeight: 'bold',
positionY: 0.25,
},
subtitle: {
fontSize: 28,
color: '#FFFFFF',
positionY: 0.70,
// 标签自动换行,每行最多显示5个
},
};
```
---
## 七、Canvas 绘制细节
### 7.1 文字渲染
```typescript
ctx.font = `${fontWeight} ${fontSize}px "DouyinSans", "PingFang SC", sans-serif`;
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
```
### 7.2 描边效果
```typescript
ctx.strokeStyle = '#000000';
ctx.lineWidth = strokeWidth;
ctx.strokeText(text, x, y);
```
### 7.3 文字换行(副标题/标签)
```typescript
function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
const words = text.split('');
const lines: string[] = [];
let currentLine = '';
for (const char of words) {
const testLine = currentLine + char;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine) {
lines.push(currentLine);
currentLine = char;
} else {
currentLine = testLine;
}
}
if (currentLine) lines.push(currentLine);
return lines;
}
```
### 7.4 背景图绘制
```typescript
const img = new Image();
img.onload = () => {
// 计算裁剪区域(居中裁剪)
const scale = Math.max(width / img.width, height / img.height);
const scaledWidth = img.width * scale;
const scaledHeight = img.height * scale;
const dx = (width - scaledWidth) / 2;
const dy = (height - scaledHeight) / 2;
ctx.drawImage(img, dx, dy, scaledWidth, scaledHeight);
};
img.src = `asset://localhost/${encodeURIComponent(imagePath)}`;
```
---
## 八、进度安排
| 阶段 | 任务 | 预计时间 |
|------|------|----------|
| Phase 1 | 添加图片选择功能,基础布局调整 | 1h |
| Phase 2 | 实现 Canvas 渲染 Hook | 2h |
| Phase 3 | 重构预览区为 Canvas | 2h |
| Phase 4 | 实现模板切换和文字渲染 | 2h |
| Phase 5 | 导出 PNG 并保存 | 1h |
| Phase 6 | 测试和样式调优 | 1h |
**总计**:约 9 小时
---
## 九、注意事项
1. **字体加载**:确保字体文件已嵌入,Canvas 需等待字体加载完成后再绘制
2. **跨域问题**Tauri 的 `asset://` 协议需要正确配置
3. **高清导出**:导出时使用 `toBlob('image/png', 1.0)` 保证最高质量
4. **性能优化**:防抖处理输入,避免频繁重绘
5. **移动端适配**:预览区使用 CSS `transform: scale()` 缩放
+293
View File
@@ -0,0 +1,293 @@
# 空镜素材匹配实现方案 v2(已对齐实际素材)
> 状态:待审阅
---
## 一、需求概述
Step 3(视频生成)统一处理所有分镜:
- **segment(人物出镜)**:从本地素材库选择形象视频,裁剪拼接
- **empty_shot(空镜)**:从七牛云素材库匹配,裁剪拼接
- **统一合成**:FFmpeg 裁剪所有素材后按顺序拼接,混音输出,最终视频时长与音频匹配
---
## 二、素材体系
### 2.1 人物出镜素材(本地)
**存储位置**`~/Documents/Meijiaka-zj/avatar_materials/`
**索引**`~/Documents/Meijiaka-zj/avatar_materials_index.json`
```json
{
"materials": [
{
"id": "avt_001",
"name": "男主播正面",
"filename": "host_male.mp4",
"duration": 15.2,
"uploadedAt": "2026-04-22T10:00:00Z"
}
]
}
```
**时长获取**:上传后用 ffprobe 提取,存入索引。
### 2.2 空镜素材(七牛云)
**实际 URL 格式**
```
https://media.liche.cn/meijiaka-zj/material/{slug}/{slug}_{序号}_{时长}s.mp4
```
**示例**
- `https://media.liche.cn/meijiaka-zj/material/ceiling/ceiling_001_5s.mp4`
- `https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_153_4s.mp4`
- `https://media.liche.cn/meijiaka-zj/material/paint/paint_001_5s.mp4`
- `https://media.liche.cn/meijiaka-zj/material/final/final_001_7s.mp4`
**关键发现**:文件名自带时长(`_{N}s.mp4`),无需 ffprobe,直接解析。
**映射表**`tauri-app/src/constants/materialUrls.ts`
```typescript
export interface MaterialInfo {
url: string;
duration: number; // 从文件名解析,如 "5s" → 5
}
export const MATERIAL_URLS: Record<string, MaterialInfo[]> = {
"ceiling": [
{ url: "https://media.liche.cn/meijiaka-zj/material/ceiling/ceiling_001_5s.mp4", duration: 5 },
],
"plumbing": [
{ url: "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_153_4s.mp4", duration: 4 },
],
"paint": [
{ url: "https://media.liche.cn/meijiaka-zj/material/paint/paint_001_5s.mp4", duration: 5 },
],
"final": [
{ url: "https://media.liche.cn/meijiaka-zj/material/final/final_001_7s.mp4", duration: 7 },
],
};
export const KEYWORD_MAP: Record<string, string> = {
"吊顶": "ceiling",
"天花": "ceiling",
"水电": "plumbing",
"水管": "plumbing",
"油漆": "paint",
"涂料": "paint",
"刷漆": "paint",
"完工": "final",
"竣工": "final",
"交付": "final",
};
```
> 新增 slug/关键词随时补充。
---
## 三、匹配规则(含时长检查)
### 3.1 人物出镜素材选择
用户手动选择一个本地素材作为"当前形象"。
系统检查:
```
let maxSegmentDuration = max(所有 segment 分镜的 duration);
if 形象素材.duration < maxSegmentDuration:
警告:形象素材时长不足,最长 segment 需要 X 秒,当前素材只有 Y 秒
```
### 3.2 空镜素材匹配
```typescript
function matchMaterial(scene: string, requiredDuration: number): MaterialInfo | null {
for (const [keyword, slug] of Object.entries(KEYWORD_MAP)) {
if (scene.includes(keyword)) {
const candidates = MATERIAL_URLS[slug]?.filter(m => m.duration >= requiredDuration);
if (candidates && candidates.length > 0) {
return candidates[Math.floor(Math.random() * candidates.length)];
}
}
}
return null;
}
```
**匹配失败场景**
- 无关键词匹配 → "未找到对应素材"
- 素材时长不足 → "素材时长不足(需要 X 秒,素材只有 Y 秒)"
---
## 四、裁剪与合成
### 4.1 裁剪策略
| 场景 | 素材时长 | 分镜时长 | 处理方式 |
|------|---------|---------|---------|
| 等于 | 5s | 5s | 从头截取 0-5s |
| 大于 | 8s | 5s | 随机从 [0, 3s] 开始截取 5s |
| 小于 | 3s | 5s | **匹配阶段已过滤,不会出现** |
**FFmpeg 裁剪**
```bash
ffmpeg -ss {start} -t {duration} -i {input} -c:v libx264 -preset fast -an -y {output}
```
### 4.2 拼接与混音
```bash
# concat 列表文件
file 'clip_001.mp4'
file 'clip_002.mp4'
file 'clip_003.mp4'
# 拼接 + 混音
ffmpeg -f concat -safe 0 -i clips.txt -i audio.mp3 -c:v libx264 -c:a aac -shortest output.mp4
```
`-shortest`:以较短的一方为准(视频/音频)。
### 4.3 时长兜底
拼接前检查:
```
videoTotal = sum(所有分镜 duration)
audioTotal = 音频时长
if |videoTotal - audioTotal| >= 1秒:
提示用户:分镜总时长与音频不匹配,建议调整
else:
正常合成,-shortest 处理微小偏差
```
---
## 五、Step 3(视频生成)交互
### 5.1 页面结构
```
┌─────────────────────────────────────────┐
│ Step 3: 视频生成 │
│ │
│ ┌─ 人物形象选择 ──────────────────────┐ │
│ │ [上传形象视频] │ │
│ │ │ │
│ │ ● 男主播正面 (15.2s) ← 已选 │ │
│ │ │ │
│ │ ✓ 最长 segment 需 8s,素材满足 │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌─ 分镜素材匹配 ──────────────────────┐ │
│ │ │ │
│ │ 分镜 1:人物出镜 (3s) │ │
│ │ 形象素材:男主播正面 │ │
│ │ (15.2s,从 2.1s 截取 3s) │ │
│ │ │ │
│ │ 分镜 2:瓦工贴墙砖 (5s) │ │
│ │ [自动匹配] → ✓ ceiling_001 │ │
│ │ (5s,从头截取) │ │
│ │ │ │
│ │ 分镜 3:防水施工 (4s) │ │
│ │ [自动匹配] → ✗ 素材不足 │ │
│ │ (需要 4splumbing 只有 4s │ │
│ │ 如果刚好等于,也是满足的) │ │
│ │ [手动选择本地文件] │ │
│ │ │ │
│ │ 分镜 4:人物出镜 (3s) │ │
│ │ 形象素材:男主播正面 │ │
│ │ (15.2s,从 8.5s 截取 3s) │ │
│ └──────────────────────────────────────┘ │
│ │
│ [生成视频] │
│ │
│ 进度:裁剪中... 拼接中... 混音中... │
└─────────────────────────────────────────┘
```
### 5.2 状态流转
1. 进入 Step 3 → 自动为所有 empty_shot 执行 `matchMaterial`
2. 绿色:素材满足(duration >= 分镜时长)
3. 红色:素材不足或无匹配 → 可手动选择本地文件替代
4. 点击【生成视频】→ FFmpeg 批量裁剪 + 拼接 + 混音
---
## 六、数据模型
```typescript
interface Segment {
id: string;
type: "segment" | "empty_shot";
scene: string;
voiceover: string;
duration: number;
// 视频生成后填充
videoUrl?: string; // 空镜:七牛云 URLsegmentnull(用本地形象)
videoPath?: string; // 本地路径(形象素材或手动替代文件)
// 素材匹配信息(仅展示用)
materialInfo?: {
source: "auto" | "manual"; // 自动匹配 / 手动选择
url?: string; // 原始 URL
duration: number; // 素材总时长
clipStart: number; // 截取起始点
clipDuration: number; // 截取时长(= 分镜 duration
};
}
```
---
## 七、实施清单
### Phase 1:人物出镜素材库
| # | 文件 | 内容 |
|---|------|------|
| 1 | `src-tauri/src/commands/avatar_material.rs` | IPC:上传、列表、删除 |
| 2 | `src-tauri/src/storage/avatar_material.rs` | 索引读写、ffprobe 提取时长 |
| 3 | `src/api/modules/avatarMaterial.ts` | 前端 API 封装 |
| 4 | `VideoGeneration.tsx` | 形象上传 + 选择组件 |
### Phase 2:空镜素材匹配
| # | 文件 | 内容 |
|---|------|------|
| 5 | `src/constants/materialUrls.ts` | 映射表(已按实际 URL 格式) |
| 6 | `VideoGeneration.tsx` | 自动匹配逻辑 + 状态展示 |
### Phase 3:视频合成
| # | 文件 | 内容 |
|---|------|------|
| 7 | `src-tauri/src/ffmpeg_cmd.rs` | 新增:批量裁剪函数 |
| 8 | `src-tauri/src/video_processing.rs` | 新增:合成主流程(裁剪→拼接→混音) |
| 9 | `src-tauri/src/commands/video_generate.rs` | IPC:生成视频命令 |
| 10 | `VideoGeneration.tsx` | [生成视频] 按钮 + 进度 |
---
## 八、确认事项
- [ ] 人物出镜素材存在本地,支持上传/列表/删除
- [ ] 空镜素材走七牛云,URL 格式 `meijiaka-zj/material/{slug}/{slug}_{序号}_{时长}s.mp4`
- [ ] 时长从文件名解析,无需 ffprobe
- [ ] 匹配时过滤时长不足的素材
- [ ] 素材时长 > 分镜时长时,随机位置截取
- [ ] 合成用 FFmpeg concat + 音频混流
- [ ] 时长差值 >= 1 秒时提示用户
**确认后我开始写。**
+306
View File
@@ -0,0 +1,306 @@
# 视频生成实现方案 v3(Vidu 对口型版)
> 状态:待审阅
---
## 一、流程概述
| Step | 页面 | 产出 |
|------|------|------|
| 1 | ScriptCreation | 分镜列表(含 scene + duration |
| 2 | VoiceDubbing | 配音音频(已存七牛云) |
| **3** | **VideoGeneration** | **最终视频(Vidu 对口型后)** |
| 4 | SubtitleBurning | 字幕压制 |
| 5 | CoverDesign | 封面 |
| 6 | VideoComposite | 最终成品(如需要额外合成) |
---
## 二、Step 3 完整流程
```
┌─────────────────────────────────────────────────────────────┐
│ Step 3: 视频生成 │
│ │
│ 1. 选择形象素材(本地 avatar_materials
│ └─ 上传 / 选择已有素材 │
│ │
│ 2. 匹配空镜素材(七牛云) │
│ └─ 根据 scene 关键词匹配 │
│ └─ 检查时长 >= 分镜 duration │
│ │
│ 3. FFmpeg 裁剪 │
│ └─ segment:从形象素材中截取 duration 秒 │
│ └─ empty_shot:从空镜素材中截取 duration 秒 │
│ └─ 输出:clip_001.mp4, clip_002.mp4, ... │
│ │
│ 4. FFmpeg 拼接(移除音频) │
│ └─ concat 所有 clip → raw_video.mp4(无声) │
│ │
│ 5. 上传临时视频 │
│ └─ raw_video.mp4 → 七牛云临时 URL │
│ │
│ 6. Vidu 对口型 │
│ └─ POST /ent/v2/lip-sync │
│ { video_url: 临时URL, audio_url: Step2音频URL } │
│ └─ 返回 task_id │
│ │
│ 7. 轮询等待 │
│ └─ GET /ent/v2/tasks/{task_id}/creations │
│ └─ state = "success" 时获取 video_url │
│ │
│ 8. 下载最终视频 │
│ └─ 下载到本地 products/{project_id}/ │
│ │
│ 9. 上传永久保存 │
│ └─ 上传七牛云 videos/{project_id}/final.mp4 │
│ │
│ 10. 更新项目状态 │
│ └─ segment.videoPath = 本地路径 │
│ └─ segment.videoUrl = 七牛云永久 URL │
└─────────────────────────────────────────────────────────────┘
```
---
## 三、素材体系
### 3.1 人物出镜素材(本地)
**存储位置**`~/Documents/Meijiaka-zj/avatar_materials/`
**索引**`~/Documents/Meijiaka-zj/avatar_materials_index.json`
```json
{
"materials": [
{
"id": "avt_001",
"name": "男主播正面",
"filename": "host_male.mp4",
"duration": 15.2,
"uploadedAt": "2026-04-22T10:00:00Z"
}
]
}
```
**时长获取**:上传后用 ffprobe 提取。
### 3.2 空镜素材(七牛云)
**URL 格式**`https://media.liche.cn/meijiaka-zj/material/{slug}/{slug}_{序号}_{时长}s.mp4`
**映射表**`tauri-app/src/constants/materialUrls.ts`
```typescript
export interface MaterialInfo {
url: string;
duration: number;
}
export const MATERIAL_URLS: Record<string, MaterialInfo[]> = {
"ceiling": [
{ url: "https://media.liche.cn/meijiaka-zj/material/ceiling/ceiling_001_5s.mp4", duration: 5 },
],
"plumbing": [
{ url: "https://media.liche.cn/meijiaka-zj/material/plumbing/plumbing_153_4s.mp4", duration: 4 },
],
"paint": [
{ url: "https://media.liche.cn/meijiaka-zj/material/paint/paint_001_5s.mp4", duration: 5 },
],
"final": [
{ url: "https://media.liche.cn/meijiaka-zj/material/final/final_001_7s.mp4", duration: 7 },
],
};
export const KEYWORD_MAP: Record<string, string> = {
"吊顶": "ceiling",
"天花": "ceiling",
"水电": "plumbing",
"水管": "plumbing",
"油漆": "paint",
"涂料": "paint",
"刷漆": "paint",
"完工": "final",
"竣工": "final",
"交付": "final",
};
```
---
## 四、匹配规则(含时长检查)
### 4.1 人物出镜素材选择
用户手动选择一个本地素材作为"当前形象"。
系统检查:
```
let maxSegmentDuration = max(所有 segment 分镜的 duration);
if 形象素材.duration < maxSegmentDuration:
警告:形象素材时长不足
```
### 4.2 空镜素材匹配
```typescript
function matchMaterial(scene: string, requiredDuration: number): MaterialInfo | null {
for (const [keyword, slug] of Object.entries(KEYWORD_MAP)) {
if (scene.includes(keyword)) {
const candidates = MATERIAL_URLS[slug]?.filter(m => m.duration >= requiredDuration);
if (candidates && candidates.length > 0) {
return candidates[Math.floor(Math.random() * candidates.length)];
}
}
}
return null;
}
```
---
## 五、裁剪与拼接
### 5.1 裁剪
每个分镜从素材中截取 `duration` 秒:
| 场景 | 素材时长 | 截取方式 |
|------|---------|---------|
| 等于 | 5s | 从头截取 0-5s |
| 大于 | 8s | 随机从 [0, 3s] 开始截取 |
```bash
ffmpeg -ss {start} -t {duration} -i {input} -c:v libx264 -preset fast -an -y {output}
```
`-an` 确保输出无音频。
### 5.2 拼接(无声)
```bash
# clips.txt
file 'clip_001.mp4'
file 'clip_002.mp4'
...
# 拼接,无音频
ffmpeg -f concat -safe 0 -i clips.txt -c copy -an raw_video.mp4
```
### 5.3 上传临时视频
使用七牛云 SDK 上传 `raw_video.mp4`,获取临时访问 URL。
### 5.4 Vidu 对口型
```bash
POST https://api.vidu.cn/ent/v2/lip-sync
{
"video_url": "https://media.liche.cn/tmp/raw_video_xxx.mp4",
"audio_url": "https://media.liche.cn/audios/project_xxx.mp3"
}
```
**响应**`{ task_id: "xxx", state: "created" }`
**轮询**
```bash
GET https://api.vidu.cn/ent/v2/tasks/{task_id}/creations
```
**成功响应**
```json
{
"state": "success",
"creations": [{ "url": "https://.../final.mp4" }]
}
```
### 5.5 下载与保存
1. 下载 Vidu 返回的 `url` 到本地
2. 本地路径:`~/Documents/Meijiaka-zj/products/{project_id}/final.mp4`
3. 上传七牛云:`videos/{project_id}/final.mp4`
4. 更新项目数据
---
## 六、Step 3 页面交互
```
┌─────────────────────────────────────────┐
│ Step 3: 视频生成 │
│ │
│ ┌─ 人物形象 ──────────────────────────┐ │
│ │ [上传] 或 选择已有 │ │
│ │ ● 男主播正面 (15.2s) │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌─ 空镜匹配 ──────────────────────────┐ │
│ │ 分镜2:瓦工贴墙砖 (5s) │ │
│ │ ✓ ceiling_001 (5s) │ │
│ │ │ │
│ │ 分镜4:防水施工 (4s) │ │
│ │ ✗ 素材不足 (plumbing 只有 4s?) │ │
│ │ [手动选择本地文件] │ │
│ └──────────────────────────────────────┘ │
│ │
│ [生成视频] │
│ │
│ 进度: │
│ 裁剪中... │
│ 拼接中... │
│ 上传中... │
│ Vidu 处理中... (task_id: xxx) │
│ 下载中... │
│ 完成 ✓ │
└─────────────────────────────────────────┘
```
---
## 七、实施清单
### Phase 1:人物出镜素材库
| # | 文件 | 内容 |
|---|------|------|
| 1 | `src-tauri/src/commands/avatar_material.rs` | IPC:上传、列表、删除 |
| 2 | `src-tauri/src/storage/avatar_material.rs` | 索引读写、ffprobe 提取时长 |
| 3 | `src/api/modules/avatarMaterial.ts` | 前端 API 封装 |
| 4 | `VideoGeneration.tsx` | 形象上传 + 选择组件 |
### Phase 2:空镜素材匹配
| # | 文件 | 内容 |
|---|------|------|
| 5 | `src/constants/materialUrls.ts` | 映射表(按实际 URL 格式) |
| 6 | `VideoGeneration.tsx` | 自动匹配逻辑 |
### Phase 3Vidu 合成流水线
| # | 文件 | 内容 |
|---|------|------|
| 7 | `src-tauri/src/ffmpeg_cmd.rs` | 新增:批量裁剪 + 无声拼接 |
| 8 | `python-api/app/services/vidu_service.py` | Vidu API 封装(对口型 + 轮询) |
| 9 | `python-api/app/api/v1/vidu.py` | 后端路由:提交对口型、查询状态 |
| 10 | `src-tauri/src/commands/video_generate.rs` | IPC:生成视频主流程 |
| 11 | `VideoGeneration.tsx` | [生成视频] 按钮 + 进度显示 |
---
## 八、确认事项
- [ ] 人物出镜素材本地管理(上传/选择/时长检查)
- [ ] 空镜素材七牛云映射(关键词 → slug → URL)
- [ ] FFmpeg 裁剪 + 无声拼接
- [ ] 拼接后视频上传七牛云临时 URL
- [ ] Vidu 对口型 API(提交 + 轮询)
- [ ] 最终视频下载到本地 products/ + 上传七牛云永久保存
- [ ] Step 2 音频已在七牛云,直接取 URL
**确认后我开始写。**
+245
View File
@@ -0,0 +1,245 @@
# 空镜素材匹配实现方案
> 状态:待审阅
> 基于讨论结果整理
---
## 一、需求概述
在视频创作流程的 **Step 3(视频生成)** 中,为 `empty_shot`(空镜)分镜自动匹配七牛云上的空镜素材,合成时直接用 URL 拼接,无需下载到本地。
---
## 二、步骤映射(确认版)
| Step | 页面 | 内容 | 素材匹配相关 |
|------|------|------|-------------|
| 1 | ScriptCreation | 脚本生成 | AI 输出分镜(含 `scene` 字段) |
| 2 | VoiceDubbing | 音频合成 | 配音生成 |
| **3** | **VideoGeneration** | **视频生成** | **空镜素材匹配在这里** |
| 4 | SubtitleBurning | 字幕压制 | — |
| 5 | CoverDesign | 封面制作 | — |
| 6 | VideoComposite | 视频合成 | FFmpeg 直接用 URL 拼接 |
---
## 三、七牛云存储规范
- **Bucket**:复用现有 `media-liche`
- **Key 格式**`materials/{slug}/vid_{nnn}.mp4`
- **文件命名**:全英文,禁止中文
| slug | 场景 |
|------|------|
| `tiling` | 瓦工贴砖 |
| `waterproofing` | 防水施工 |
| `plumbing` | 水电施工 |
| `putty` | 腻子打磨 |
| `living_room` | 客厅全景 |
| `kitchen` | 厨房 |
| `bedroom` | 卧室 |
| `bathroom` | 卫生间 |
| `ceiling` | 吊顶 |
| `flooring` | 地板铺设 |
| `cabinet` | 橱柜安装 |
| `demolition` | 拆旧 |
| `masonry` | 砌墙 |
| `door_window` | 门窗安装 |
> 新增 slug 随时补充。
---
## 四、映射表设计
### 4.1 文件位置
`tauri-app/src/constants/materialUrls.ts`
### 4.2 数据结构
```typescript
// slug → URL 列表
export const MATERIAL_URLS: Record<string, string[]> = {
"tiling": [
"https://media.liche.cn/materials/tiling/vid_001.mp4",
"https://media.liche.cn/materials/tiling/vid_002.mp4",
],
"waterproofing": [
"https://media.liche.cn/materials/waterproofing/vid_001.mp4",
],
// ...
};
// 中文关键词 → slug 映射
export const KEYWORD_MAP: Record<string, string> = {
"贴砖": "tiling",
"瓦工": "tiling",
"瓷砖": "tiling",
"防水": "waterproofing",
"水电": "plumbing",
"腻子": "putty",
"客厅": "living_room",
"厨房": "kitchen",
"卧室": "bedroom",
"卫生间": "bathroom",
"吊顶": "ceiling",
"地板": "flooring",
"橱柜": "cabinet",
"拆旧": "demolition",
"砌墙": "masonry",
"门窗": "door_window",
};
```
### 4.3 匹配函数
```typescript
export function matchMaterial(scene: string): string | null {
for (const [keyword, slug] of Object.entries(KEYWORD_MAP)) {
if (scene.includes(keyword)) {
const urls = MATERIAL_URLS[slug];
if (urls && urls.length > 0) {
return urls[Math.floor(Math.random() * urls.length)];
}
}
}
return null;
}
```
- **匹配策略**:关键词包含(`scene.includes(keyword)`
- **多素材**:同一 slug 下有多个 URL 时随机选一个
- **无匹配**:返回 `null`
---
## 五、数据流
```
Step 1 脚本生成
↓ 产出分镜列表(含 scene 字段)
Step 2 音频合成
Step 3 视频生成
├─ segment 分镜(人物出镜)
│ └─ 现有逻辑:选择形象 → 生成数字人视频
└─ empty_shot 分镜(空镜)
└─ [匹配素材] 按钮
↓ 调用 matchMaterial(scene)
↓ 返回 URL → 写入 segment.videoUrl
Step 6 视频合成
↓ FFmpeg 读取 segment.videoUrl(支持 http/https
↓ 拼接输出成品
```
---
## 六、Step 3 前端交互
`VideoGeneration.tsx``empty_shot` 分镜卡片中增加:
```
┌─────────────────────────────────┐
│ 空镜:瓦工贴墙砖、贴地砖 │
│ │
│ [匹配空镜素材] │
│ ↓ │
│ ✓ 已匹配:vid_001.mp4 │
│ [▶ 预览] [重新匹配] │
│ │
│ (匹配失败时) │
│ ✗ 未匹配到素材 │
│ [手动输入 URL] │
└─────────────────────────────────┘
```
### 状态流转
| 状态 | 显示 | 操作 |
|------|------|------|
| 初始 | `[匹配空镜素材]` 按钮 | 点击执行匹配 |
| 匹配成功 | 显示文件名 + 预览按钮 + 重新匹配 | 点击预览播放 |
| 匹配失败 | `未匹配到素材` + 手动输入 | 可手动填 URL |
### 写入字段
匹配成功后写入 `segment.videoUrl`
```typescript
// 类型扩展
interface Segment {
id: string;
type: "segment" | "empty_shot";
scene: string;
voiceover: string;
duration: number;
videoUrl?: string; // 【新增】素材 URL 或数字人视频 URL
videoPath?: string; // 本地路径(数字人视频用)
}
```
---
## 七、视频合成适配
`VideoComposite.tsx` / Rust 层 FFmpeg 合成时:
```rust
// 输入来源判断
for segment in segments {
let input = if segment.type == "segment" {
// 人物出镜:用本地数字人视频路径
segment.video_path.clone()
} else if let Some(url) = &segment.video_url {
// 空镜:直接用七牛云 URLFFmpeg 支持 http 输入)
url.clone()
} else {
// 无素材:黑屏兜底(生成纯色视频)
generate_black_screen(segment.duration)
};
inputs.push(input);
}
```
---
## 八、更新流程
素材新增/变更时:
1. 你上传素材到七牛云 `materials/{slug}/`
2. 把 slug 和完整 URL 列表发给我
3. 我更新 `materialUrls.ts` 中的 `MATERIAL_URLS`
4. 如有新增关键词,同步更新 `KEYWORD_MAP`
5. 提交代码,发版后生效
> 不上线索索管理后台、不建数据库、不做动态配置。MVP 阶段手动维护映射表。
---
## 九、实施清单
| # | 文件 | 动作 | 内容 |
|---|------|------|------|
| 1 | `tauri-app/src/constants/materialUrls.ts` | 新建 | 映射表 + 匹配函数 |
| 2 | `tauri-app/src/api/types.ts` | 修改 | `Segment` 类型加 `videoUrl` 字段 |
| 3 | `tauri-app/src/pages/VideoCreation/VideoGeneration.tsx` | 修改 | empty_shot 卡片加匹配按钮和状态 |
| 4 | `tauri-app/src/store/projectStore.ts` | 修改 | `saveSegmentsToLocalFile` 支持 `videoUrl` 持久化 |
| 5 | `tauri-app/src-tauri/src/video_processing.rs` | 修改 | FFmpeg 合成支持 http URL 输入 |
---
## 十、确认事项
请审阅以上方案,确认或提出修改:
- [ ] 七牛云路径规范 `materials/{slug}/vid_{nnn}.mp4`
- [ ] 前端映射表文件位置 `src/constants/materialUrls.ts`
- [ ] 关键词包含匹配策略
- [ ] Step 3 交互设计(按钮位置、状态显示)
- [ ] 匹配失败时支持手动输入 URL
- [ ] FFmpeg 直接用 URL 合成(不下载本地)
- [ ] 素材更新方式:你发给我 → 我改代码
**全部确认后,我开始按清单写代码。**
+443
View File
@@ -0,0 +1,443 @@
# 空镜素材智能匹配方案
> 文档版本:v1.0
> 创建时间:2026-04-22
> 状态:待实现
## 一、需求背景
### 1.1 业务场景
在视频创作流程中,脚本生成阶段会产生分镜脚本,每个分镜包含:
- **画面描述**:AI 生成的口播场景描述(如 "厨房台面特写"
- **配音文字**:该分镜的口播文案
当前痛点:画面描述只是文字,没有对应的空镜素材,每个分镜只能配音,无法展示实际画面。
### 1.2 解决思路
建立**空镜素材库**,AI 生成脚本时同时输出**标签**,根据标签从素材库匹配对应视频素材,实现:
- 口播 + 空镜画面 的完整分镜效果
- 自动替换/拼接空镜素材,减少人工选材工作量
---
## 二、功能需求
### 2.1 标签体系
| 层级 | 示例 | 说明 |
|------|------|------|
| 父标签 | `室内` | 大场景分类 |
| 子标签 | `客厅``厨房``卫生间``卧室``书房` | 具体房间 |
| 扩展标签 | `台面特写``整体空间``角落细节` | 拍摄角度/风格 |
**标签格式**`父标签/子标签``父标签/子标签/扩展标签`
**示例**
```
室内/客厅
室内/厨房/开放式
室内/卫生间/干湿分离
室外/阳台
```
### 2.2 素材管理
| 需求 | 描述 |
|------|------|
| 素材上传 | 运营人员上传空镜视频到七牛云 |
| 标签标注 | 上传时指定素材的标签(支持多标签) |
| 素材索引 | 本地维护标签 → 素材文件 的映射关系 |
| 素材检索 | 根据标签快速查找匹配素材 |
### 2.3 脚本生成增强
| 需求 | 描述 |
|------|------|
| 标签输出 | AI 生成脚本时,在画面描述后输出对应标签 |
| 标签格式 | 统一使用 `【标签】室内/客厅` 格式 |
| 兼容旧格式 | 已有脚本无需修改,缺失标签的分镜跳过素材匹配 |
**示例输出**
```json
{
"segments": [
{
"id": "seg_001",
"scene": "开场:展示整洁的厨房整体空间",
"voiceover": "大家好,今天我们来聊聊厨房装修的注意事项...",
"duration": 15,
"tag": "室内/厨房/整体空间"
},
{
"id": "seg_002",
"scene": "特写:台面材质细节",
"voiceover": "首先,台面的材质选择非常重要...",
"duration": 12,
"tag": "室内/厨房/台面特写"
}
]
}
```
### 2.4 素材匹配流程
| 步骤 | 操作 | 说明 |
|------|------|------|
| 1 | 解析标签 | 从分镜数据中提取 `tag` 字段 |
| 2 | 查询索引 | 在本地索引中查找对应标签的素材列表 |
| 3 | 随机选择 | 从匹配素材中随机选择一个 |
| 4 | 获取 URL | 拼接七牛云访问 URL |
| 5 | 下载素材 | 下载到本地 `shots/` 目录 |
| 6 | 记录关联 | 将 `videoPath` 写入分镜数据 |
### 2.5 素材路径设计
**七牛云存储结构**(使用英文路径):
```
materials/
├── indoor/
│ ├── living_room/
│ │ ├── vid_001.mp4
│ │ ├── vid_002.mp4
│ │ └── ...
│ ├── kitchen/
│ │ ├── open/
│ │ │ ├── vid_010.mp4
│ │ │ └── ...
│ │ └── closed/
│ └── bathroom/
├── outdoor/
│ ├── balcony/
│ └── garden/
└── ...
```
**本地索引结构**`materials_index.json`):
```json
{
"version": "1.0",
"lastUpdated": "2026-04-22T10:00:00Z",
"tags": {
"室内/客厅": {
"displayName": "室内/客厅",
"qiniuDir": "materials/indoor/living_room",
"videos": [
{
"id": "vid_001",
"filename": "vid_001.mp4",
"qiniuKey": "materials/indoor/living_room/vid_001.mp4",
"duration": 8.5,
"size": 1024000,
"uploadedAt": "2026-04-20T08:30:00Z"
}
]
},
"室内/厨房/开放式": {
"displayName": "室内/厨房/开放式",
"qiniuDir": "materials/indoor/kitchen/open",
"videos": [...]
}
}
}
```
---
## 三、技术方案
### 3.1 架构概览
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ 脚本生成 │────▶│ 素材匹配服务 │────▶│ 七牛云存储 │
│ (AI 输出标签) │ │ (本地索引查询) │ │ (英文路径) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
┌──────────────────┐
│ 本地素材索引 │
│ (materials_index) │
└──────────────────┘
```
### 3.2 核心模块
| 模块 | 位置 | 职责 |
|------|------|------|
| `MaterialIndex` | `python-api/app/services/material_index.py` | 素材索引管理(加载/查询/更新) |
| `MaterialMatcher` | `python-api/app/services/material_matcher.py` | 素材匹配逻辑 |
| `QiniuMaterialService` | `python-api/app/services/qiniu_material.py` | 七牛云素材相关操作 |
| `material_router` | `python-api/app/api/v1/material.py` | 素材管理 API |
### 3.3 关键设计决策
| 决策 | 方案 | 理由 |
|------|------|------|
| 标签中文,路径英文 | 中文标签映射到英文目录 | URL 可读性、FFmpeg 兼容性 |
| 本地索引 + 七牛云 | 双层架构 | 减少 API 调用、快速检索 |
| 随机选择素材 | `random.choice()` | 避免每次生成视频都用相同素材 |
| 索引热更新 | 定时刷新 + 手动刷新 | 支持运营动态增删素材 |
---
## 四、数据模型
### 4.1 分镜数据扩展
```typescript
// 前端 types.ts
interface Segment {
id: string;
type: "segment" | "empty_shot";
scene: string; // 画面描述
voiceover: string; // 配音文案
duration: number; // 预估时长(秒)
tag?: string; // 【新增】素材标签,如 "室内/客厅"
videoPath?: string; // 【新增】匹配到的素材本地路径
videoUrl?: string; // 【新增】七牛云原 URL
status: "pending" | "matched" | "downloaded" | "failed";
}
```
### 4.2 素材索引 Schema
```json
// materials_index.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["version", "lastUpdated", "tags"],
"properties": {
"version": {
"type": "string",
"description": "索引文件版本"
},
"lastUpdated": {
"type": "string",
"format": "date-time",
"description": "最后更新时间"
},
"tags": {
"type": "object",
"description": "标签到素材的映射",
"additionalProperties": {
"type": "object",
"required": ["displayName", "qiniuDir", "videos"],
"properties": {
"displayName": {"type": "string"},
"qiniuDir": {"type": "string"},
"videos": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "filename", "qiniuKey"],
"properties": {
"id": {"type": "string"},
"filename": {"type": "string"},
"qiniuKey": {"type": "string"},
"duration": {"type": "number"},
"size": {"type": "integer"},
"uploadedAt": {"type": "string"}
}
}
}
}
}
}
}
}
```
---
## 五、API 设计
### 5.1 素材管理 API
| 方法 | 路径 | 描述 |
|------|------|------|
| GET | `/api/v1/materials` | 获取素材索引 |
| GET | `/api/v1/materials/tags` | 获取所有标签列表 |
| GET | `/api/v1/materials/match?tag=室内/客厅` | 根据标签匹配素材 |
| POST | `/api/v1/materials/sync` | 同步/刷新素材索引(管理员) |
| POST | `/api/v1/materials/upload` | 上传新素材并标注标签 |
### 5.2 匹配流程 API
| 方法 | 路径 | 描述 |
|------|------|------|
| POST | `/api/v1/materials/batch-match` | 批量匹配分镜素材 |
| GET | `/api/v1/materials/download/{id}` | 下载素材到本地 |
**批量匹配请求**
```json
POST /api/v1/materials/batch-match
{
"segments": [
{"id": "seg_001", "tag": "室内/客厅"},
{"id": "seg_002", "tag": "室内/厨房/台面特写"}
]
}
```
**批量匹配响应**
```json
{
"matched": [
{"segmentId": "seg_001", "videoId": "vid_001", "url": "https://..."},
{"segmentId": "seg_002", "videoId": "vid_015", "url": "https://..."}
],
"unmatched": ["seg_003"],
"summary": {
"total": 3,
"matched": 2,
"unmatched": 1
}
}
```
---
## 六、实施步骤
### Phase 1:基础设施(1天)
- [ ] 创建 `python-api/app/services/material_index.py`
- [ ] 创建 `python-api/app/services/material_matcher.py`
- [ ] 创建 `python-api/app/services/qiniu_material.py`
- [ ] 初始化示例 `materials_index.json`
### Phase 2API 层(1天)
- [ ] 创建 `python-api/app/api/v1/material.py` 路由
- [ ] 注册路由到 `router.py`
- [ ] 编写 API 文档和单元测试
### Phase 3:脚本生成集成(1天)
- [ ] 更新 `python-api/app/ai/prompts/system/script.txt` 添加标签输出要求
- [ ] 更新 `python-api/app/services/script_service.py` 支持素材匹配
- [ ] 测试端到端流程
### Phase 4:前端集成(1天)
- [ ] 更新 `tauri-app/src/api/types.ts` 添加 tag 字段
- [ ] 更新 `SegmentAdapter` 支持 tag 映射
- [ ] 前端显示素材匹配状态
### Phase 5:运营工具(待定)
- [ ] 开发素材上传 + 标签标注 Web 页面
- [ ] 开发素材索引管理后台
---
## 七、文件结构
```
meijiaka-zj/
├── python-api/
│ └── app/
│ ├── services/
│ │ ├── material_index.py # 【新建】素材索引管理
│ │ ├── material_matcher.py # 【新建】素材匹配器
│ │ └── qiniu_material.py # 【新建】七牛云素材操作
│ ├── api/v1/
│ │ ├── material.py # 【新建】素材管理 API
│ │ └── router.py # 【修改】注册 material 路由
│ └── schemas/
│ └── material.py # 【新建】Pydantic 模型
├── tauri-app/
│ └── src/
│ ├── api/
│ │ ├── modules/
│ │ │ └── material.ts # 【新建】素材 API 模块
│ │ └── types.ts # 【修改】添加 Segment.tag
│ └── pages/
│ └── VideoCreation/
│ └── ScriptCreation.tsx # 【修改】显示标签
├── docs/
│ └── material-matching-plan.md # 【新建】本文档
└── materials/
└── materials_index.json # 【新建】素材索引文件
```
---
## 八、配置项
### 8.1 环境变量
```bash
# .env 添加
QINIU_MATERIALS_ENABLED=true # 是否启用素材匹配
QINIU_MATERIALS_DOMAIN=https://materials.xxx.com # 素材专用域名
MATERIALS_INDEX_PATH=./materials_index.json # 本地索引文件路径
```
### 8.2 标签-目录映射表
| 中文标签 | 七牛云目录 | 英文路径 |
|----------|-----------|----------|
| 室内/客厅 | materials/indoor/living_room | materials/indoor/living_room |
| 室内/厨房 | materials/indoor/kitchen | materials/indoor/kitchen |
| 室内/厨房/开放式 | materials/indoor/kitchen/open | materials/indoor/kitchen/open |
| 室内/卫生间 | materials/indoor/bathroom | materials/indoor/bathroom |
| 室内/卧室 | materials/indoor/bedroom | materials/indoor/bedroom |
| 室外/阳台 | materials/outdoor/balcony | materials/outdoor/balcony |
---
## 九、注意事项
### 9.1 编码问题
- **七牛云 Key**:使用 UTF-8 编码,中文 key 技术上支持但建议避免
- **本地路径**:下载后确保路径处理兼容中文文件名
- **FFmpeg**:操作本地文件时优先使用绝对路径和引号包裹
### 9.2 容错处理
| 场景 | 处理方式 |
|------|----------|
| 标签无匹配素材 | 记录 `unmatched`,不影响后续分镜 |
| 七牛云下载失败 | 标记状态为 `failed`,允许重试 |
| 索引文件缺失 | 自动重建或返回空列表 |
### 9.3 性能考虑
- 素材索引加载到内存,避免每次请求都读文件
- 使用 LRU 缓存最近访问的素材 URL
- 下载操作异步执行,不阻塞主流程
---
## 十、附录
### 10.1 七牛云 Bucket 配置建议
```
Bucket 名称:media-materials
存储区域:华东 zone
访问域名:materials.xxx.comCDN 加速)
```
### 10.2 素材上传规范
| 字段 | 要求 |
|------|------|
| 格式 | MP4、H.264 编码 |
| 时长 | 5-30 秒 |
| 分辨率 | 1920x1080 或 1280x720 |
| 文件名 | `vid_{序号}.mp4` |
| 标签 | 上传后填写,支持多标签 |
### 10.3 参考文档
- [七牛云 Python SDK 指南](../docs/qiniu-kodo-python-sdk-guide.md)
- [火山引擎字幕 API](../docs/volcengine-video-caption-api.md)