优化
This commit is contained in:
@@ -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, {
|
||||
|
||||
@@ -12,9 +12,8 @@
|
||||
<div class="mix-modal__summary">
|
||||
<p>视频分组:{{ getGroupName(videoGroupId) || '未选择' }}</p>
|
||||
<p>视频数量:{{ videoGroupFiles.length }} 个</p>
|
||||
<p>背景音乐:{{ selectedBgMusic?.fileName || '未选择' }}</p>
|
||||
<p style="margin-top: 8px; font-size: 12px; color: var(--color-text-3);">
|
||||
系统将根据文案自动生成配音并匹配视频片段
|
||||
纯画面模式:仅拼接视频片段,无配音、无背景音乐
|
||||
</p>
|
||||
</div>
|
||||
<a-form layout="vertical">
|
||||
@@ -31,41 +30,12 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="选择背景音乐" required>
|
||||
<a-select
|
||||
v-model:value="selectedBgMusic"
|
||||
placeholder="请选择背景音乐"
|
||||
style="width: 100%"
|
||||
show-search
|
||||
:filter-option="(input, option) => option.children.toLowerCase().includes(input.toLowerCase())"
|
||||
>
|
||||
<a-select-option v-for="audio in allAudioFiles" :key="audio.id" :value="audio.id">
|
||||
{{ audio.fileName }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="视频标题" required>
|
||||
<a-input v-model:value="mixForm.title" placeholder="请输入生成视频标题" />
|
||||
</a-form-item>
|
||||
<a-form-item label="文案内容" required>
|
||||
<a-textarea
|
||||
v-model:value="mixForm.text"
|
||||
placeholder="请输入文案(每句话用句号分隔)"
|
||||
:rows="4"
|
||||
/>
|
||||
<a-input v-model:value="mixForm.title" placeholder="请输入生成视频标题(仅用于记录)" />
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--color-text-3);">
|
||||
文案将用于生成 TTS 配音,每句话对应一个视频片段
|
||||
标题仅用于任务记录,不会在视频上显示
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="生成成片数量" required>
|
||||
<a-input-number
|
||||
v-model:value="mixForm.produceCount"
|
||||
:min="1"
|
||||
:max="10"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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<TikUserFileDO>()
|
||||
.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<TikUserFileGroupDO> remainingRelations = userFileGroupMapper.selectList(
|
||||
new LambdaQueryWrapperX<TikUserFileGroupDO>()
|
||||
.eq(TikUserFileGroupDO::getFileId, fileId)
|
||||
);
|
||||
|
||||
// 如果没有剩余关联关系,清理 groupId 字段
|
||||
if (CollUtil.isEmpty(remainingRelations)) {
|
||||
userFileMapper.update(
|
||||
new TikUserFileDO().setGroupId(null),
|
||||
new LambdaQueryWrapperX<TikUserFileDO>()
|
||||
.eq(TikUserFileDO::getId, fileId)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[removeFilesFromGroups][用户({})从分组移除文件成功,文件数量({}),分组数量({})]",
|
||||
userId, fileIds.size(), groupIds.size());
|
||||
}
|
||||
|
||||
@@ -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<String> 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<String> batchProduceAlignment(String title,String text,String[] videoArray,String[] bgMusicArray,int produceCount) throws Exception {
|
||||
public List<String> batchProduceAlignment(String title, String[] videoArray, int produceCount) throws Exception {
|
||||
// 初始化 ICE 客户端
|
||||
if (iceClient == null) {
|
||||
initClient();
|
||||
@@ -107,48 +66,72 @@ public class BatchProduceAlignment {
|
||||
|
||||
// 批量提交任务,返回 "jobId:url" 格式
|
||||
List<String> 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<String> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<String> jobIds = batchProduce.batchProduceAlignment(title,text,videoArray,bgMusicArray,produceCount);
|
||||
// 纯画面模式:仅传入视频数组,无需text和bgMusicArray
|
||||
List<String> jobIds = batchProduceAlignment.batchProduceAlignment(title, videoArray, produceCount);
|
||||
return CommonResult.success(jobIds);
|
||||
} catch (Exception e) {
|
||||
return CommonResult.error(new ErrorCode(500, "Produce failed. Exception: " + e));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
// 防止实例化
|
||||
|
||||
@@ -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<MixTaskDO> runningTasks = mixTaskMapper.selectList(
|
||||
new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<MixTaskDO>()
|
||||
.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<String> jobIdWithUrls = batchProduceAlignment.batchProduceAlignment(
|
||||
createReqVO.getTitle(),
|
||||
createReqVO.getText(),
|
||||
videoArray,
|
||||
bgMusicArray,
|
||||
createReqVO.getProduceCount()
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String> videoUrls;
|
||||
|
||||
@Schema(description = "背景音乐URL列表", required = true)
|
||||
@NotEmpty(message = "背景音乐不能为空")
|
||||
private List<String> bgMusicUrls;
|
||||
|
||||
@Schema(description = "生成数量", required = true, example = "3")
|
||||
@Schema(description = "生成数量", required = true, example = "1")
|
||||
@NotNull(message = "生成数量不能为空")
|
||||
private Integer produceCount;
|
||||
private Integer produceCount = 1; // 默认生成1个
|
||||
}
|
||||
|
||||
@@ -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:} # 建议通过环境变量覆盖仓库默认值
|
||||
|
||||
Reference in New Issue
Block a user