feat:优化

This commit is contained in:
2025-11-22 01:42:20 +08:00
parent a3cc6c6db0
commit 161d9568a9
4 changed files with 240 additions and 67 deletions

View File

@@ -3,11 +3,12 @@ defineOptions({ name: 'DigitalVideoPage' })
import { ref, computed, onMounted, watch, onUnmounted } from 'vue' import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { InboxOutlined } from '@ant-design/icons-vue' import { InboxOutlined } from '@ant-design/icons-vue'
import { useVoiceCopyStore } from '@/stores/voiceCopy'
import { VoiceService } from '@/api/voice' import { VoiceService } from '@/api/voice'
import { MaterialService } from '@/api/material' import { MaterialService } from '@/api/material'
import { createDigitalHumanTask, getDigitalHumanTask, cancelTask, retryTask } from '@/api/digitalHuman' import { createDigitalHumanTask, getDigitalHumanTask, cancelTask, retryTask } from '@/api/digitalHuman'
// 导入 voiceStore 用于获取用户音色
import { useVoiceCopyStore } from '@/stores/voiceCopy'
const voiceStore = useVoiceCopyStore() const voiceStore = useVoiceCopyStore()
// 状态管理 // 状态管理
@@ -35,7 +36,9 @@ const ttsText = ref('')
const selectedTtsVoice = ref('') const selectedTtsVoice = ref('')
const speechRate = ref(1.0) const speechRate = ref(1.0)
const instruction = ref('neutral') // 指令参数,用于控制音色风格 const instruction = ref('neutral') // 指令参数,用于控制音色风格
const voiceSource = ref('user') const emotion = ref('neutral') // 情感参数
const emotionActive = ref(false) // 是否激活情感tabfalse为指令true为情感
const voiceSource = ref('system') // 音色来源user 或 system
// 系统音色库使用CosyVoice v3-flash模型 // 系统音色库使用CosyVoice v3-flash模型
const SYSTEM_VOICES = [ const SYSTEM_VOICES = [
@@ -103,6 +106,10 @@ const setVoiceSource = (source) => {
const selectVoiceProfile = (voice) => { const selectVoiceProfile = (voice) => {
selectedTtsVoice.value = `${voice.source}-${voice.id}` 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) => { const buildPreviewParams = (voice) => {
if (voice.source === 'user') { if (voice.source === 'user') {
// 使用voiceConfigId让后端查询数据库获取文件URL和transcriptionText // 用户音色:使用voiceConfigId不传instruction
// 用户音色不传instruction
const configId = voice.rawId || extractIdFromString(voice.id) const configId = voice.rawId || extractIdFromString(voice.id)
if (!configId) { if (!configId) {
message.error('配音配置无效') message.error('配音配置无效')
@@ -164,19 +170,29 @@ const buildPreviewParams = (voice) => {
} }
return { return {
voiceConfigId: configId, voiceConfigId: configId,
inputText: ttsText.value, // 传递用户输入的文本 inputText: ttsText.value,
speechRate: speechRate.value || 1.0, speechRate: speechRate.value || 1.0,
audioFormat: 'mp3' audioFormat: 'mp3'
} }
} else { } else {
// 系统音色使用用户选择instruction // 系统音色:根据是否选择instruction或emotion来决定传递哪个参数
return { const params = {
voiceId: voice.voiceId, voiceId: voice.voiceId,
inputText: ttsText.value, // 传递用户输入的文本 inputText: ttsText.value,
instruction: instruction.value && instruction.value !== 'neutral' ? instruction.value : (voice.defaultInstruction || '请用自然流畅的语调朗读'),
speechRate: speechRate.value || 1.0, speechRate: speechRate.value || 1.0,
audioFormat: 'mp3' 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' audioFormat: 'mp3'
} }
// 如果是用户配音使用voiceConfigId让后端查询不传instruction
if (voice.source === 'user') { if (voice.source === 'user') {
// 用户音色使用voiceConfigId不传instruction
const configId = voice.rawId || extractIdFromString(voice.id) const configId = voice.rawId || extractIdFromString(voice.id)
if (!configId) { if (!configId) {
message.warning('音色配置无效') message.warning('音色配置无效')
@@ -209,14 +225,22 @@ const handleSynthesizeVoice = async () => {
} }
params.voiceConfigId = configId params.voiceConfigId = configId
} else { } else {
// 使用系统音色voiceId和用户选择instruction // 系统音色:使用voiceId,根据是否选择instruction或emotion来决定传递哪个参数
const voiceId = voice.voiceId || voice.rawId const voiceId = voice.voiceId || voice.rawId
if (!voiceId) { if (!voiceId) {
message.warning('音色配置无效') message.warning('音色配置无效')
return return
} }
params.voiceId = voiceId 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) const res = await VoiceService.synthesize(params)
@@ -317,19 +341,41 @@ const generateVideo = async () => {
message.destroy() message.destroy()
// 2. 创建数字人任务简化只使用voiceId后端实时TTS // 2. 创建数字人任务
const taskData = { const taskData = {
taskName: `数字人任务_${Date.now()}`, taskName: `数字人任务_${Date.now()}`,
videoFileId: videoFileId, videoFileId: videoFileId,
// 音频由后端实时合成使用voiceId inputText: ttsText.value,
voiceId: voice.voiceId || voice.rawId,
inputText: ttsText.value, // 文本内容用于TTS合成
speechRate: speechRate.value, speechRate: speechRate.value,
instruction: voice.source === 'user' ? undefined : (instruction.value && instruction.value !== 'neutral' ? instruction.value : (voice.defaultInstruction || '请用自然流畅的语调朗读')), volume: 0,
guidanceScale: 1, guidanceScale: 1,
seed: 8888 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) message.loading('正在创建任务...', 0)
const createRes = await createDigitalHumanTask(taskData) const createRes = await createDigitalHumanTask(taskData)
message.destroy() message.destroy()
@@ -724,16 +770,55 @@ let previewObjectUrl = ''
</div> </div>
<div v-if="voiceSource === 'system'" class="control-group"> <div v-if="voiceSource === 'system'" class="control-group">
<div class="control-label">指令</div> <div class="control-label">情感</div>
<div class="control-tabs">
<button
:class="['tab-btn', { active: !emotionActive }]"
@click="emotionActive = false"
>
语调
</button>
<button
:class="['tab-btn', { active: emotionActive }]"
@click="emotionActive = true"
>
情感
</button>
</div>
<div class="emotion-buttons"> <div class="emotion-buttons">
<button <button
v-for="inst in ['neutral', '请用自然流畅的语调朗读', '请用温柔专业的语调朗读', '请用热情洋溢的语调朗读', '请用低沉磁性的语调朗读', '请用活泼生动的语调朗读']" v-if="!emotionActive"
:key="inst" v-for="inst in [
{ value: 'neutral', label: '中性' },
{ value: '请用自然流畅的语调朗读', label: '自然' },
{ value: '请用温柔专业的语调朗读', label: '温柔' },
{ value: '请用热情洋溢的语调朗读', label: '热情' },
{ value: '请用低沉磁性的语调朗读', label: '磁性' },
{ value: '请用活泼生动的语调朗读', label: '活泼' }
]"
:key="inst.value"
class="emotion-btn" class="emotion-btn"
:class="{ active: instruction === inst }" :class="{ active: instruction === inst.value }"
@click="instruction = inst" @click="instruction = inst.value"
> >
{{ inst === 'neutral' ? '中性' : inst }} {{ inst.label }}
</button>
<button
v-else
v-for="emo in [
{ value: 'neutral', label: '中性' },
{ value: 'happy', label: '开心' },
{ value: 'sad', label: '悲伤' },
{ value: 'angry', label: '愤怒' },
{ value: 'excited', label: '兴奋' },
{ value: 'calm', label: '平静' }
]"
:key="emo.value"
class="emotion-btn"
:class="{ active: emotion === emo.value }"
@click="emotion = emo.value"
>
{{ emo.label }}
</button> </button>
</div> </div>
</div> </div>
@@ -1091,6 +1176,28 @@ let previewObjectUrl = ''
gap: 8px; 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 { .emotion-btn {
padding: 8px 16px; padding: 8px 16px;
border: 1px solid rgba(59, 130, 246, 0.2); border: 1px solid rgba(59, 130, 246, 0.2);

View File

@@ -31,6 +31,19 @@
<!-- 筛选条件 --> <!-- 筛选条件 -->
<div class="material-list__filters"> <div class="material-list__filters">
<a-space> <a-space>
<a-select
v-model:value="filters.fileCategory"
style="width: 120px"
placeholder="文件分类"
@change="handleFilterChange"
>
<a-select-option value="">全部分类</a-select-option>
<a-select-option value="video">视频</a-select-option>
<a-select-option value="generate">生成</a-select-option>
<a-select-option value="audio">音频</a-select-option>
<a-select-option value="mix">混剪</a-select-option>
<a-select-option value="voice">配音</a-select-option>
</a-select>
<a-input <a-input
v-model="filters.fileName" v-model="filters.fileName"
@@ -48,6 +61,7 @@
style="width: 300px" style="width: 300px"
format="YYYY-MM-DD" format="YYYY-MM-DD"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
placeholder="['开始日期', '结束日期']"
@change="handleFilterChange" @change="handleFilterChange"
/> />
<a-button type="primary" @click="handleFilterChange">查询</a-button> <a-button type="primary" @click="handleFilterChange">查询</a-button>
@@ -102,13 +116,9 @@
<a-tag v-else-if="file.isImage" color="blue">图片</a-tag> <a-tag v-else-if="file.isImage" color="blue">图片</a-tag>
<a-tag v-else color="gray">文件</a-tag> <a-tag v-else color="gray">文件</a-tag>
</div> </div>
<!-- 选中复选框 --> <!-- 删除图标 -->
<div class="material-item__checkbox"> <div class="material-item__delete" @click.stop="handleDeleteFile(file)">
<a-checkbox <DeleteOutlined />
:model-value="selectedFileIds.includes(file.id)"
@click.stop
@change="(checked) => handleSelectFile(file.id, checked)"
/>
</div> </div>
</div> </div>
<!-- 文件信息 --> <!-- 文件信息 -->
@@ -195,7 +205,8 @@ import { message, Modal } from 'ant-design-vue'
import { import {
UploadOutlined, UploadOutlined,
SearchOutlined, SearchOutlined,
FileOutlined FileOutlined,
DeleteOutlined
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import { MaterialService } from '@/api/material' import { MaterialService } from '@/api/material'
import { MixService } from '@/api/mix' import { MixService } from '@/api/mix'
@@ -213,6 +224,7 @@ const mixing = ref(false)
// 筛选条件 // 筛选条件
const filters = reactive({ const filters = reactive({
fileCategory: 'video', // 默认分类为视频
fileName: '', fileName: '',
createTime: undefined createTime: undefined
}) })
@@ -229,8 +241,10 @@ const buildQueryParams = () => {
const params = { const params = {
pageNo: pagination.pageNo, pageNo: pagination.pageNo,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
...filters fileCategory: filters.fileCategory || undefined,
fileName: filters.fileName || undefined
} }
// 处理日期范围 // 处理日期范围
if (filters.createTime && Array.isArray(filters.createTime) && filters.createTime.length === 2) { if (filters.createTime && Array.isArray(filters.createTime) && filters.createTime.length === 2) {
params.createTime = [ params.createTime = [
@@ -238,6 +252,7 @@ const buildQueryParams = () => {
`${filters.createTime[1]} 23:59:59` `${filters.createTime[1]} 23:59:59`
] ]
} }
return params return params
} }
@@ -332,20 +347,44 @@ const handleBatchDelete = () => {
}) })
} }
// 选择文件(简化逻辑) // 删除单个文件
const handleSelectFile = (fileId, checked) => { const handleDeleteFile = (file) => {
const index = selectedFileIds.value.indexOf(fileId) if (!file?.id) return
if (checked && index === -1) {
selectedFileIds.value.push(fileId) // 二次确认弹窗
} else if (!checked && index > -1) { Modal.confirm({
selectedFileIds.value.splice(index, 1) 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) => { const handleFileClick = (file) => {
// TODO: 打开文件详情或预览 const isSelected = selectedFileIds.value.includes(file.id)
console.log('点击文件:', file) // 切换选中状态
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 = () => { const handleResetFilters = () => {
filters.fileCategory = 'video'
filters.fileName = '' filters.fileName = ''
filters.createTime = undefined filters.createTime = undefined
pagination.pageNo = 1 pagination.pageNo = 1
@@ -590,27 +630,18 @@ onMounted(() => {
.material-item { .material-item {
cursor: pointer; cursor: pointer;
transition: all 0.2s;
}
.material-item:hover {
transform: translateY(-2px);
}
.material-item--selected {
border: 2px solid var(--color-primary);
} }
.material-item__content { .material-item__content {
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 2px solid transparent;
border-radius: var(--radius-card); border-radius: var(--radius-card);
overflow: hidden; overflow: hidden;
transition: all 0.2s; transition: border-color 0.2s;
} }
.material-item:hover .material-item__content { .material-item--selected .material-item__content {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); border-color: var(--color-primary);
} }
.material-item__preview { .material-item__preview {
@@ -650,10 +681,31 @@ onMounted(() => {
left: 8px; left: 8px;
} }
.material-item__checkbox { .material-item__delete {
position: absolute; position: absolute;
top: 8px; bottom: 8px;
right: 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 { .material-item__info {

View File

@@ -129,6 +129,8 @@ public class LatentsyncClient {
.addQueryParameter("request_id", requestId) .addQueryParameter("request_id", requestId)
.build(); .build();
log.info("[Latentsync][get result] requestId={}, url={}", requestId, url);
Request httpRequest = new Request.Builder() Request httpRequest = new Request.Builder()
.url(url) .url(url)
.addHeader("Authorization", "Bearer " + properties.getApiKey()) .addHeader("Authorization", "Bearer " + properties.getApiKey())
@@ -138,6 +140,7 @@ public class LatentsyncClient {
try { try {
return executeRequest(httpRequest, "get result", requestId); return executeRequest(httpRequest, "get result", requestId);
} catch (ServiceException ex) { } catch (ServiceException ex) {
log.error("[Latentsync][get result failed] requestId={}, message={}", requestId, ex.getMessage());
throw ex; throw ex;
} catch (Exception ex) { } catch (Exception ex) {
log.error("[Latentsync][get result exception]", ex); log.error("[Latentsync][get result exception]", ex);
@@ -196,7 +199,16 @@ public class LatentsyncClient {
private ServiceException buildException(String body) { private ServiceException buildException(String body) {
try { try {
JsonNode root = objectMapper.readTree(body); 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); return exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), message);
} catch (Exception ignored) { } catch (Exception ignored) {
return exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), body); return exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), body);

View File

@@ -476,7 +476,7 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
log.info("[syncWithLatentsync][任务({})提交成功requestId={}]", task.getId(), requestId); log.info("[syncWithLatentsync][任务({})提交成功requestId={}]", task.getId(), requestId);
// 轮询等待任务完成 // 轮询等待任务完成
int maxAttempts = 60; // 最多轮询60次 int maxAttempts = 90; // 最多轮询90次15分钟
int attempt = 0; int attempt = 0;
while (attempt < maxAttempts) { while (attempt < maxAttempts) {
attempt++; attempt++;
@@ -485,8 +485,10 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
AppTikLatentsyncResultRespVO result = latentsyncService.getTaskResult(requestId); AppTikLatentsyncResultRespVO result = latentsyncService.getTaskResult(requestId);
String status = result.getStatus(); String status = result.getStatus();
log.info("[syncWithLatentsync][任务({})轮询结果: 第{}次, status={}]", task.getId(), attempt, status); log.info("[syncWithLatentsync][任务({})轮询结果: 第{}次, status={}]",
task.getId(), attempt, status);
// 检查任务是否完成
if ("COMPLETED".equals(status)) { if ("COMPLETED".equals(status)) {
// 任务完成获取视频URL // 任务完成获取视频URL
String videoUrl = result.getVideo().getUrl(); String videoUrl = result.getVideo().getUrl();
@@ -496,12 +498,12 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
} else { } else {
throw new Exception("Latentsync 返回视频URL为空"); throw new Exception("Latentsync 返回视频URL为空");
} }
} else if ("FAILED".equals(status)) { } else if ("FAILED".equals(status) || "ERROR".equals(status)) {
throw new Exception("Latentsync 任务处理失败"); throw new Exception("Latentsync 任务处理失败: " + status);
} }
// 等待5秒后再次轮询 // 等待10秒后再次轮询(处理中的任务间隔稍长一些)
Thread.sleep(5000); Thread.sleep(10000);
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
throw new Exception("等待Latentsync结果时被中断", e); throw new Exception("等待Latentsync结果时被中断", e);
@@ -512,7 +514,7 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
throw new Exception("等待Latentsync结果超时: " + e.getMessage(), e); throw new Exception("等待Latentsync结果超时: " + e.getMessage(), e);
} }
// 否则等待后重试 // 否则等待后重试
Thread.sleep(5000); Thread.sleep(10000);
} }
} }