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 @@
图片
文件
-
-
-
handleSelectFile(file.id, checked)"
- />
+
+
+
@@ -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);
}
}