34 KiB
34 KiB
混剪逻辑与页面实现规格(基于阿里云ICE)
📋 核心需求
输入:用户选择素材分组中的视频 输出:2-3个不同内容的混剪视频 规则:
- 每个素材可设置截取时长:3s-15s(默认3s)
- 总时长限制:15s-60s
- 素材处理:从每条视频截取指定时长片段,非简单拼接
- 视频差异性:生成的2-3个视频必须内容不同,每个视频使用不同的素材组合 底层引擎:阿里云 ICE (Intelligent Collaboration Editor)
🎯 核心交互逻辑
多视频生成机制
素材分配算法
核心原则:
- 确保每个生成的视频内容不同
- 每个视频内素材时长随机截取(3s-15s范围内)
- 最后一个视频补全所有素材,确保无遗漏
算法一:分组循环分配(推荐)
场景:用户选择 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 ✅ │ │ │ ✅ 时长合规 │ │
│ │ │ │ │ │ │
│ │ [🚀 开始混剪] │ │ └──────────────────────────────┘ │
│ │ │ │ │
│ └────────────────────────┘ │ │
│ │ │
└───────────────────────────────┴────────────────────────────────────────┘
核心组件实现
<!-- 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接口变更
创建混剪任务(新格式)
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(新格式)
@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
}
}
素材分配算法实现
/**
* 素材分配算法
* 根据素材数量和生成数量,智能分配素材到每个视频
* 特性:
* 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(数据库表)
@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;
}
数据库字段变更
-- 新增 materials_json 字段,替代原来的 video_urls
ALTER TABLE tik_mix_task
ADD COLUMN materials_json TEXT COMMENT '素材配置JSON';
-- 可选:保留 video_urls 做兼容,或迁移后删除
☁️ 阿里云ICE集成变更
Timeline 构建逻辑
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 | 在输出视频中的结束时间 |
⚠️ 校验规则
前端校验
// 时长校验规则
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 }
}
后端校验
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
└─ 用户下载/预览
📝 实现清单
前端任务
- 修改 Mix.vue:素材选择改为 Map 结构存储(fileId → duration)
- 添加时长选择下拉框(每个素材卡片)
- 添加总时长实时计算和显示
- 添加时长校验警告提示
- 修改提交数据格式(materials 数组替代 videoUrls)
- 新增:多视频生成提示
- 在页面显示"将生成X个不同内容的视频"提示
- 优化生成数量选择UI,突出差异性
后端任务
- 修改 MixTaskSaveReqVO:新增 MaterialItem 内部类
- 修改 MixTaskDO:新增 materialsJson 字段
- 修改 MixTaskService:添加时长校验逻辑
- 修改 ICE Timeline 构建:支持每个素材不同时长截取
- 数据库迁移:新增 materials_json 字段
- 新增:多视频生成核心功能
- 实现 MaterialDistribution 素材分配算法类
- 新增:随机时长生成功能
- 实现 generateRandomDuration() 方法
- 使用素材ID和视频序号作为随机种子
- 确保随机值的可重现性
- 新增:最后一个视频补全功能
- 实现补全逻辑:最后一个视频包含所有素材
- 前(N-1)个视频避免重复使用素材
- 循环补全机制:随机选择部分素材再次使用
- 修改 createMixTask:支持批量生成不同视频
- 修改 submitToICE:循环提交多个视频任务
- 修改任务状态管理:支持多任务状态跟踪
- 更新任务完成逻辑:合并多个视频的结果
算法实现任务
- 素材分配算法
- 平均分配算法(默认)
- 交错分配算法(奇偶分配)
- 智能平衡分配算法
- 素材不足时的循环分配处理
- 随机时长算法
- 基于素材ID和视频序号的随机种子生成
- 3s-15s范围内随机时长生成
- 可重现性保证(相同输入产生相同输出)
- 补全逻辑算法
- 最后一个视频包含所有素材
- 前(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保持不变,新增字段可选
- 数据迁移:无需迁移,已有任务不受影响
- 平滑升级:老版本任务继续使用原有逻辑
性能优化建议
- 并发提交:多个ICE任务并发提交,提高效率
- 状态缓存:批量查询任务状态,减少API调用
- 结果合并:异步合并多个视频的完成状态
文件:mix-logic-spec.md 基于:阿里云ICE混剪接口 + 素材库系统 更新时间:2025-12-14 版本:v2.0 - 多视频差异化生成