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