Files
sionrui/openspec/mix-logic-spec.md
2025-12-15 23:33:02 +08:00

1027 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 混剪逻辑与页面实现规格基于阿里云ICE
## 📋 核心需求
**输入**:用户选择素材分组中的视频
**输出**2-3个**不同内容**的混剪视频
**规则**
- 每个素材可设置截取时长:**3s-15s**默认3s
- 总时长限制:**15s-60s**
- 素材处理:**从每条视频截取指定时长片段**,非简单拼接
- **视频差异性**生成的2-3个视频必须内容不同每个视频使用不同的素材组合
**底层引擎**:阿里云 ICE (Intelligent Collaboration Editor)
---
## 🎯 核心交互逻辑
### 多视频生成机制
#### 素材分配算法
**核心原则**
1. 确保每个生成的视频内容不同
2. **每个视频内素材时长随机截取**3s-15s范围内
3. **最后一个视频补全所有素材**,确保无遗漏
**算法一:分组循环分配(推荐)**
```
场景:用户选择 9 个素材,生成 3 个视频
分配逻辑:
├─ 视频1使用素材 [1, 2, 3] 随机时长5s, 8s, 4s
├─ 视频2使用素材 [4, 5, 6] 随机时长6s, 3s, 9s
└─ 视频3使用素材 [7, 8, 9, 1, 2, 3, 4, 5, 6] (补全所有剩余素材,随机时长)
结果:
- 视频13个素材总时长约17s
- 视频23个素材总时长约18s
- 视频39个素材随机分配时长总时长约27s
```
**算法二:交错分配**
```
场景:用户选择 12 个素材,生成 2 个视频
分配逻辑:
├─ 视频1使用素材 [1, 3, 5, 7, 9, 11] (奇数位,随机时长)
└─ 视频2使用素材 [2, 4, 6, 8, 10, 12, 1, 2, 3, 4, 5, 6] (偶数位 + 补全奇数位)
结果:
- 视频16个素材随机时长总时长约18s
- 视频212个素材补全随机时长总时长约36s
```
**算法三:智能平衡分配**
```
场景:用户选择 10 个素材,生成 3 个视频
分配逻辑:
├─ 视频1素材 [1, 2, 3, 4] 4个素材随机时长
├─ 视频2素材 [5, 6, 7, 8] 4个素材随机时长
└─ 视频3素材 [9, 10, 1, 2, 3, 4, 5, 6, 7, 8] 2个 + 补全前8个
结果:
- 视频1、2各4个素材随机时长
- 视频310个素材补全所有随机时长
```
#### 随机时长截取
**规则**
- 每个素材的时长在3s-15s范围内**随机生成**
- 随机种子基于素材ID和视频序号确保可重现性
- 最后一个视频的素材时长也随机生成
**示例**
```
素材1ID=123
- 视频1中使用随机生成 7s
- 视频2中使用随机生成 4s
- 视频3中使用随机生成 12s
素材2ID=456
- 视频1中使用随机生成 9s
- 视频2中使用随机生成 6s
- 视频3中使用随机生成 3s
```
#### 最后一个视频补全逻辑
**补全规则**
- 最后一个视频N号视频必须包含**所有素材**
- 前面(N-1)个视频使用部分素材,避免重复
- 补全时保持素材的随机时长特性
**实现流程**
```
1. 计算每个视频应分配的基础素材数
baseCount = 素材总数 ÷ 生成数量
2. 前(N-1)个视频各分配 baseCount 个素材
3. 最后一个视频分配:
- 剩余所有素材(素材总数 - baseCount × (N-1)
- + 循环补全前面(N-1)个视频使用过的素材
- 确保包含所有素材
```
#### 容错机制设计
**1. 视频时长不足容错**
```
当素材时长不足时:
├─ 自动循环使用素材补充时长
├─ 调整素材截取起始点(从素材中间截取)
└─ 确保最终时长达到目标时长
```
**2. ICE API调用容错**
```
调用失败处理:
├─ 重试机制最多重试3次间隔时间递增1s, 3s, 9s
├─ 部分成功:如果部分视频成功,返回成功的视频
└─ 失败补偿:失败的视频可手动重试
```
**3. 随机时长生成容错**
```
随机生成失败时:
├─ 使用默认时长5s作为fallback
├─ 记录异常日志用于调试
└─ 继续后续流程
```
**4. 素材不足容错**
```
素材数量不足时:
├─ 循环使用素材填充所有视频
├─ 调整素材时长(延长或缩短)
└─ 保证每个视频至少包含2个素材
```
**5. 任务状态容错**
```
多视频状态跟踪:
├─ 部分成功至少1个视频成功即算任务部分成功
├─ 状态同步:定期同步所有视频状态
└─ 失败补偿:提供"补全失败视频"功能
```
#### 目标时长分配
**每个视频的目标时长** = 总时长 ÷ 生成数量
**示例**
- 总时长45s
- 生成数量3个
- 目标时长15s/视频
---
## 🏗️ 系统架构
```
┌─────────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 前端页面 │ │ 后端API │ │ 阿里云ICE │
│ │ │ │ │ │
│ /material/mix │◄──►│ MixTaskController│◄──►│ IceClient │
│ (独立混剪页面) │ │ MixTaskService │ │ │
│ │ │ │ │ Timeline构建 │
│ 素材时长选择 │ │ 时长校验 │ │ 片段截取 │
│ 总时长计算 │ │ 参数转换 │ │ 视频合成 │
└─────────────────────┘ └─────────────────┘ └─────────────────┘
```
---
## 📄 前端页面设计
### 页面布局
```
┌────────────────────────────────────────────────────────────────────────┐
│ 📹 智能混剪 [返回素材列表] │
├───────────────────────────────┬────────────────────────────────────────┤
│ │ │
│ ┌─ 混剪参数 ─────────────┐ │ ┌─ 素材预览 ────────────────────┐ │
│ │ │ │ │ │ │
│ │ 选择素材分组: │ │ │ ┌────┐ ┌────┐ ┌────┐ │ │
│ │ [下拉选择分组 ▼] │ │ │ │ 01 │ │ 02 │ │ 03 │ ... │ │
│ │ │ │ │ │封面│ │封面│ │封面│ │ │
│ │ 视频标题: │ │ │ │ │ │ │ │ │ │ │
│ │ [输入标题____________] │ │ │ │3s▼ │ │5s▼ │ │3s▼ │ │ │
│ │ │ │ │ └────┘ └────┘ └────┘ │ │
│ │ 生成数量: │ │ │ │ │
│ │ ○ 1个 ○ 2个 ● 3个 │ │ │ 已选择 5 个素材 │ │
│ │ │ │ │ │ │
│ │ ──────────────────────│ │ └──────────────────────────────┘ │
│ │ │ │ │
│ │ 📊 时长统计 │ │ ┌─ 时长概览 ────────────────────┐ │
│ │ ├─ 已选素材: 5 个 │ │ │ │ │
│ │ ├─ 总时长: 18s │ │ │ ████████████░░░░░░ 18s/60s │ │
│ │ └─ 限制: 15s-60s ✅ │ │ │ ✅ 时长合规 │ │
│ │ │ │ │ │ │
│ │ [🚀 开始混剪] │ │ └──────────────────────────────┘ │
│ │ │ │ │
│ └────────────────────────┘ │ │
│ │ │
└───────────────────────────────┴────────────────────────────────────────┘
```
### 核心组件实现
```vue
<!-- views/material/Mix.vue -->
<template>
<div class="mix-page">
<!-- 页面头部 -->
<div class="mix-page__header">
<h1 class="mix-page__title">智能混剪</h1>
<a-button @click="$router.push('/material/list')">
<template #icon><ArrowLeftOutlined /></template>
返回素材列表
</a-button>
</div>
<div class="mix-page__content">
<!-- 左侧参数配置 -->
<div class="mix-page__params">
<a-card title="混剪参数" :bordered="false">
<a-form layout="vertical">
<!-- 分组选择 -->
<a-form-item label="选择素材分组" required>
<a-select
v-model:value="formData.groupId"
placeholder="请选择素材分组"
@change="handleGroupChange"
>
<a-select-option v-for="g in groupList" :key="g.id" :value="g.id">
{{ g.name }}
</a-select-option>
</a-select>
</a-form-item>
<!-- 视频标题 -->
<a-form-item label="视频标题" required>
<a-input v-model:value="formData.title" placeholder="请输入视频标题" />
</a-form-item>
<!-- 生成数量 -->
<a-form-item label="生成数量">
<a-radio-group v-model:value="formData.produceCount" button-style="solid">
<a-radio-button :value="1">1</a-radio-button>
<a-radio-button :value="2">2</a-radio-button>
<a-radio-button :value="3">3</a-radio-button>
</a-radio-group>
</a-form-item>
<a-divider />
<!-- 时长统计 -->
<div class="mix-page__duration-stats">
<div class="stat-row">
<span>已选素材</span>
<strong>{{ selectedMaterials.length }} </strong>
</div>
<div class="stat-row">
<span>总时长</span>
<strong :class="{ 'text-red': !isDurationValid }">{{ totalDuration }}s</strong>
</div>
<div class="stat-row">
<span>限制</span>
<span>15s - 60s</span>
<a-tag :color="isDurationValid ? 'green' : 'red'">
{{ isDurationValid ? '✅ 合规' : '❌ 超出' }}
</a-tag>
</div>
</div>
<!-- 时长进度条 -->
<div class="mix-page__duration-bar">
<a-progress
:percent="durationPercent"
:status="isDurationValid ? 'normal' : 'exception'"
:show-info="false"
/>
<div class="duration-text">{{ totalDuration }}s / 60s</div>
</div>
<a-alert
v-if="!isDurationValid && totalDuration > 0"
type="warning"
show-icon
:message="durationWarning"
style="margin-bottom: 16px"
/>
<a-button
type="primary"
block
size="large"
:loading="submitting"
:disabled="!canSubmit"
@click="handleSubmit"
>
<template #icon><RocketOutlined /></template>
开始混剪
</a-button>
</a-form>
</a-card>
</div>
<!-- 右侧素材预览 -->
<div class="mix-page__preview">
<a-card title="素材预览" :bordered="false">
<template #extra>
<a-button size="small" @click="selectAll">全选</a-button>
<a-button size="small" @click="clearAll" style="margin-left: 8px">清空</a-button>
</template>
<a-spin :spinning="loadingFiles">
<div v-if="groupFiles.length > 0" class="mix-page__grid">
<div
v-for="(file, index) in groupFiles"
:key="file.id"
class="mix-page__item"
:class="{ 'mix-page__item--selected': isSelected(file.id) }"
@click="toggleSelect(file.id)"
>
<!-- 封面图 -->
<div class="mix-page__thumb">
<img v-if="file.coverBase64" :src="file.coverBase64" :alt="file.fileName" />
<div v-else class="mix-page__placeholder">
<VideoCameraOutlined />
</div>
</div>
<!-- 序号 -->
<span class="mix-page__index">{{ index + 1 }}</span>
<!-- 选中标记 -->
<span v-if="isSelected(file.id)" class="mix-page__check">
<CheckCircleFilled />
</span>
<!-- 文件名 -->
<div class="mix-page__name" :title="file.fileName">
{{ file.fileName }}
</div>
<!-- 时长选择器仅选中时显示 -->
<div v-if="isSelected(file.id)" class="mix-page__duration" @click.stop>
<a-select
:value="getMaterialDuration(file.id)"
size="small"
style="width: 70px"
@change="(val) => setMaterialDuration(file.id, val)"
>
<a-select-option :value="3">3s</a-select-option>
<a-select-option :value="5">5s</a-select-option>
<a-select-option :value="8">8s</a-select-option>
<a-select-option :value="10">10s</a-select-option>
<a-select-option :value="15">15s</a-select-option>
</a-select>
</div>
</div>
</div>
<a-empty v-else description="请先选择素材分组" />
</a-spin>
</a-card>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { useRouter } from 'vue-router'
const router = useRouter()
// 表单数据
const formData = ref({
groupId: null,
title: '',
produceCount: 3
})
// 素材选择数据:{ fileId: duration }
const selectedMaterials = ref({}) // 例: { 123: 3, 456: 5 }
// 分组和文件
const groupList = ref([])
const groupFiles = ref([])
const loadingFiles = ref(false)
const submitting = ref(false)
// 时长限制
const MIN_DURATION = 15
const MAX_DURATION = 60
const DEFAULT_DURATION = 3
// 是否选中
const isSelected = (fileId) => fileId in selectedMaterials.value
// 获取素材时长
const getMaterialDuration = (fileId) => selectedMaterials.value[fileId] || DEFAULT_DURATION
// 设置素材时长
const setMaterialDuration = (fileId, duration) => {
if (isSelected(fileId)) {
selectedMaterials.value[fileId] = duration
}
}
// 切换选择
const toggleSelect = (fileId) => {
if (isSelected(fileId)) {
delete selectedMaterials.value[fileId]
} else {
selectedMaterials.value[fileId] = DEFAULT_DURATION
}
}
// 全选
const selectAll = () => {
groupFiles.value.forEach(file => {
if (!isSelected(file.id)) {
selectedMaterials.value[file.id] = DEFAULT_DURATION
}
})
}
// 清空
const clearAll = () => {
selectedMaterials.value = {}
}
// 计算总时长
const totalDuration = computed(() => {
return Object.values(selectedMaterials.value).reduce((sum, d) => sum + d, 0)
})
// 时长百分比(用于进度条)
const durationPercent = computed(() => {
return Math.min((totalDuration.value / MAX_DURATION) * 100, 100)
})
// 时长是否合规
const isDurationValid = computed(() => {
const total = totalDuration.value
return total >= MIN_DURATION && total <= MAX_DURATION
})
// 时长警告信息
const durationWarning = computed(() => {
const total = totalDuration.value
if (total < MIN_DURATION) {
return `总时长不足,最少需要 ${MIN_DURATION}s当前 ${total}s`
}
if (total > MAX_DURATION) {
return `总时长超出限制,最多 ${MAX_DURATION}s当前 ${total}s请减少素材或缩短时长`
}
return ''
})
// 是否可提交
const canSubmit = computed(() => {
return formData.value.groupId &&
formData.value.title.trim() &&
Object.keys(selectedMaterials.value).length > 0 &&
isDurationValid.value
})
// 提交混剪
const handleSubmit = async () => {
if (!canSubmit.value) return
submitting.value = true
try {
// 构建素材列表(包含时长)
const materials = Object.entries(selectedMaterials.value).map(([fileId, duration]) => {
const file = groupFiles.value.find(f => f.id === Number(fileId))
return {
fileId: Number(fileId),
fileUrl: file?.fileUrl,
duration: duration
}
})
const res = await MixTaskService.createTask({
title: formData.value.title,
materials: materials, // 新格式:包含时长信息
produceCount: formData.value.produceCount
})
if (res.code === 0) {
message.success('混剪任务创建成功!')
router.push('/material/mix-task')
}
} catch (error) {
message.error('提交失败:' + error.message)
} finally {
submitting.value = false
}
}
</script>
```
---
## 📡 后端API接口变更
### 创建混剪任务(新格式)
```http
POST /api/mix/create
Content-Type: application/json
{
"title": "",
"materials": [
{ "fileId": 123, "fileUrl": "https://xxx.com/video1.mp4", "duration": 3 },
{ "fileId": 456, "fileUrl": "https://xxx.com/video2.mp4", "duration": 5 },
{ "fileId": 789, "fileUrl": "https://xxx.com/video3.mp4", "duration": 8 },
{ "fileId": 101, "fileUrl": "https://xxx.com/video4.mp4", "duration": 4 },
{ "fileId": 102, "fileUrl": "https://xxx.com/video5.mp4", "duration": 6 }
],
"produceCount": 2
}
```
**返回**`{"code": 0, "data": 12345}` (任务ID)
**后端处理**
- 解析materials列表和produceCount
- 使用分配算法将素材分配给每个视频
- 生成2个不同的视频每个视频使用不同的素材组合
### MixTaskSaveReqVO新格式
```java
@Data
public class MixTaskSaveReqVO {
@NotBlank(message = "视频标题不能为空")
private String title;
@NotEmpty(message = "素材列表不能为空")
@Valid
private List<MaterialItem> materials;
@NotNull(message = "生成数量不能为空")
@Min(1) @Max(3)
private Integer produceCount;
@Data
public static class MaterialItem {
private Long fileId; // 素材文件ID
private String fileUrl; // 素材URL
@Min(3) @Max(15)
private Integer duration; // 截取时长(秒): 3-15
}
}
```
### 素材分配算法实现
```java
/**
* 素材分配算法
* 根据素材数量和生成数量,智能分配素材到每个视频
* 特性:
* 1. 随机时长截取3s-15s
* 2. 最后一个视频补全所有素材
*/
public class MaterialDistribution {
/**
* 分配素材到多个视频(分组循环分配)
*
* @param materials 原始素材列表
* @param videoCount 生成视频数量
* @return 每个视频的素材列表(包含随机时长)
*/
public static List<List<MaterialItem>> distribute(List<MaterialItem> materials, int videoCount) {
List<List<MaterialItem>> result = new ArrayList<>();
int materialCount = materials.size();
int baseCount = materialCount / videoCount;
// 前(N-1)个视频:分配基础素材
Set<Long> usedMaterialIds = new HashSet<>();
for (int i = 0; i < videoCount - 1; i++) {
List<MaterialItem> videoMaterials = new ArrayList<>();
for (int j = 0; j < baseCount; j++) {
int index = i * baseCount + j;
if (index < materialCount) {
MaterialItem material = materials.get(index);
usedMaterialIds.add(material.getFileId());
// 生成随机时长
material.setDuration(generateRandomDuration(material.getFileId(), i));
videoMaterials.add(material);
}
}
result.add(videoMaterials);
}
// 最后一个视频:补全所有素材
List<MaterialItem> lastVideoMaterials = new ArrayList<>();
// 添加未使用的素材
for (MaterialItem material : materials) {
if (!usedMaterialIds.contains(material.getFileId())) {
material.setDuration(generateRandomDuration(material.getFileId(), videoCount - 1));
lastVideoMaterials.add(material);
}
}
// 循环补全已使用的素材(随机选择部分)
List<MaterialItem> recycledMaterials = new ArrayList<>();
for (MaterialItem material : materials) {
if (usedMaterialIds.contains(material.getFileId())) {
// 随机决定是否循环使用该素材
if (Math.random() > 0.3) { // 70%概率循环使用
MaterialItem recycled = new MaterialItem();
recycled.setFileId(material.getFileId());
recycled.setFileUrl(material.getFileUrl());
recycled.setDuration(generateRandomDuration(material.getFileId(), videoCount - 1));
recycledMaterials.add(recycled);
}
}
}
lastVideoMaterials.addAll(recycledMaterials);
result.add(lastVideoMaterials);
return result;
}
/**
* 生成随机时长3s-15s
* 使用素材ID和视频序号作为随机种子确保可重现性
*/
private static int generateRandomDuration(Long materialId, int videoIndex) {
Random random = new Random(materialId * 1000L + videoIndex);
return random.nextInt(13) + 3; // 3-15 inclusive
}
/**
* 交错分配算法(奇偶分配)+ 补全逻辑
*/
public static List<List<MaterialItem>> distributeByParity(List<MaterialItem> materials, int videoCount) {
List<List<MaterialItem>> result = new ArrayList<>();
// 创建视频列表
for (int i = 0; i < videoCount; i++) {
result.add(new ArrayList<>());
}
// 按奇偶分配素材
Set<Long> usedMaterialIds = new HashSet<>();
for (int i = 0; i < materials.size(); i++) {
int videoIndex = i % videoCount;
MaterialItem material = materials.get(i);
material.setDuration(generateRandomDuration(material.getFileId(), videoIndex));
result.get(videoIndex).add(material);
usedMaterialIds.add(material.getFileId());
}
// 最后一个视频补全所有素材
List<MaterialItem> lastVideo = result.get(videoCount - 1);
for (MaterialItem material : materials) {
if (!usedMaterialIds.contains(material.getFileId())) {
MaterialItem supplement = new MaterialItem();
supplement.setFileId(material.getFileId());
supplement.setFileUrl(material.getFileUrl());
supplement.setDuration(generateRandomDuration(material.getFileId(), videoCount - 1));
lastVideo.add(supplement);
}
}
return result;
}
}
```
---
## 💾 数据模型变更
### MixTaskDO数据库表
```java
@TableName("tik_mix_task")
public class MixTaskDO extends TenantBaseDO {
private Long id;
private Long userId;
private String title;
// 素材配置JSON格式存储
// 格式: [{"fileId":123,"fileUrl":"...","duration":3}, ...]
private String materialsJson;
private Integer produceCount;
private String jobIds;
private String outputUrls;
private String status;
private Integer progress;
private String errorMsg;
private LocalDateTime finishTime;
}
```
### 数据库字段变更
```sql
-- 新增 materials_json 字段,替代原来的 video_urls
ALTER TABLE tik_mix_task
ADD COLUMN materials_json TEXT COMMENT '素材配置JSON';
-- 可选:保留 video_urls 做兼容,或迁移后删除
```
---
## ☁️ 阿里云ICE集成变更
### Timeline 构建逻辑
```java
public String buildTimeline(List<MaterialItem> materials) {
// ICE Timeline 结构
// 每个素材需要指定:
// 1. MediaURL: 视频源地址
// 2. In: 开始时间通常为0
// 3. Out: 结束时间 = duration
StringBuilder tracks = new StringBuilder();
float currentTime = 0;
for (MaterialItem material : materials) {
tracks.append(String.format("""
{
"MediaURL": "%s",
"In": 0,
"Out": %d,
"TimelineIn": %.2f,
"TimelineOut": %.2f
}
""",
material.getFileUrl(),
material.getDuration(),
currentTime,
currentTime + material.getDuration()
));
currentTime += material.getDuration();
}
return buildFullTimeline(tracks.toString());
}
```
### ICE 关键参数说明
| 参数 | 说明 |
|------|------|
| MediaURL | 素材视频URL |
| In | 素材起始时间(秒),从原视频的哪个时间点开始截取 |
| Out | 素材结束时间(秒),截取到原视频的哪个时间点 |
| TimelineIn | 在输出视频中的起始时间 |
| TimelineOut | 在输出视频中的结束时间 |
---
## ⚠️ 校验规则
### 前端校验
```javascript
// 时长校验规则
const validateDuration = () => {
const total = totalDuration.value
// 1. 总时长不能小于15秒
if (total < 15) {
return { valid: false, msg: '总时长不足15秒' }
}
// 2. 总时长不能超过60秒
if (total > 60) {
return { valid: false, msg: '总时长超过60秒' }
}
// 3. 至少选择1个素材
if (Object.keys(selectedMaterials.value).length === 0) {
return { valid: false, msg: '请至少选择1个素材' }
}
return { valid: true }
}
```
### 后端校验
```java
public void validateMixTask(MixTaskSaveReqVO req) {
// 1. 素材列表不能为空
if (req.getMaterials() == null || req.getMaterials().isEmpty()) {
throw new ServiceException("素材列表不能为空");
}
// 2. 计算总时长
int totalDuration = req.getMaterials().stream()
.mapToInt(MaterialItem::getDuration)
.sum();
// 3. 总时长校验
if (totalDuration < 15) {
throw new ServiceException("总时长不能小于15秒");
}
if (totalDuration > 60) {
throw new ServiceException("总时长不能超过60秒");
}
// 4. 单个素材时长校验
for (MaterialItem item : req.getMaterials()) {
if (item.getDuration() < 3 || item.getDuration() > 15) {
throw new ServiceException("单个素材时长需在3-15秒之间");
}
}
}
```
---
## 🔄 完整流程
```
1. 用户进入混剪页面 (/material/mix)
2. 选择素材分组
3. 加载分组内的视频素材
4. 点击素材卡片进行选择
5. 为每个选中的素材设置截取时长3s-15s
├─ 默认: 3s
└─ 可选: 3s/5s/8s/10s/15s
6. 实时计算并显示总时长
├─ 小于15s: 警告提示,禁止提交
├─ 15s-60s: 正常,允许提交
└─ 大于60s: 警告提示,禁止提交
7. 填写视频标题、选择生成数量1-3个
8. 点击"开始混剪"
9. 调用 POST /api/mix/create
{
title: "xxx",
materials: [
{ fileId: 1, fileUrl: "...", duration: 3 },
{ fileId: 2, fileUrl: "...", duration: 5 },
{ fileId: 3, fileUrl: "...", duration: 4 },
{ fileId: 4, fileUrl: "...", duration: 6 },
{ fileId: 5, fileUrl: "...", duration: 5 }
],
produceCount: 2
}
10. 后端处理:素材分配算法
├─ 计算目标时长23s ÷ 2 = 11.5s ≈ 12s/视频
├─ 分配素材:
│ ├─ 视频1: [素材1(随机7s), 素材2(随机4s), 素材3(随机9s)]
│ └─ 视频2: [素材4,5, 素材1,2,3,4,5 (补全所有)]
│ └─ 包含所有9个素材每个素材随机时长
├─ **随机时长生成**基于素材ID和视频序号生成可重现的随机值
└─ 构建多个ICE Timeline每个视频独立Timeline
11. 批量提交到ICE2个任务
├─ 任务1: 视频1的Timeline
├─ 任务2: 视频2的Timeline
└─ 任务状态pending → running → success/failed
12. 任务完成生成2个不同内容的视频
├─ 视频1: 使用素材1,2,3
├─ 视频2: 使用素材4,5,1
└─ 用户下载/预览
```
---
## 📝 实现清单
### 前端任务
- [x] 修改 Mix.vue素材选择改为 Map 结构存储fileId → duration
- [x] 添加时长选择下拉框(每个素材卡片)
- [x] 添加总时长实时计算和显示
- [x] 添加时长校验警告提示
- [x] 修改提交数据格式materials 数组替代 videoUrls
- [ ] **新增:多视频生成提示**
- [ ] 在页面显示"将生成X个不同内容的视频"提示
- [ ] 优化生成数量选择UI突出差异性
### 后端任务
- [x] 修改 MixTaskSaveReqVO新增 MaterialItem 内部类
- [x] 修改 MixTaskDO新增 materialsJson 字段
- [x] 修改 MixTaskService添加时长校验逻辑
- [x] 修改 ICE Timeline 构建:支持每个素材不同时长截取
- [x] 数据库迁移:新增 materials_json 字段
- [ ] **新增:多视频生成核心功能**
- [ ] 实现 MaterialDistribution 素材分配算法类
- [ ] **新增:随机时长生成功能**
- [ ] 实现 generateRandomDuration() 方法
- [ ] 使用素材ID和视频序号作为随机种子
- [ ] 确保随机值的可重现性
- [ ] **新增:最后一个视频补全功能**
- [ ] 实现补全逻辑:最后一个视频包含所有素材
- [ ] 前(N-1)个视频避免重复使用素材
- [ ] 循环补全机制:随机选择部分素材再次使用
- [ ] 修改 createMixTask支持批量生成不同视频
- [ ] 修改 submitToICE循环提交多个视频任务
- [ ] 修改任务状态管理:支持多任务状态跟踪
- [ ] 更新任务完成逻辑:合并多个视频的结果
### 算法实现任务
- [ ] **素材分配算法**
- [x] 平均分配算法(默认)
- [x] 交错分配算法(奇偶分配)
- [ ] 智能平衡分配算法
- [ ] 素材不足时的循环分配处理
- [ ] **随机时长算法**
- [x] 基于素材ID和视频序号的随机种子生成
- [x] 3s-15s范围内随机时长生成
- [x] 可重现性保证(相同输入产生相同输出)
- [ ] **补全逻辑算法**
- [x] 最后一个视频包含所有素材
- [x] 前(N-1)个视频去重分配
- [ ] 循环补全策略优化70%概率循环使用)
### 容错机制实现任务
- [ ] **ICE API调用容错**
- [ ] 实现重试机制最多3次间隔1s/3s/9s
- [ ] 部分成功处理:返回成功的视频
- [ ] 失败补偿:提供手动重试功能
- [ ] **视频时长不足容错**
- [ ] 自动循环使用素材补充时长
- [ ] 调整素材截取起始点
- [ ] 确保最终时长达到目标
- [ ] **随机时长生成容错**
- [ ] 添加默认时长fallback5s
- [ ] 异常日志记录
- [ ] 继续流程机制
- [ ] **素材不足容错**
- [ ] 循环使用素材填充
- [ ] 动态调整素材时长
- [ ] 保证每个视频至少2个素材
- [ ] **任务状态容错**
- [ ] 部分成功状态定义≥1个视频成功
- [ ] 定期状态同步机制
- [ ] 补全失败视频功能
### 数据模型增强
- [ ] **新增:视频批次表**(可选)
- [ ] 创建 tik_mix_video_batch 表
- [ ] 存储每个混剪任务生成的多个视频信息
- [ ] 记录素材分配结果和时长信息
## ⚠️ 重大变更说明
### 变更概述
本次更新引入**多视频差异化生成**功能,这是对原有混剪逻辑的重大升级。
### 核心变更点
#### 1. 数据流变更
**原逻辑**
```
用户选择素材 → 生成1个视频使用所有素材
```
**新逻辑**
```
用户选择素材 → 分配算法 → 生成N个不同视频每个视频使用不同素材组合
```
#### 2. 技术实现复杂度
- **原实现**单次ICE提交1个Timeline1个输出
- **新实现**N次ICE提交N个TimelineN个输出需要状态管理
#### 3. 资源消耗
- **CPU**:增加素材分配计算开销
- **内存**:需要存储多个视频的素材分配信息
- **API调用**ICE API调用次数翻倍N倍
- **存储**任务表存储更多jobId和outputUrl
### 设计权衡
#### 优势
**用户体验提升**:一次操作获得多个不同视频,提高创作效率
**内容多样性**:避免生成的视频内容重复
**灵活性**:支持多种素材分配策略,满足不同需求
#### 挑战
⚠️ **复杂度增加**:需要处理多个视频的状态同步
⚠️ **失败处理**:部分视频失败时的补偿机制
⚠️ **资源消耗**N倍ICE调用可能带来成本压力
### 兼容性考虑
- **向后兼容**现有API保持不变新增字段可选
- **数据迁移**:无需迁移,已有任务不受影响
- **平滑升级**:老版本任务继续使用原有逻辑
### 性能优化建议
1. **并发提交**多个ICE任务并发提交提高效率
2. **状态缓存**批量查询任务状态减少API调用
3. **结果合并**:异步合并多个视频的完成状态
---
*文件mix-logic-spec.md*
*基于阿里云ICE混剪接口 + 素材库系统*
*更新时间2025-12-14*
*版本v2.0 - 多视频差异化生成*