feat(kling): add validation error display in timeline panel and update UI components
- Remove unused audio base64 reference and error message in VoiceSelector - Rename CSS class from 'result-banner' to 'result-inline' and update button styling - Pass validationError prop from GenerateStep to TimelinePanel - Add validation error display in TimelinePanel with error state styling - Update conditional rendering to show either validation error or duration diff - Add CloseCircleOutlined icon for error status display
This commit is contained in:
@@ -103,7 +103,6 @@ const playerContainer = ref(null)
|
|||||||
const audioUrl = ref('')
|
const audioUrl = ref('')
|
||||||
const currentVoiceName = ref('')
|
const currentVoiceName = ref('')
|
||||||
const isPlayerInitializing = ref(false)
|
const isPlayerInitializing = ref(false)
|
||||||
const currentAudioBase64 = ref('') // 保存当前音频的 base64 数据
|
|
||||||
|
|
||||||
// 默认封面图片(音频波形图标)
|
// 默认封面图片(音频波形图标)
|
||||||
const defaultCover = `data:image/svg+xml;base64,${btoa(`
|
const defaultCover = `data:image/svg+xml;base64,${btoa(`
|
||||||
@@ -208,7 +207,6 @@ const handlePlayVoiceSample = (voice) => {
|
|||||||
(data) => {
|
(data) => {
|
||||||
const url = data.audioUrl || data.objectUrl
|
const url = data.audioUrl || data.objectUrl
|
||||||
if (!url) return
|
if (!url) return
|
||||||
currentAudioBase64.value = data.audioBase64 || ''
|
|
||||||
initPlayer(url)
|
initPlayer(url)
|
||||||
},
|
},
|
||||||
undefined, // 错误静默处理
|
undefined, // 错误静默处理
|
||||||
@@ -259,7 +257,6 @@ const initPlayer = (url) => {
|
|||||||
|
|
||||||
player.on('error', (e) => {
|
player.on('error', (e) => {
|
||||||
console.error('APlayer 播放错误:', e)
|
console.error('APlayer 播放错误:', e)
|
||||||
message.error('音频播放失败,请重试')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
player.on('canplay', () => {
|
player.on('canplay', () => {
|
||||||
|
|||||||
@@ -850,7 +850,7 @@ onMounted(async () => {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-banner {
|
.result-inline {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -870,17 +870,7 @@ onMounted(async () => {
|
|||||||
color: @accent-red;
|
color: @accent-red;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-icon {
|
.inline-btn {
|
||||||
font-size: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-text {
|
|
||||||
flex: 1;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retry-btn {
|
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid currentColor;
|
border: 1px solid currentColor;
|
||||||
@@ -890,9 +880,6 @@ onMounted(async () => {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(16, 185, 129, 0.1);
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
:audio-duration-ms="store.timeline.audioDurationMs"
|
:audio-duration-ms="store.timeline.audioDurationMs"
|
||||||
:face-start-time="store.timeline.faceStartTime"
|
:face-start-time="store.timeline.faceStartTime"
|
||||||
:face-end-time="store.timeline.faceEndTime"
|
:face-end-time="store.timeline.faceEndTime"
|
||||||
|
:validation-error="store.validationError"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 积分预估 -->
|
<!-- 积分预估 -->
|
||||||
|
|||||||
@@ -72,8 +72,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 校验错误提示 -->
|
||||||
|
<div v-if="validationError" class="timeline-diff error">
|
||||||
|
<CloseCircleOutlined class="diff-icon" />
|
||||||
|
<span>{{ validationError }}</span>
|
||||||
|
</div>
|
||||||
<!-- 时长差异提示 -->
|
<!-- 时长差异提示 -->
|
||||||
<div v-if="audioDurationMs > 0" class="timeline-diff" :class="diffStatus">
|
<div v-else-if="audioDurationMs > 0" class="timeline-diff" :class="diffStatus">
|
||||||
<template v-if="diffStatus === 'match'">
|
<template v-if="diffStatus === 'match'">
|
||||||
<CheckCircleOutlined class="diff-icon" />
|
<CheckCircleOutlined class="diff-icon" />
|
||||||
<span>时长匹配良好,可以生成</span>
|
<span>时长匹配良好,可以生成</span>
|
||||||
@@ -92,7 +97,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { CheckCircleOutlined, ExclamationCircleOutlined, InfoCircleOutlined } from '@ant-design/icons-vue'
|
import { CheckCircleOutlined, ExclamationCircleOutlined, InfoCircleOutlined, CloseCircleOutlined } from '@ant-design/icons-vue'
|
||||||
import { formatDurationMs } from '../utils/format'
|
import { formatDurationMs } from '../utils/format'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -100,11 +105,13 @@ interface Props {
|
|||||||
audioDurationMs: number
|
audioDurationMs: number
|
||||||
faceStartTime?: number
|
faceStartTime?: number
|
||||||
faceEndTime?: number
|
faceEndTime?: number
|
||||||
|
validationError?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
faceStartTime: 0,
|
faceStartTime: 0,
|
||||||
faceEndTime: 0,
|
faceEndTime: 0,
|
||||||
|
validationError: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const maxDuration = computed(() => {
|
const maxDuration = computed(() => {
|
||||||
@@ -393,5 +400,10 @@ const formatDuration = formatDurationMs
|
|||||||
background: rgba(245, 158, 11, 0.08);
|
background: rgba(245, 158, 11, 0.08);
|
||||||
color: @accent-orange;
|
color: @accent-orange;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
color: @accent-red;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
:audio-duration-ms="store.timeline.audioDurationMs"
|
:audio-duration-ms="store.timeline.audioDurationMs"
|
||||||
:face-start-time="store.timeline.faceStartTime"
|
:face-start-time="store.timeline.faceStartTime"
|
||||||
:face-end-time="store.timeline.faceEndTime"
|
:face-end-time="store.timeline.faceEndTime"
|
||||||
|
:validation-error="store.validationError"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 生成音频按钮 -->
|
<!-- 生成音频按钮 -->
|
||||||
|
|||||||
@@ -126,10 +126,22 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
|||||||
const canGenerate = computed(() => {
|
const canGenerate = computed(() => {
|
||||||
if (!isVideoReady.value || !isAudioReady.value) return false
|
if (!isVideoReady.value || !isAudioReady.value) return false
|
||||||
if (!timeline.value) return false
|
if (!timeline.value) return false
|
||||||
// 音频时长不能超过人脸时长
|
// 1. 人脸区间至少2秒
|
||||||
|
if (timeline.value.videoDurationMs < 2000) return false
|
||||||
|
// 2. 音频时长不能超过人脸时长
|
||||||
return timeline.value.audioDurationMs <= timeline.value.videoDurationMs
|
return timeline.value.audioDurationMs <= timeline.value.videoDurationMs
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 校验失败原因 */
|
||||||
|
const validationError = computed(() => {
|
||||||
|
if (!timeline.value) return '请先完成视频识别'
|
||||||
|
if (!isVideoReady.value) return '请先完成视频识别'
|
||||||
|
if (!isAudioReady.value) return '请先生成音频'
|
||||||
|
if (timeline.value.videoDurationMs < 2000) return '人脸区间不足2秒,无法生成对口型视频'
|
||||||
|
if (timeline.value.audioDurationMs > timeline.value.videoDurationMs) return '音频时长超过人脸时长,请缩短文案'
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
/** 时间轴匹配状态 */
|
/** 时间轴匹配状态 */
|
||||||
const timelineMatch = computed(() => {
|
const timelineMatch = computed(() => {
|
||||||
if (!timeline.value || timeline.value.audioDurationMs === 0) return 'none'
|
if (!timeline.value || timeline.value.audioDurationMs === 0) return 'none'
|
||||||
@@ -658,6 +670,7 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
|||||||
isAudioReady,
|
isAudioReady,
|
||||||
canGoNext,
|
canGoNext,
|
||||||
canGenerate,
|
canGenerate,
|
||||||
|
validationError,
|
||||||
timelineMatch,
|
timelineMatch,
|
||||||
faceDurationMs,
|
faceDurationMs,
|
||||||
audioDurationMs,
|
audioDurationMs,
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
class="action-btn action-btn--primary"
|
class="action-btn action-btn--primary"
|
||||||
@click="handlePreview(record)"
|
@click="openVideoUrl(record)"
|
||||||
>
|
>
|
||||||
<PlayCircleOutlined /> 预览
|
<PlayCircleOutlined /> 预览
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
class="action-btn action-btn--success"
|
class="action-btn action-btn--success"
|
||||||
@click="handleDownload(record)"
|
@click="openVideoUrl(record)"
|
||||||
>
|
>
|
||||||
<DownloadOutlined /> 下载
|
<DownloadOutlined /> 下载
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -170,17 +170,8 @@ const rowSelection = {
|
|||||||
// 状态判断
|
// 状态判断
|
||||||
const isStatus = (status, target) => status === target || status === target.toUpperCase()
|
const isStatus = (status, target) => status === target || status === target.toUpperCase()
|
||||||
|
|
||||||
// 预览视频(直接打开链接)
|
// 打开视频链接(预览/下载共用)
|
||||||
const handlePreview = (record) => {
|
const openVideoUrl = (record) => {
|
||||||
if (!record.resultVideoUrl) {
|
|
||||||
message.warning('该任务暂无视频结果,请稍后再试')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
window.open(record.resultVideoUrl, '_blank')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 下载视频
|
|
||||||
const handleDownload = (record) => {
|
|
||||||
if (!record.resultVideoUrl) {
|
if (!record.resultVideoUrl) {
|
||||||
message.warning('该任务暂无视频结果,请稍后再试')
|
message.warning('该任务暂无视频结果,请稍后再试')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -580,43 +580,6 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
|
|||||||
StrUtil.blankToDefault(requestFormat, getDefaultFormat()));
|
StrUtil.blankToDefault(requestFormat, getDefaultFormat()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildFileName(String voiceId, String format) {
|
|
||||||
String safeVoice = StrUtil.blankToDefault(voiceId, "voice")
|
|
||||||
.replaceAll("[^a-zA-Z0-9_-]", "");
|
|
||||||
return safeVoice + "-" + System.currentTimeMillis() + "." + format;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveContentType(String format) {
|
|
||||||
if (format == null) {
|
|
||||||
return "audio/mpeg";
|
|
||||||
}
|
|
||||||
return switch (format.toLowerCase()) {
|
|
||||||
case "wav" -> "audio/wav";
|
|
||||||
case "flac" -> "audio/flac";
|
|
||||||
default -> "audio/mpeg";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private String determineSynthesisText(String transcriptionText, String inputText, boolean allowFallback) {
|
|
||||||
StringBuilder builder = new StringBuilder();
|
|
||||||
if (StrUtil.isNotBlank(transcriptionText)) {
|
|
||||||
builder.append(transcriptionText.trim());
|
|
||||||
}
|
|
||||||
if (StrUtil.isNotBlank(inputText)) {
|
|
||||||
if (builder.length() > 0) {
|
|
||||||
builder.append("\n");
|
|
||||||
}
|
|
||||||
builder.append(inputText.trim());
|
|
||||||
}
|
|
||||||
if (builder.length() > 0) {
|
|
||||||
return builder.toString();
|
|
||||||
}
|
|
||||||
if (allowFallback) {
|
|
||||||
return getPreviewText();
|
|
||||||
}
|
|
||||||
throw exception(VOICE_TTS_FAILED, "请提供需要合成的文本内容");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从URL中提取原始URL(去除查询参数和锚点)
|
* 从URL中提取原始URL(去除查询参数和锚点)
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user