diff --git a/frontend/app/web-gold/src/api/mix.js b/frontend/app/web-gold/src/api/mix.js
index 49c775c1b0..551a3fd03c 100644
--- a/frontend/app/web-gold/src/api/mix.js
+++ b/frontend/app/web-gold/src/api/mix.js
@@ -8,29 +8,21 @@ import { API_BASE } from '@gold/config/api'
const BASE_URL = `${API_BASE.APP}/api/media`
/**
- * 提交素材混剪任务
+ * 提交素材混剪任务(纯画面模式)
* @param {Object} data
* @param {string} data.title
- * @param {string} data.text
* @param {string[]} data.videoUrls
- * @param {string[]} data.bgMusicUrls
* @param {number} data.produceCount
*/
export const MixService = {
- batchProduceAlignment({ title, text, videoUrls = [], bgMusicUrls = [], produceCount = 1 }) {
+ batchProduceAlignment({ title, videoUrls = [], produceCount = 1 }) {
const formData = new URLSearchParams()
formData.append('title', title)
- formData.append('text', text)
videoUrls.forEach((url) => {
if (url) {
formData.append('videoArray', url)
}
})
- bgMusicUrls.forEach((url) => {
- if (url) {
- formData.append('bgMusicArray', url)
- }
- })
formData.append('produceCount', produceCount)
return http.post(`${BASE_URL}/batchProduceAlignment`, formData, {
diff --git a/frontend/app/web-gold/src/components/material/MaterialMixModal.vue b/frontend/app/web-gold/src/components/material/MaterialMixModal.vue
index 6917564e07..cd249b27de 100644
--- a/frontend/app/web-gold/src/components/material/MaterialMixModal.vue
+++ b/frontend/app/web-gold/src/components/material/MaterialMixModal.vue
@@ -12,9 +12,8 @@
视频分组:{{ getGroupName(videoGroupId) || '未选择' }}
视频数量:{{ videoGroupFiles.length }} 个
-
背景音乐:{{ selectedBgMusic?.fileName || '未选择' }}
- 系统将根据文案自动生成配音并匹配视频片段
+ 纯画面模式:仅拼接视频片段,无配音、无背景音乐
@@ -31,41 +30,12 @@
-
-
-
- {{ audio.fileName }}
-
-
-
-
-
-
-
-
+
- 文案将用于生成 TTS 配音,每句话对应一个视频片段
+ 标题仅用于任务记录,不会在视频上显示
-
-
-
@@ -87,10 +57,6 @@ const props = defineProps({
groupList: {
type: Array,
default: () => []
- },
- allAudioFiles: {
- type: Array,
- default: () => []
}
})
@@ -99,13 +65,10 @@ const emit = defineEmits(['update:open', 'confirm', 'cancel'])
// 数据
const modalVisible = ref(false)
const videoGroupId = ref(null)
-const selectedBgMusic = ref(null)
const videoGroupFiles = ref([])
const mixForm = reactive({
- title: '',
- text: '',
- produceCount: 1
+ title: ''
})
// 获取分组名称
@@ -120,7 +83,6 @@ watch(() => props.open, (newVal) => {
modalVisible.value = newVal
if (newVal) {
resetForm()
- // 组件打开时,可以在这里加载音频文件,但最好由父组件传入
}
})
@@ -159,10 +121,7 @@ const handleVideoGroupChange = async (groupId) => {
// 重置表单
const resetForm = () => {
mixForm.title = ''
- mixForm.text = ''
- mixForm.produceCount = 1
videoGroupId.value = null
- selectedBgMusic.value = null
videoGroupFiles.value = []
}
@@ -174,24 +133,15 @@ defineExpose({
// 处理确认
const handleConfirm = async () => {
const title = mixForm.title.trim()
- const text = mixForm.text.trim()
if (!videoGroupId.value) {
message.warning('请选择视频分组')
return
}
- if (!selectedBgMusic.value) {
- message.warning('请选择背景音乐')
- return
- }
if (!title) {
message.warning('请输入视频标题')
return
}
- if (!text) {
- message.warning('请输入文案内容')
- return
- }
// 如果当前没有视频文件,重新加载一次
if (videoGroupFiles.value.length === 0) {
@@ -205,30 +155,19 @@ const handleConfirm = async () => {
return
}
- // 提取视频URL和音频URL
+ // 提取视频URL
const videoUrls = videoGroupFiles.value
.map(file => file?.fileUrl || file?.previewUrl)
.filter(Boolean)
- const bgMusicUrls = [selectedBgMusic.value?.fileUrl || selectedBgMusic.value?.previewUrl].filter(Boolean)
-
if (videoUrls.length === 0) {
message.warning('视频分组中没有有效的视频文件')
return
}
- if (bgMusicUrls.length === 0) {
- message.warning('所选背景音乐无效')
- return
- }
-
- const produceCount = Math.max(1, Math.min(10, Number(mixForm.produceCount) || 1))
emit('confirm', {
title,
- text,
- videoUrls,
- bgMusicUrls,
- produceCount
+ videoUrls
})
}
diff --git a/frontend/app/web-gold/src/views/dh/Video.vue b/frontend/app/web-gold/src/views/dh/Video.vue
index 36ecaadc6c..e9c2103171 100644
--- a/frontend/app/web-gold/src/views/dh/Video.vue
+++ b/frontend/app/web-gold/src/views/dh/Video.vue
@@ -12,6 +12,7 @@ import { InboxOutlined, SoundOutlined, LoadingOutlined } from '@ant-design/icons
import { VoiceService } from '@/api/voice'
import { MaterialService } from '@/api/material'
import { createDigitalHumanTask, getDigitalHumanTask, cancelTask, retryTask } from '@/api/digitalHuman'
+import { extractVideoCover } from '@/utils/video-cover'
// 导入 voiceStore 用于获取用户音色
import { useVoiceCopyStore } from '@/stores/voiceCopy'
@@ -381,8 +382,22 @@ const handleVideoUpload = async (file) => {
try {
uploadedVideo.value = await toDataURL(file)
uploadedVideoFile.value = file // 保存文件对象
+
+ // 提取视频封面
+ try {
+ const cover = await extractVideoCover(file, {
+ maxWidth: 800,
+ quality: 0.8
+ })
+ uploadedVideoFile.value.coverBase64 = cover.base64 // 保存封面到文件对象
+ } catch (coverError) {
+ console.warn('视频封面提取失败:', coverError)
+ // 封面提取失败不影响主流程
+ }
+
message.success('视频上传成功')
} catch (error) {
+ console.error('视频上传失败:', error)
message.error('视频上传失败')
}
return false
@@ -489,7 +504,9 @@ const generateVideo = async () => {
// 上传视频文件到后端
const uploadVideoFile = async (file) => {
try {
- const res = await MaterialService.uploadFile(file, 'video')
+ // 获取封面base64
+ const coverBase64 = file.coverBase64 || null
+ const res = await MaterialService.uploadFile(file, 'video', coverBase64)
if (res.code === 0) {
return res.data // res.data就是文件ID
} else {
diff --git a/frontend/app/web-gold/src/views/material/MaterialList.vue b/frontend/app/web-gold/src/views/material/MaterialList.vue
index 958f0459fc..159c25699f 100644
--- a/frontend/app/web-gold/src/views/material/MaterialList.vue
+++ b/frontend/app/web-gold/src/views/material/MaterialList.vue
@@ -216,7 +216,6 @@ import MaterialBatchGroupModal from '@/components/material/MaterialBatchGroupMod
import MaterialMixModal from '@/components/material/MaterialMixModal.vue'
import { formatFileSize, formatDate } from '@/utils/file'
import { MixTaskService } from '@/api/mixTask'
-import coverCache from '@/utils/coverCache'
// 数据
const loading = ref(false)
@@ -230,7 +229,6 @@ const mixing = ref(false)
// 分组相关
const groupModalVisible = ref(false)
const groupList = ref([])
-const selectedGroupId = ref(null)
const groupingFileId = ref(null) // 当前正在分组的单个文件ID
// 获取单个文件分组的文件名
@@ -249,7 +247,6 @@ const getGroupName = (groupId) => {
// 混剪相关
const mixModalRef = ref(null) // 混剪模态框的 ref
-const allAudioFiles = ref([]) // 所有音频文件
// 筛选条件
const filters = reactive({
@@ -293,52 +290,8 @@ const loadFileList = async () => {
if (res.code === 0) {
const files = res.data.list || []
- // 优先从缓存获取封面,避免重复请求OSS
- const coverList = []
- files.forEach(file => {
- // 视频文件:只使用缓存的coverBase64,绝对不使用coverUrl
- // 所有视频都不允许从OSS获取封面(包括generate/其他分类)
- if (file.isVideo) {
- // 从缓存获取coverBase64
- const cachedCover = coverCache.getCover(file.id)
- if (cachedCover) {
- file.coverBase64 = cachedCover
- } else if (file.coverBase64) {
- // 服务端返回了coverBase64,保存到缓存
- coverCache.setCover(file.id, file.coverBase64)
- } else {
- // 没有缓存和base64,设置空值
- file.coverBase64 = null
- }
-
- // 彻底删除coverUrl,防止任何OSS请求
- delete file.coverUrl
-
- // 即使有previewUrl也不给视频使用
- if (file.previewUrl && !file.coverBase64) {
- delete file.previewUrl
- }
- }
-
- // 收集有coverBase64的文件,用于批量缓存
- if (file.coverBase64) {
- coverList.push({ fileId: file.id, base64: file.coverBase64 })
- }
- })
-
- // 批量保存缓存(仅保存还没有缓存的文件)
- if (coverList.length > 0) {
- coverCache.batchSetCovers(coverList)
- }
-
fileList.value = files
pagination.total = res.data.total || 0
-
- // 输出缓存统计信息(开发环境)
- if (process.env.NODE_ENV === 'development') {
- const stats = coverCache.getStats()
- console.log(`[MaterialList] 封面缓存统计: 总数${stats.total}, 有效${stats.valid}, 过期${stats.expired}`)
- }
} else {
message.error(res.msg || '加载失败')
}
@@ -411,9 +364,6 @@ const handleBatchDelete = () => {
await MaterialService.deleteFiles(selectedFileIds.value)
message.success('删除成功')
- // 清除所有封面缓存(简化处理)
- coverCache.clearAll()
-
selectedFileIds.value = []
loadFileList()
} catch (error) {
@@ -453,6 +403,7 @@ const handleDeleteFile = (file) => {
// 下载文件
const handleDownloadFile = (file) => {
+ console.log('下载文件:', file)
if (!file?.previewUrl) {
message.warning('文件地址无效')
return
@@ -534,31 +485,12 @@ const handleOpenMixModal = async () => {
// 重置混剪表单(调用子组件方法)
mixModalRef.value?.resetForm()
- // 加载所有音频文件和分组列表
- await Promise.all([
- loadAllAudioFiles(),
- loadGroupList() // 重新加载分组列表,确保显示最新数据
- ])
+ // 加载分组列表
+ await loadGroupList()
mixModalVisible.value = true
}
-// 加载所有音频文件
-const loadAllAudioFiles = async () => {
- try {
- const res = await MaterialService.getFilePage({
- pageNo: 1,
- pageSize: 100,
- fileCategory: 'audio'
- })
- if (res.code === 0) {
- allAudioFiles.value = res.data.list || []
- }
- } catch (error) {
- console.error('加载音频文件失败:', error)
- }
-}
-
const handleMixCancel = () => {
mixModalVisible.value = false
}
@@ -566,7 +498,16 @@ const handleMixCancel = () => {
const handleMixConfirm = async (params) => {
mixing.value = true
try {
- const { data } = await MixTaskService.createTask(params)
+ // 纯画面模式:仅传入 title 和 videoUrls
+ const { title, videoUrls } = params
+
+ const taskParams = {
+ title,
+ videoUrls,
+ produceCount: 1 // 固定生成1个
+ }
+
+ const { data } = await MixTaskService.createTask(taskParams)
if (data) {
message.success('混剪任务提交成功,正在处理中...')
mixModalVisible.value = false
@@ -631,8 +572,8 @@ const handleSingleGroup = (file) => {
}
// 执行批量分组
-const handleBatchGroup = async () => {
- if (!selectedGroupId.value) {
+const handleBatchGroup = async (groupId) => {
+ if (!groupId) {
message.warning('请选择分组')
return
}
@@ -653,13 +594,12 @@ const handleBatchGroup = async () => {
await MaterialGroupService.addFilesToGroups({
fileIds: fileIds,
- groupIds: [selectedGroupId.value]
+ groupIds: [groupId]
})
message.success(successMessage)
// 重置状态
groupModalVisible.value = false
- selectedGroupId.value = null
groupingFileId.value = null
// 如果是批量分组,清除选中状态
@@ -677,7 +617,6 @@ const handleBatchGroup = async () => {
// 取消分组操作
const handleGroupCancel = () => {
groupModalVisible.value = false
- selectedGroupId.value = null
groupingFileId.value = null
}
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileGroupServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileGroupServiceImpl.java
index 3659a54051..4df13bfd92 100644
--- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileGroupServiceImpl.java
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileGroupServiceImpl.java
@@ -81,8 +81,17 @@ public class TikUserFileGroupServiceImpl implements TikUserFileGroupService {
}
}
- log.info("[addFilesToGroups][用户({})添加文件到分组成功,文件数量({}),分组数量({})]",
- userId, fileIds.size(), groupIds.size());
+ // 统一更新 tik_user_file 表的 groupId 字段(使用第一个分组作为主分组)
+ // 重要:这里必须更新 groupId,否则查询时 groupId 仍为空
+ Long primaryGroupId = groupIds.get(0); // 使用第一个分组ID作为主分组
+ userFileMapper.update(
+ new TikUserFileDO().setGroupId(primaryGroupId),
+ new LambdaQueryWrapperX()
+ .in(TikUserFileDO::getId, fileIds)
+ );
+
+ log.info("[addFilesToGroups][用户({})添加文件到分组成功,文件数量({}),分组数量({}),主分组ID({})]",
+ userId, fileIds.size(), groupIds.size(), primaryGroupId);
}
@Override
@@ -104,6 +113,24 @@ public class TikUserFileGroupServiceImpl implements TikUserFileGroupService {
.in(TikUserFileGroupDO::getGroupId, groupIds)
);
+ // 清理 tik_user_file 表的 groupId 字段(当文件不再属于任何分组时)
+ for (Long fileId : fileIds) {
+ // 检查文件是否还有其他分组关联
+ List remainingRelations = userFileGroupMapper.selectList(
+ new LambdaQueryWrapperX()
+ .eq(TikUserFileGroupDO::getFileId, fileId)
+ );
+
+ // 如果没有剩余关联关系,清理 groupId 字段
+ if (CollUtil.isEmpty(remainingRelations)) {
+ userFileMapper.update(
+ new TikUserFileDO().setGroupId(null),
+ new LambdaQueryWrapperX()
+ .eq(TikUserFileDO::getId, fileId)
+ );
+ }
+ }
+
log.info("[removeFilesFromGroups][用户({})从分组移除文件成功,文件数量({}),分组数量({})]",
userId, fileIds.size(), groupIds.size());
}
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignment.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignment.java
index ea042af914..c4ebc48f19 100644
--- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignment.java
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignment.java
@@ -1,10 +1,12 @@
package cn.iocoder.yudao.module.tik.media;
+import cn.iocoder.yudao.module.tik.mix.config.IceProperties;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.aliyun.ice20201109.Client;
import com.aliyun.ice20201109.models.*;
import com.aliyun.teaopenapi.models.Config;
+import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@@ -28,78 +30,35 @@ import java.util.*;
*/
@Slf4j
@Component
+@RequiredArgsConstructor
public class BatchProduceAlignment {
- static final String regionId = "cn-hangzhou";
- static final String bucket = "muye-ai-chat";
+ private final IceProperties properties;
private Client iceClient;
- private final String accessKeyId = "LTAI5tPV9Ag3csf41GZjaLTA";
- private final String accessKeySecret = "kDqlGeJTKw6tJtFYiaY8vQTFuVIQDs";
-
-
- /*public static void main(String[] args) {
- try {
- BatchProduceAlignment batchProduce = new BatchProduceAlignment();
- batchProduce.initClient();
- // 文字素材
- String text = "人们懂得用五味杂陈形容人生,因为懂得味道是每个人心中固守的情怀。在这个时代,每一个人都经历了太多的苦痛和喜悦,人们总会将苦涩藏在心里,而把幸福变成食物,呈现在四季的餐桌之上";
- // 视频素材
- String[] videoArray = new String[]{
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/food/f1.mp4",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/food/f2.mp4",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/food/f3.mp4",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/food/f4.mp4",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/food/f5.mp4",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/food/f6.mp4",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/food/f7.mp4",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/food/f8.mp4",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/food/f9.mp4",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/food/f10.mp4",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/food/f11.mp4",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/food/f12.mp4",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/food/f13.mp4",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/food/f14.mp4",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/food/f15.mp4",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/food/f16.mp4",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/food/f17.mp4",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/food/f18.mp4"
- };
- // 背景音乐素材
- String[] bgMusicArray = new String[]{
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/music/m1.wav",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/music/m2.wav",
- "https://ice-document-materials.oss-cn-shanghai.aliyuncs.com/test_media/music/m3.wav"
- };
- // 视频标题
- String title = "舌尖上的美食";
- // 生成的成片数
- int produceCount = 3;
- List mediaList = batchProduce.batchProduceAlignment(title,text,videoArray,bgMusicArray,produceCount);
- mediaList.forEach(System.out::println);
- } catch (Exception e) {
- System.out.println("Produce failed. Exception: " + e.toString());
- }
- }*/
-
-
public void initClient() throws Exception {
- log.info("初始化阿里云 ICE 客户端...");
- // 阿里云账号AccessKey拥有所有API的访问权限,建议您使用RAM用户进行API访问或日常运维。
- // 本示例以将AccessKey ID和 AccessKey Secret保存在环境变量为例说明。配置方法请参见:https://help.aliyun.com/zh/sdk/developer-reference/v2-manage-access-credentials?spm=a2c4g.11186623.0.0.423350fbOTFdOB#2a38e5c14b4em
- com.aliyun.credentials.Client credentialClient = new com.aliyun.credentials.Client();
- Config config = new Config();
- config.setCredential(credentialClient);
- // 如需硬编码AccessKey ID和AccessKey Secret,代码如下,但强烈建议不要把AccessKey ID和AccessKey Secret保存到工程代码里,否则可能导致AccessKey泄露,威胁您账号下所有资源的安全。
- config.accessKeyId = accessKeyId;
- config.accessKeySecret = accessKeySecret;
- config.endpoint = "ice." + regionId + ".aliyuncs.com";
- config.regionId = regionId;
- iceClient = new Client(config);
- log.info("ICE 客户端初始化成功");
+ if (iceClient == null) {
+ synchronized (this) {
+ if (iceClient == null) {
+ if (!properties.isEnabled()) {
+ log.error("ICE 配置未启用或未配置 AccessKey");
+ throw new IllegalStateException("未配置 ICE AccessKey");
+ }
+ log.info("初始化阿里云 ICE 客户端... regionId={}, bucket={}",
+ properties.getRegionId(), properties.getBucket());
+ Config config = new Config();
+ config.accessKeyId = properties.getAccessKeyId();
+ config.accessKeySecret = properties.getAccessKeySecret();
+ config.endpoint = "ice." + properties.getRegionId() + ".aliyuncs.com";
+ config.regionId = properties.getRegionId();
+ iceClient = new Client(config);
+ log.info("ICE 客户端初始化成功");
+ }
+ }
+ }
}
- public List batchProduceAlignment(String title,String text,String[] videoArray,String[] bgMusicArray,int produceCount) throws Exception {
+ public List batchProduceAlignment(String title, String[] videoArray, int produceCount) throws Exception {
// 初始化 ICE 客户端
if (iceClient == null) {
initClient();
@@ -107,48 +66,72 @@ public class BatchProduceAlignment {
// 批量提交任务,返回 "jobId:url" 格式
List jobIdWithUrls = new ArrayList<>();
- for (int i = 0; i < produceCount; i++) {
- String jobIdWithUrl = produceSingleVideo(title, text, videoArray, bgMusicArray);
+
+ if (produceCount <= 1) {
+ // 生成1个视频,包含所有片段
+ String jobIdWithUrl = produceSingleVideo(title, videoArray);
jobIdWithUrls.add(jobIdWithUrl);
+ } else {
+ // 生成多个视频:将视频数组分成多份,每份生成一个视频
+ int videoCount = videoArray.length;
+
+ // 计算每份的起始和结束索引
+ int videosPerGroup = Math.max(1, videoCount / produceCount);
+ int remainder = videoCount % produceCount;
+
+ int start = 0;
+ for (int i = 0; i < produceCount; i++) {
+ // 计算当前组的视频数量(尽可能平均分配)
+ int groupSize = videosPerGroup + (i < remainder ? 1 : 0);
+
+ // 提取当前组的视频片段
+ String[] groupVideos = Arrays.copyOfRange(videoArray, start, start + groupSize);
+
+ // 生成单个视频
+ String jobIdWithUrl = produceSingleVideo(title, groupVideos);
+ jobIdWithUrls.add(jobIdWithUrl);
+
+ start += groupSize;
+ }
}
+
// 改为异步模式,不在这里等待
return jobIdWithUrls;
}
- public String produceSingleVideo(String title, String text, String[] videoArray, String[] bgMusicArray) throws Exception {
+ public String produceSingleVideo(String title, String[] videoArray) throws Exception {
// 初始化 ICE 客户端
if (iceClient == null) {
initClient();
}
- text = text.replace(",", "。");
- text = text.replace("\n", "。");
- String[] sentenceArray = text.split("。");
-
+ // 纯画面模式:仅拼接视频片段,添加空的音频轨道
JSONArray videoClipArray = new JSONArray();
- JSONArray audioClipArray = new JSONArray();
- List videoList = Arrays.asList(videoArray);
- Collections.shuffle(videoList);
- for (int i = 0; i < sentenceArray.length; i++) {
- String sentence = sentenceArray[i];
+ // 按顺序拼接视频片段(不随机打乱)
+ for (int i = 0; i < videoArray.length; i++) {
String clipId = "clip" + i;
- String videoUrl = videoList.get(i);
- String videoClip = "{\"MediaURL\":\""+videoUrl+"\",\"ReferenceClipId\":\""+clipId+"\",\"Effects\":[{\"Type\":\"Background\",\"SubType\":\"Blur\",\"Radius\":0.1}]}";
+ String videoUrl = videoArray[i];
+
+ // 验证视频URL必须是阿里云OSS地址
+ if (!videoUrl.contains(".aliyuncs.com")) {
+ log.error("[ICE][视频URL不是阿里云OSS地址][视频{}: {}]", i + 1, videoUrl);
+ throw new IllegalArgumentException("视频URL必须是阿里云OSS地址,当前URL: " + videoUrl);
+ }
+
+ log.debug("[ICE][添加视频片段][{}: {}]", i + 1, videoUrl);
+ // 每个视频片段添加静音效果(Volume: 0)
+ String videoClip = "{\"MediaURL\":\""+videoUrl+"\",\"ReferenceClipId\":\""+clipId+"\",\"Volume\":0,\"Effects\":[{\"Type\":\"Background\",\"SubType\":\"Blur\",\"Radius\":0.1}]}";
videoClipArray.add(JSONObject.parseObject(videoClip));
- String audioClip = "{\"Type\":\"AI_TTS\",\"Content\":\"" + sentence + "\",\"Voice\":\"zhichu\",\"ClipId\":\""+clipId+"\",\"Effects\":[{\"Type\":\"AI_ASR\",\"Font\":\"Alibaba PuHuiTi\",\"Alignment\":\"TopCenter\",\"Y\":0.75,\"FontSize\":55,\"FontColor\":\"#ffffff\",\"AdaptMode\":\"AutoWrap\",\"TextWidth\":0.8,\"Outline\":2,\"OutlineColour\":\"#000000\"}]}";
- audioClipArray.add(JSONObject.parseObject(audioClip));
}
- String subtitleTrack = "{\"SubtitleTrackClips\":[{\"Type\":\"Text\",\"Font\":\"HappyZcool-2016\",\"Content\":\""+title+"\",\"FontSize\":80,\"FontColor\":\"#ffffff\",\"Y\":0.15,\"Alignment\":\"TopCenter\",\"EffectColorStyle\":\"CS0004-000005\",\"FontFace\":{\"Bold\":true,\"Italic\":false,\"Underline\":false}}]}";
-
- int bgMusicIndex = (int)(Math.random() * bgMusicArray.length);
- String bgMusicUrl = bgMusicArray[bgMusicIndex];
- String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":"+videoClipArray.toJSONString()+"}],\"AudioTracks\":[{\"AudioTrackClips\":"+audioClipArray.toJSONString()+"},{\"AudioTrackClips\":[{\"MediaURL\":\""+bgMusicUrl+"\"}]}],\"SubtitleTracks\":[" + subtitleTrack + "]}";
+ // 添加空的音频轨道(避免ICE报错)
+ JSONArray emptyAudioClipArray = new JSONArray();
+ String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":"+videoClipArray.toJSONString()+"}],\"AudioTracks\":[{\"AudioTrackClips\":"+emptyAudioClipArray.toJSONString()+"}]}";
//
String targetFileName = UUID.randomUUID().toString().replace("-", "");
- String outputMediaUrl = "http://" + bucket + ".oss-" + regionId + ".aliyuncs.com/ice_output/" + targetFileName + ".mp4";
+ String outputMediaUrl = "http://" + properties.getBucket() + ".oss-" + properties.getRegionId() + ".aliyuncs.com/ice_output/" + targetFileName + ".mp4";
int width = 720;
int height = 1280;
String outputMediaConfig = "{\"MediaURL\":\"" + outputMediaUrl + "\",\"Width\":"+width+",\"Height\":"+height+"}";
@@ -156,9 +139,13 @@ public class BatchProduceAlignment {
SubmitMediaProducingJobRequest request = new SubmitMediaProducingJobRequest();
request.setTimeline(timeline);
request.setOutputMediaConfig(outputMediaConfig);
+
+ log.info("[ICE][提交任务][视频数量={}, timeline={}]", videoArray.length, timeline);
SubmitMediaProducingJobResponse response = iceClient.submitMediaProducingJob(request);
- log.info("start job. jobid: " + response.getBody().getJobId() + ", outputMediaUrl: " + outputMediaUrl);
- return response.getBody().getJobId() + " : " + outputMediaUrl;
+
+ String jobId = response.getBody().getJobId();
+ log.info("[ICE][任务提交成功][jobId={}, outputMediaUrl={}]", jobId, outputMediaUrl);
+ return jobId + " : " + outputMediaUrl;
}
/**
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/controller/BatchProduceAlignmentController.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/controller/BatchProduceAlignmentController.java
index 2c18b895cb..7d4acc33d0 100644
--- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/controller/BatchProduceAlignmentController.java
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/controller/BatchProduceAlignmentController.java
@@ -19,28 +19,24 @@ import java.util.List;
@Slf4j
public class BatchProduceAlignmentController {
+ private final BatchProduceAlignment batchProduceAlignment;
+
@PostMapping("/batchProduceAlignment")
- @Operation(summary = "视频混剪", description = "视频混剪")
+ @Operation(summary = "视频混剪(纯画面模式)", description = "仅拼接视频片段,无配音、无背景音乐")
public Object batchProduceAlignment(
//视频标题
@RequestParam String title,
- //文字素材
- @RequestParam String text,
// 视频素材
@RequestParam String[] videoArray,
- // 背景音乐素材
- @RequestParam String[] bgMusicArray,
// 生成的成片数
int produceCount) {
try {
- BatchProduceAlignment batchProduce = new BatchProduceAlignment();
- batchProduce.initClient();
- List jobIds = batchProduce.batchProduceAlignment(title,text,videoArray,bgMusicArray,produceCount);
+ // 纯画面模式:仅传入视频数组,无需text和bgMusicArray
+ List jobIds = batchProduceAlignment.batchProduceAlignment(title, videoArray, produceCount);
return CommonResult.success(jobIds);
} catch (Exception e) {
return CommonResult.error(new ErrorCode(500, "Produce failed. Exception: " + e));
}
-
}
}
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/constants/MixTaskConstants.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/constants/MixTaskConstants.java
index 0d4e39b54f..c0149c91da 100644
--- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/constants/MixTaskConstants.java
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/constants/MixTaskConstants.java
@@ -24,8 +24,15 @@ public class MixTaskConstants {
/**
* 定时任务配置
+ * 改为每2分钟检查一次,降低API调用频率
*/
- public static final String CRON_CHECK_STATUS = "*/30 * * * * ?";
+ public static final String CRON_CHECK_STATUS = "0 */2 * * * ?";
+
+ /**
+ * 任务状态检查优化配置
+ */
+ public static final int CHECK_HOURS_LIMIT = 6; // 只检查最近6小时内的任务
+ public static final int CHECK_BATCH_SIZE = 50; // 每次最多检查50个任务
private MixTaskConstants() {
// 防止实例化
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java
index 980330a8e9..86bbf81149 100644
--- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java
@@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.tik.mix.service;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.module.tik.mix.client.IceClient;
import cn.iocoder.yudao.module.tik.mix.constants.MixTaskConstants;
import cn.iocoder.yudao.module.tik.mix.dal.dataobject.MixTaskDO;
import cn.iocoder.yudao.module.tik.mix.dal.mysql.MixTaskMapper;
@@ -13,6 +14,8 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -28,6 +31,7 @@ public class MixTaskServiceImpl implements MixTaskService {
private final MixTaskMapper mixTaskMapper;
private final BatchProduceAlignment batchProduceAlignment;
+ private final IceClient iceClient;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -149,7 +153,11 @@ public class MixTaskServiceImpl implements MixTaskService {
// 3. 重新提交到ICE
CompletableFuture.runAsync(() -> {
try {
- MixTaskSaveReqVO saveReqVO = BeanUtils.toBean(existTask, MixTaskSaveReqVO.class);
+ // 手动构建请求对象(纯画面模式:无需text和bgMusicUrls)
+ MixTaskSaveReqVO saveReqVO = new MixTaskSaveReqVO();
+ saveReqVO.setTitle(existTask.getTitle());
+ saveReqVO.setVideoUrls(existTask.getVideoUrlList());
+ saveReqVO.setProduceCount(existTask.getProduceCount());
submitToICE(id, saveReqVO);
} catch (Exception e) {
log.error("重新提交任务失败,任务ID: {}", id, e);
@@ -195,18 +203,29 @@ public class MixTaskServiceImpl implements MixTaskService {
public void checkTaskStatusBatch() {
log.debug("开始批量检查任务状态");
- // 查询所有运行中的任务
+ // 性能优化点:
+ // 1. 时间范围限制:只检查最近6小时内的任务(避免检查历史任务)
+ // 2. 数量限制:每次最多检查50个任务(避免单次查询过多)
+ // 3. 索引优化:需要为 create_time 字段添加索引(见 V20251129 迁移文件)
+ // 4. 频率优化:定时任务频率从30秒改为2分钟
+ LocalDateTime startTime = LocalDateTime.now().minusHours(MixTaskConstants.CHECK_HOURS_LIMIT);
+
+ // 查询运行中的任务(限制时间和数量)
List runningTasks = mixTaskMapper.selectList(
new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX()
.eq(MixTaskDO::getStatus, MixTaskConstants.STATUS_RUNNING)
+ .ge(MixTaskDO::getCreateTime, startTime)
+ .orderByDesc(MixTaskDO::getCreateTime)
+ .last("LIMIT " + MixTaskConstants.CHECK_BATCH_SIZE) // 限制数量
);
if (runningTasks.isEmpty()) {
- log.debug("没有运行中的任务,跳过检查");
+ log.debug("没有最近{}小时内运行中的任务,跳过检查", MixTaskConstants.CHECK_HOURS_LIMIT);
return;
}
- log.info("发现 {} 个运行中的任务,开始检查状态", runningTasks.size());
+ log.info("发现 {} 个最近{}小时内的运行中任务,开始检查状态(最多检查{}个)",
+ runningTasks.size(), MixTaskConstants.CHECK_HOURS_LIMIT, MixTaskConstants.CHECK_BATCH_SIZE);
// 逐个检查任务状态
for (MixTaskDO task : runningTasks) {
@@ -219,6 +238,7 @@ public class MixTaskServiceImpl implements MixTaskService {
}
} catch (Exception e) {
log.error("检查任务状态失败,任务ID: {}", task.getId(), e);
+ // 单个任务失败不影响其他任务
}
}
@@ -230,16 +250,52 @@ public class MixTaskServiceImpl implements MixTaskService {
log.debug("同步任务状态,任务ID: {}, jobId: {}", taskId, jobId);
try {
- // TODO: 调用阿里云 ICE API 查询任务状态
- // 这里需要集成具体的 ICE SDK 或 HTTP API
+ // 检查任务是否超时(超过12小时则标记为失败)
+ MixTaskDO task = mixTaskMapper.selectById(taskId);
+ if (task != null) {
+ LocalDateTime createTime = task.getCreateTime();
+ if (createTime != null) {
+ long hoursPassed = ChronoUnit.HOURS.between(createTime, LocalDateTime.now());
+ if (hoursPassed > 12) {
+ log.warn("[ICE][任务超时,自动标记为失败][taskId={}, 已运行{}小时]", taskId, hoursPassed);
+ updateTaskError(taskId, "任务执行超时(超过12小时)");
+ return;
+ }
+ }
+ }
- // 模拟状态检查逻辑
- // String status = iceClient.getJobStatus(jobId);
- // String progress = iceClient.getJobProgress(jobId);
- // String outputUrl = iceClient.getJobOutput(jobId);
+ // 调用阿里云 ICE API 查询任务状态
+ String status = iceClient.getMediaProducingJobStatus(jobId);
+ log.debug("[ICE][查询到任务状态][taskId={}, jobId={}, status={}]", taskId, jobId, status);
- // 根据返回的状态更新任务
- // updateTaskStatus(taskId, status, progress, outputUrl);
+ // 根据ICE状态更新任务
+ if ("Success".equalsIgnoreCase(status) || "success".equalsIgnoreCase(status)) {
+ // 任务成功完成,更新为100%
+ log.info("[ICE][任务执行成功][taskId={}, jobId={}]", taskId, jobId);
+ updateTaskStatus(taskId, MixTaskConstants.STATUS_SUCCESS, MixTaskConstants.PROGRESS_COMPLETED);
+ } else if ("Failed".equalsIgnoreCase(status) || "failed".equalsIgnoreCase(status) || "Failure".equalsIgnoreCase(status)) {
+ // 任务失败 - 获取详细错误信息
+ String errorMsg = "ICE任务执行失败";
+ try {
+ // 尝试获取更详细的失败信息(如果ICE API支持)
+ errorMsg = "ICE任务执行失败,状态: " + status;
+ } catch (Exception ex) {
+ log.warn("[ICE][获取详细失败信息失败][taskId={}]", taskId, ex);
+ }
+ log.error("[ICE][任务执行失败][taskId={}, jobId={}, status={}]", taskId, jobId, status);
+ updateTaskError(taskId, errorMsg);
+ } else if ("Running".equalsIgnoreCase(status) || "running".equalsIgnoreCase(status) || "Processing".equalsIgnoreCase(status)) {
+ // 任务仍在运行,更新进度为70%
+ updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, 70);
+ log.debug("[ICE][任务执行中][taskId={}, jobId={}, progress=70%]", taskId, jobId);
+ } else if ("Pending".equalsIgnoreCase(status) || "pending".equalsIgnoreCase(status)) {
+ // 任务等待中,更新进度为60%
+ updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, 60);
+ log.debug("[ICE][任务等待中][taskId={}, jobId={}, progress=60%]", taskId, jobId);
+ } else {
+ // 未知状态,记录日志但不更新
+ log.warn("[ICE][未知任务状态][taskId={}, jobId={}, status={}]", taskId, jobId, status);
+ }
} catch (Exception e) {
log.error("同步任务状态失败,任务ID: {}, jobId: {}", taskId, jobId, e);
@@ -276,14 +332,11 @@ public class MixTaskServiceImpl implements MixTaskService {
// 2. 转换为ICE需要的参数格式
String[] videoArray = createReqVO.getVideoUrls().toArray(new String[0]);
- String[] bgMusicArray = createReqVO.getBgMusicUrls().toArray(new String[0]);
- // 3. 调用ICE批量生成接口
+ // 3. 调用ICE批量生成接口(纯画面模式:无需text和bgMusic)
List jobIdWithUrls = batchProduceAlignment.batchProduceAlignment(
createReqVO.getTitle(),
- createReqVO.getText(),
videoArray,
- bgMusicArray,
createReqVO.getProduceCount()
);
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/util/MixTaskUtils.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/util/MixTaskUtils.java
index 5994b5fbb7..522abf62ba 100644
--- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/util/MixTaskUtils.java
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/util/MixTaskUtils.java
@@ -26,9 +26,9 @@ public class MixTaskUtils {
MixTaskDO task = new MixTaskDO();
task.setUserId(userId);
task.setTitle(reqVO.getTitle());
- task.setText(reqVO.getText());
+ task.setText(null); // 纯画面模式,不需要文案
task.setVideoUrlList(reqVO.getVideoUrls());
- task.setBgMusicUrlList(reqVO.getBgMusicUrls());
+ task.setBgMusicUrlList(null); // 纯画面模式,不需要背景音乐
task.setProduceCount(reqVO.getProduceCount());
task.setStatus(MixTaskConstants.STATUS_PENDING);
task.setProgress(0);
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/vo/MixTaskSaveReqVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/vo/MixTaskSaveReqVO.java
index 392a89f7c5..8b0dfbdd63 100644
--- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/vo/MixTaskSaveReqVO.java
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/vo/MixTaskSaveReqVO.java
@@ -16,19 +16,11 @@ public class MixTaskSaveReqVO {
@NotBlank(message = "视频标题不能为空")
private String title;
- @Schema(description = "文案内容", required = true, example = "人们懂得用五味杂陈形容人生...")
- @NotBlank(message = "文案内容不能为空")
- private String text;
-
@Schema(description = "视频素材URL列表", required = true)
@NotEmpty(message = "视频素材不能为空")
private List videoUrls;
- @Schema(description = "背景音乐URL列表", required = true)
- @NotEmpty(message = "背景音乐不能为空")
- private List bgMusicUrls;
-
- @Schema(description = "生成数量", required = true, example = "3")
+ @Schema(description = "生成数量", required = true, example = "1")
@NotNull(message = "生成数量不能为空")
- private Integer produceCount;
+ private Integer produceCount = 1; // 默认生成1个
}
diff --git a/yudao-server/src/main/resources/application-local.yaml b/yudao-server/src/main/resources/application-local.yaml
index 3c0fc150a9..64c5d3d204 100644
--- a/yudao-server/src/main/resources/application-local.yaml
+++ b/yudao-server/src/main/resources/application-local.yaml
@@ -225,6 +225,12 @@ wx:
yudao:
cosyvoice:
api-key: sk-10c746f8cb8640738f8d6b71af699003
+ ice:
+ access-key-id: LTAI5tPV9Ag3csf41GZjaLTA
+ access-key-secret: kDqlGeJTKw6tJtFYiaY8vQTFuVIQDs
+ region-id: cn-hangzhou
+ bucket: muye-ai-chat
+ enabled: true
# tik:
# latentsync:
# api-key: ${TIK_LATENTSYNC_API_KEY:} # 建议通过环境变量覆盖仓库默认值