From 161d9568a9ee27d245b435087bb775dcecd35f90 Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Sat, 22 Nov 2025 01:42:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=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/views/dh/Video.vue | 153 +++++++++++++++--- .../src/views/material/MaterialList.vue | 122 ++++++++++---- .../tik/voice/client/LatentsyncClient.java | 14 +- .../service/DigitalHumanTaskServiceImpl.java | 18 ++- 4 files changed, 240 insertions(+), 67 deletions(-) diff --git a/frontend/app/web-gold/src/views/dh/Video.vue b/frontend/app/web-gold/src/views/dh/Video.vue index b9824f5ac9..cdd2f27ae2 100644 --- a/frontend/app/web-gold/src/views/dh/Video.vue +++ b/frontend/app/web-gold/src/views/dh/Video.vue @@ -3,11 +3,12 @@ defineOptions({ name: 'DigitalVideoPage' }) import { ref, computed, onMounted, watch, onUnmounted } from 'vue' import { message } from 'ant-design-vue' import { InboxOutlined } from '@ant-design/icons-vue' -import { useVoiceCopyStore } from '@/stores/voiceCopy' import { VoiceService } from '@/api/voice' import { MaterialService } from '@/api/material' import { createDigitalHumanTask, getDigitalHumanTask, cancelTask, retryTask } from '@/api/digitalHuman' +// 导入 voiceStore 用于获取用户音色 +import { useVoiceCopyStore } from '@/stores/voiceCopy' const voiceStore = useVoiceCopyStore() // 状态管理 @@ -35,7 +36,9 @@ const ttsText = ref('') const selectedTtsVoice = ref('') const speechRate = ref(1.0) const instruction = ref('neutral') // 指令参数,用于控制音色风格 -const voiceSource = ref('user') +const emotion = ref('neutral') // 情感参数 +const emotionActive = ref(false) // 是否激活情感tab(false为指令,true为情感) +const voiceSource = ref('system') // 音色来源:user 或 system // 系统音色库(使用CosyVoice v3-flash模型) const SYSTEM_VOICES = [ @@ -103,6 +106,10 @@ const setVoiceSource = (source) => { const selectVoiceProfile = (voice) => { selectedTtsVoice.value = `${voice.source}-${voice.id}` + // 选系统音色时也更新对应的instruction + if (voice.source === 'system' && voice.defaultInstruction) { + instruction.value = voice.defaultInstruction + } } // 音频播放 @@ -155,8 +162,7 @@ const triggerVoicePreview = async (voice) => { const buildPreviewParams = (voice) => { if (voice.source === 'user') { - // 使用voiceConfigId,让后端查询数据库获取文件URL和transcriptionText - // 用户音色不传instruction + // 用户音色:使用voiceConfigId,不传instruction const configId = voice.rawId || extractIdFromString(voice.id) if (!configId) { message.error('配音配置无效') @@ -164,19 +170,29 @@ const buildPreviewParams = (voice) => { } return { voiceConfigId: configId, - inputText: ttsText.value, // 传递用户输入的文本 + inputText: ttsText.value, speechRate: speechRate.value || 1.0, audioFormat: 'mp3' } } else { - // 系统音色使用用户选择的instruction - return { + // 系统音色:根据是否选择instruction或emotion来决定传递哪个参数 + const params = { voiceId: voice.voiceId, - inputText: ttsText.value, // 传递用户输入的文本 - instruction: instruction.value && instruction.value !== 'neutral' ? instruction.value : (voice.defaultInstruction || '请用自然流畅的语调朗读'), + inputText: ttsText.value, speechRate: speechRate.value || 1.0, audioFormat: 'mp3' } + + // instruction和emotion只能选一个传递 + if (instruction.value && instruction.value !== 'neutral') { + params.instruction = instruction.value + } else if (emotion.value && emotion.value !== 'neutral') { + params.emotion = emotion.value + } else if (voice.defaultInstruction) { + params.instruction = voice.defaultInstruction + } + + return params } } @@ -200,8 +216,8 @@ const handleSynthesizeVoice = async () => { audioFormat: 'mp3' } - // 如果是用户配音,使用voiceConfigId让后端查询,不传instruction if (voice.source === 'user') { + // 用户音色:使用voiceConfigId,不传instruction const configId = voice.rawId || extractIdFromString(voice.id) if (!configId) { message.warning('音色配置无效') @@ -209,14 +225,22 @@ const handleSynthesizeVoice = async () => { } params.voiceConfigId = configId } else { - // 使用系统音色voiceId和用户选择的instruction + // 系统音色:使用voiceId,根据是否选择instruction或emotion来决定传递哪个参数 const voiceId = voice.voiceId || voice.rawId if (!voiceId) { message.warning('音色配置无效') return } params.voiceId = voiceId - params.instruction = instruction.value && instruction.value !== 'neutral' ? instruction.value : (voice.defaultInstruction || '请用自然流畅的语调朗读') + + // instruction和emotion只能选一个传递 + if (instruction.value && instruction.value !== 'neutral') { + params.instruction = instruction.value + } else if (emotion.value && emotion.value !== 'neutral') { + params.emotion = emotion.value + } else if (voice.defaultInstruction) { + params.instruction = voice.defaultInstruction + } } const res = await VoiceService.synthesize(params) @@ -317,19 +341,41 @@ const generateVideo = async () => { message.destroy() - // 2. 创建数字人任务(简化:只使用voiceId,后端实时TTS) + // 2. 创建数字人任务 const taskData = { taskName: `数字人任务_${Date.now()}`, videoFileId: videoFileId, - // 音频由后端实时合成,使用voiceId - voiceId: voice.voiceId || voice.rawId, - inputText: ttsText.value, // 文本内容(用于TTS合成) + inputText: ttsText.value, speechRate: speechRate.value, - instruction: voice.source === 'user' ? undefined : (instruction.value && instruction.value !== 'neutral' ? instruction.value : (voice.defaultInstruction || '请用自然流畅的语调朗读')), + volume: 0, guidanceScale: 1, seed: 8888 } + if (voice.source === 'user') { + // 用户音色:使用voiceConfigId,不传instruction + const configId = voice.rawId || extractIdFromString(voice.id) + if (!configId) { + message.warning('音色配置无效') + return + } + taskData.voiceConfigId = configId + } else { + // 系统音色:使用voiceId,根据是否选择instruction或emotion来决定传递哪个参数 + taskData.voiceId = voice.voiceId + + // instruction和emotion只能选一个传递 + if (instruction.value && instruction.value !== 'neutral') { + taskData.instruction = instruction.value + } else if (emotion.value && emotion.value !== 'neutral') { + taskData.emotion = emotion.value + } else if (voice.defaultInstruction) { + taskData.instruction = voice.defaultInstruction + } else { + taskData.emotion = 'neutral' + } + } + message.loading('正在创建任务...', 0) const createRes = await createDigitalHumanTask(taskData) message.destroy() @@ -724,16 +770,55 @@ let previewObjectUrl = ''
-
指令
+
情感
+
+ + +
+
@@ -1091,6 +1176,28 @@ let previewObjectUrl = '' gap: 8px; } +.control-tabs { + display: inline-flex; + border: 1px solid rgba(59, 130, 246, 0.2); + border-radius: 20px; + overflow: hidden; + margin-bottom: 8px; +} + +.tab-btn { + padding: 6px 20px; + border: none; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + font-size: 12px; +} + +.tab-btn.active { + background: rgba(59, 130, 246, 0.2); + color: var(--color-text); +} + .emotion-btn { padding: 8px 16px; border: 1px solid rgba(59, 130, 246, 0.2); diff --git a/frontend/app/web-gold/src/views/material/MaterialList.vue b/frontend/app/web-gold/src/views/material/MaterialList.vue index 0397e61001..21d9664116 100644 --- a/frontend/app/web-gold/src/views/material/MaterialList.vue +++ b/frontend/app/web-gold/src/views/material/MaterialList.vue @@ -31,7 +31,20 @@
- + + 全部分类 + 视频 + 生成 + 音频 + 混剪 + 配音 + + 查询 @@ -102,13 +116,9 @@ 图片 文件
- -
- + +
+
@@ -195,7 +205,8 @@ import { message, Modal } from 'ant-design-vue' import { UploadOutlined, SearchOutlined, - FileOutlined + FileOutlined, + DeleteOutlined } from '@ant-design/icons-vue' import { MaterialService } from '@/api/material' import { MixService } from '@/api/mix' @@ -213,6 +224,7 @@ const mixing = ref(false) // 筛选条件 const filters = reactive({ + fileCategory: 'video', // 默认分类为视频 fileName: '', createTime: undefined }) @@ -229,8 +241,10 @@ const buildQueryParams = () => { const params = { pageNo: pagination.pageNo, pageSize: pagination.pageSize, - ...filters + fileCategory: filters.fileCategory || undefined, + fileName: filters.fileName || undefined } + // 处理日期范围 if (filters.createTime && Array.isArray(filters.createTime) && filters.createTime.length === 2) { params.createTime = [ @@ -238,6 +252,7 @@ const buildQueryParams = () => { `${filters.createTime[1]} 23:59:59` ] } + return params } @@ -332,20 +347,44 @@ const handleBatchDelete = () => { }) } -// 选择文件(简化逻辑) -const handleSelectFile = (fileId, checked) => { - const index = selectedFileIds.value.indexOf(fileId) - if (checked && index === -1) { - selectedFileIds.value.push(fileId) - } else if (!checked && index > -1) { - selectedFileIds.value.splice(index, 1) - } +// 删除单个文件 +const handleDeleteFile = (file) => { + if (!file?.id) return + + // 二次确认弹窗 + Modal.confirm({ + title: '确认删除', + content: `确定要删除文件 "${file.fileName}" 吗?删除后无法恢复。`, + okText: '确定删除', + cancelText: '取消', + okType: 'danger', + onOk: async () => { + try { + await MaterialService.deleteFiles([file.id]) + message.success('删除成功') + // 如果在选中列表中,也移除 + const index = selectedFileIds.value.indexOf(file.id) + if (index > -1) { + selectedFileIds.value.splice(index, 1) + } + loadFileList() + } catch (error) { + console.error('删除失败:', error) + message.error(error.message || '删除失败,请重试') + } + } + }) } // 文件点击 const handleFileClick = (file) => { - // TODO: 打开文件详情或预览 - console.log('点击文件:', file) + const isSelected = selectedFileIds.value.includes(file.id) + // 切换选中状态 + if (isSelected) { + selectedFileIds.value = selectedFileIds.value.filter(id => id !== file.id) + } else { + selectedFileIds.value.push(file.id) + } } // 筛选 @@ -355,6 +394,7 @@ const handleFilterChange = () => { } const handleResetFilters = () => { + filters.fileCategory = 'video' filters.fileName = '' filters.createTime = undefined pagination.pageNo = 1 @@ -590,27 +630,18 @@ onMounted(() => { .material-item { cursor: pointer; - transition: all 0.2s; -} - -.material-item:hover { - transform: translateY(-2px); -} - -.material-item--selected { - border: 2px solid var(--color-primary); } .material-item__content { background: var(--color-surface); - border: 1px solid var(--color-border); + border: 2px solid transparent; border-radius: var(--radius-card); overflow: hidden; - transition: all 0.2s; + transition: border-color 0.2s; } -.material-item:hover .material-item__content { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +.material-item--selected .material-item__content { + border-color: var(--color-primary); } .material-item__preview { @@ -650,10 +681,31 @@ onMounted(() => { left: 8px; } -.material-item__checkbox { +.material-item__delete { position: absolute; - top: 8px; + bottom: 8px; right: 8px; + width: 28px; + height: 28px; + background: rgba(255, 77, 79, 0.9); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + cursor: pointer; + opacity: 0; + transition: all 0.3s; + font-size: 16px; +} + +.material-item:hover .material-item__delete { + opacity: 1; +} + +.material-item__delete:hover { + background: rgb(255, 77, 79); + transform: scale(1.1); } .material-item__info { diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/LatentsyncClient.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/LatentsyncClient.java index 51acfe9ac6..d54d98ba66 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/LatentsyncClient.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/LatentsyncClient.java @@ -129,6 +129,8 @@ public class LatentsyncClient { .addQueryParameter("request_id", requestId) .build(); + log.info("[Latentsync][get result] requestId={}, url={}", requestId, url); + Request httpRequest = new Request.Builder() .url(url) .addHeader("Authorization", "Bearer " + properties.getApiKey()) @@ -138,6 +140,7 @@ public class LatentsyncClient { try { return executeRequest(httpRequest, "get result", requestId); } catch (ServiceException ex) { + log.error("[Latentsync][get result failed] requestId={}, message={}", requestId, ex.getMessage()); throw ex; } catch (Exception ex) { log.error("[Latentsync][get result exception]", ex); @@ -196,7 +199,16 @@ public class LatentsyncClient { private ServiceException buildException(String body) { try { JsonNode root = objectMapper.readTree(body); - String message = root.path("message").asText(body); + // 尝试读取 message 字段(标准错误格式) + String message = root.path("message").asText(""); + // 如果没有 message,尝试读取 detail 字段(302AI 的错误格式) + if (StrUtil.isBlank(message)) { + message = root.path("detail").asText(""); + } + // 如果都没有,使用整个响应体 + if (StrUtil.isBlank(message)) { + message = body; + } return exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), message); } catch (Exception ignored) { return exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), body); diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/DigitalHumanTaskServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/DigitalHumanTaskServiceImpl.java index 648f68795f..4b3c40dda6 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/DigitalHumanTaskServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/DigitalHumanTaskServiceImpl.java @@ -476,7 +476,7 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService { log.info("[syncWithLatentsync][任务({})提交成功,requestId={}]", task.getId(), requestId); // 轮询等待任务完成 - int maxAttempts = 60; // 最多轮询60次 + int maxAttempts = 90; // 最多轮询90次(15分钟) int attempt = 0; while (attempt < maxAttempts) { attempt++; @@ -485,8 +485,10 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService { AppTikLatentsyncResultRespVO result = latentsyncService.getTaskResult(requestId); String status = result.getStatus(); - log.info("[syncWithLatentsync][任务({})轮询结果: 第{}次, status={}]", task.getId(), attempt, status); + log.info("[syncWithLatentsync][任务({})轮询结果: 第{}次, status={}]", + task.getId(), attempt, status); + // 检查任务是否完成 if ("COMPLETED".equals(status)) { // 任务完成,获取视频URL String videoUrl = result.getVideo().getUrl(); @@ -496,12 +498,12 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService { } else { throw new Exception("Latentsync 返回视频URL为空"); } - } else if ("FAILED".equals(status)) { - throw new Exception("Latentsync 任务处理失败"); + } else if ("FAILED".equals(status) || "ERROR".equals(status)) { + throw new Exception("Latentsync 任务处理失败: " + status); } - - // 等待5秒后再次轮询 - Thread.sleep(5000); + + // 等待10秒后再次轮询(处理中的任务间隔稍长一些) + Thread.sleep(10000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new Exception("等待Latentsync结果时被中断", e); @@ -512,7 +514,7 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService { throw new Exception("等待Latentsync结果超时: " + e.getMessage(), e); } // 否则等待后重试 - Thread.sleep(5000); + Thread.sleep(10000); } }