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

34 KiB
Raw Blame History

混剪逻辑与页面实现规格基于阿里云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  ✅   │   │   │  ✅ 时长合规                  │    │
│  │                        │   │   │                              │    │
│  │ [🚀 开始混剪]          │   │   └──────────────────────────────┘    │
│  │                        │   │                                        │
│  └────────────────────────┘   │                                        │
│                               │                                        │
└───────────────────────────────┴────────────────────────────────────────┘

核心组件实现

<!-- 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. 批量提交到ICE2个任务
    ├─ 任务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
    • 部分成功处理:返回成功的视频
    • 失败补偿:提供手动重试功能
  • 视频时长不足容错
    • 自动循环使用素材补充时长
    • 调整素材截取起始点
    • 确保最终时长达到目标
  • 随机时长生成容错
    • 添加默认时长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 - 多视频差异化生成