1027 lines
34 KiB
Markdown
1027 lines
34 KiB
Markdown
# 混剪逻辑与页面实现规格(基于阿里云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] (补全所有剩余素材,随机时长)
|
||
|
||
结果:
|
||
- 视频1:3个素材,总时长约17s
|
||
- 视频2:3个素材,总时长约18s
|
||
- 视频3:9个素材,随机分配时长,总时长约27s
|
||
```
|
||
|
||
**算法二:交错分配**
|
||
```
|
||
场景:用户选择 12 个素材,生成 2 个视频
|
||
|
||
分配逻辑:
|
||
├─ 视频1:使用素材 [1, 3, 5, 7, 9, 11] (奇数位,随机时长)
|
||
└─ 视频2:使用素材 [2, 4, 6, 8, 10, 12, 1, 2, 3, 4, 5, 6] (偶数位 + 补全奇数位)
|
||
|
||
结果:
|
||
- 视频1:6个素材,随机时长,总时长约18s
|
||
- 视频2:12个素材(补全),随机时长,总时长约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个素材,随机时长
|
||
- 视频3:10个素材(补全所有),随机时长
|
||
```
|
||
|
||
#### 随机时长截取
|
||
|
||
**规则**:
|
||
- 每个素材的时长在3s-15s范围内**随机生成**
|
||
- 随机种子基于素材ID和视频序号,确保可重现性
|
||
- 最后一个视频的素材时长也随机生成
|
||
|
||
**示例**:
|
||
```
|
||
素材1(ID=123):
|
||
- 视频1中使用:随机生成 7s
|
||
- 视频2中使用:随机生成 4s
|
||
- 视频3中使用:随机生成 12s
|
||
|
||
素材2(ID=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. 批量提交到ICE(2个任务)
|
||
├─ 任务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)
|
||
- [ ] 部分成功处理:返回成功的视频
|
||
- [ ] 失败补偿:提供手动重试功能
|
||
- [ ] **视频时长不足容错**
|
||
- [ ] 自动循环使用素材补充时长
|
||
- [ ] 调整素材截取起始点
|
||
- [ ] 确保最终时长达到目标
|
||
- [ ] **随机时长生成容错**
|
||
- [ ] 添加默认时长fallback(5s)
|
||
- [ ] 异常日志记录
|
||
- [ ] 继续流程机制
|
||
- [ ] **素材不足容错**
|
||
- [ ] 循环使用素材填充
|
||
- [ ] 动态调整素材时长
|
||
- [ ] 保证每个视频至少2个素材
|
||
- [ ] **任务状态容错**
|
||
- [ ] 部分成功状态定义(≥1个视频成功)
|
||
- [ ] 定期状态同步机制
|
||
- [ ] 补全失败视频功能
|
||
|
||
### 数据模型增强
|
||
- [ ] **新增:视频批次表**(可选)
|
||
- [ ] 创建 tik_mix_video_batch 表
|
||
- [ ] 存储每个混剪任务生成的多个视频信息
|
||
- [ ] 记录素材分配结果和时长信息
|
||
|
||
## ⚠️ 重大变更说明
|
||
|
||
### 变更概述
|
||
本次更新引入**多视频差异化生成**功能,这是对原有混剪逻辑的重大升级。
|
||
|
||
### 核心变更点
|
||
|
||
#### 1. 数据流变更
|
||
**原逻辑**:
|
||
```
|
||
用户选择素材 → 生成1个视频(使用所有素材)
|
||
```
|
||
|
||
**新逻辑**:
|
||
```
|
||
用户选择素材 → 分配算法 → 生成N个不同视频(每个视频使用不同素材组合)
|
||
```
|
||
|
||
#### 2. 技术实现复杂度
|
||
- **原实现**:单次ICE提交,1个Timeline,1个输出
|
||
- **新实现**:N次ICE提交,N个Timeline,N个输出,需要状态管理
|
||
|
||
#### 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 - 多视频差异化生成*
|