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:
2026-03-05 23:43:27 +08:00
parent dff90abbb4
commit 52a1094144
8 changed files with 36 additions and 71 deletions

View File

@@ -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', () => {

View File

@@ -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);

View File

@@ -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"
/> />
<!-- 积分预估 --> <!-- 积分预估 -->

View File

@@ -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>

View File

@@ -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"
/> />
<!-- 生成音频按钮 --> <!-- 生成音频按钮 -->

View File

@@ -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,

View File

@@ -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

View File

@@ -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去除查询参数和锚点
*/ */