From 853bedcb23548bb0e12d05af26bd2a379447b4fe Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Sat, 29 Nov 2025 21:53:17 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/web-gold/src/api/mix.js | 12 +- .../components/material/MaterialMixModal.vue | 73 +------- frontend/app/web-gold/src/views/dh/Video.vue | 19 +- .../src/views/material/MaterialList.vue | 93 ++-------- .../service/TikUserFileGroupServiceImpl.java | 31 +++- .../tik/media/BatchProduceAlignment.java | 165 ++++++++---------- .../BatchProduceAlignmentController.java | 14 +- .../tik/mix/constants/MixTaskConstants.java | 9 +- .../tik/mix/service/MixTaskServiceImpl.java | 85 +++++++-- .../module/tik/mix/util/MixTaskUtils.java | 4 +- .../module/tik/mix/vo/MixTaskSaveReqVO.java | 12 +- .../src/main/resources/application-local.yaml | 6 + 12 files changed, 239 insertions(+), 284 deletions(-) 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:} # 建议通过环境变量覆盖仓库默认值