Compare commits

...

3 Commits

Author SHA1 Message Date
52a1094144 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
2026-03-05 23:43:27 +08:00
dff90abbb4 feat: 功能 2026-03-05 23:21:00 +08:00
9b132082d2 优化 2026-03-05 22:58:31 +08:00
16 changed files with 1608 additions and 884 deletions

View File

@@ -76,12 +76,3 @@ export function deleteTask(taskId) {
})
}
/**
* 获取任务输出文件的签名URL
*/
export function getSignedUrls(taskId) {
return request({
url: `/webApi/api/tik/digital-human/task/${taskId}/signed-url`,
method: 'get'
})
}

View File

@@ -96,7 +96,7 @@ const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE
const voiceStore = useVoiceCopyStore()
const emit = defineEmits(['select'])
const emit = defineEmits(['select', 'audioGenerated'])
let player = null
const playerContainer = ref(null)
@@ -182,9 +182,7 @@ const handleVoiceChange = (value, option) => {
}
const handleSynthesize = () => {
if (!selectedVoiceId.value) return
// 防止在播放器初始化过程中重复点击
if (isPlayerInitializing.value) return
if (!selectedVoiceId.value || isPlayerInitializing.value) return
const voice = userVoiceCards.value.find(v => v.id === selectedVoiceId.value)
if (!voice) return
@@ -211,10 +209,8 @@ const handlePlayVoiceSample = (voice) => {
if (!url) return
initPlayer(url)
},
(error) => {
// 音频播放失败,静默处理
},
{ autoPlay: false } // 禁用自动播放,由 APlayer 控制
undefined, // 错误静默处理
{ autoPlay: false }
)
}
@@ -261,11 +257,18 @@ const initPlayer = (url) => {
player.on('error', (e) => {
console.error('APlayer 播放错误:', e)
message.error('音频播放失败,请重试')
})
player.on('canplay', () => {
isPlayerInitializing.value = false
// 发送音频时长和 URL 给父组件
const durationMs = Math.floor(player.audio.duration * 1000)
if (durationMs > 0) {
emit('audioGenerated', {
durationMs,
audioUrl: audioUrl.value // 使用 URL性能优化
})
}
})
} catch (e) {
console.error('APlayer 初始化失败:', e)
@@ -276,29 +279,21 @@ const initPlayer = (url) => {
})
}
/**
* 下载音频
*/
const downloadAudio = () => {
if (!audioUrl.value) return
const filename = `${currentVoiceName.value || '语音合成'}.mp3`
const link = document.createElement('a')
link.href = audioUrl.value
link.download = filename
link.download = `${currentVoiceName.value || '语音合成'}.mp3`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
/**
* 销毁播放器
*/
const destroyPlayer = () => {
isPlayerInitializing.value = false
if (player) {
try {
// 先暂停播放,防止销毁过程中出错
player.pause()
player.destroy()
} catch (e) {
@@ -306,8 +301,7 @@ const destroyPlayer = () => {
}
player = null
}
// 只对 blob URL 调用 revokeObjectURL
if (audioUrl.value && audioUrl.value.startsWith('blob:')) {
if (audioUrl.value?.startsWith('blob:')) {
URL.revokeObjectURL(audioUrl.value)
}
audioUrl.value = ''
@@ -338,25 +332,25 @@ onBeforeUnmount(() => {
.voice-selector-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
gap: 14px;
}
/* 音色卡片 */
/* 音色卡片 - 柔和风格 */
.voice-card {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%);
border: 1px solid rgba(59, 130, 246, 0.15);
border-radius: 12px;
padding: 16px;
transition: all 0.3s ease;
background: rgba(59, 130, 246, 0.03);
border: 1px solid rgba(59, 130, 246, 0.1);
border-radius: 10px;
padding: 14px;
transition: all 0.25s ease;
&:hover {
border-color: rgba(59, 130, 246, 0.3);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1);
border-color: rgba(59, 130, 246, 0.18);
background: rgba(59, 130, 246, 0.05);
}
&.has-audio {
border-color: rgba(59, 130, 246, 0.4);
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%);
border-color: rgba(59, 130, 246, 0.2);
background: rgba(59, 130, 246, 0.06);
}
}
@@ -365,40 +359,40 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
margin-bottom: 12px;
}
.header-icon {
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
width: 26px;
height: 26px;
border-radius: 6px;
background: rgba(59, 130, 246, 0.1);
display: flex;
align-items: center;
justify-content: center;
color: white;
color: #3b82f6;
svg {
width: 16px;
height: 16px;
width: 14px;
height: 14px;
}
}
.header-title {
font-size: 14px;
font-size: 13px;
font-weight: 500;
color: var(--color-text);
}
.header-badge {
margin-left: auto;
padding: 2px 10px;
background: rgba(59, 130, 246, 0.1);
border-radius: 12px;
font-size: 12px;
padding: 2px 8px;
background: rgba(59, 130, 246, 0.08);
border-radius: 10px;
font-size: 11px;
color: #3b82f6;
font-weight: 500;
max-width: 120px;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -407,7 +401,7 @@ onBeforeUnmount(() => {
/* 卡片主体 */
.voice-card-body {
display: flex;
gap: 12px;
gap: 10px;
align-items: stretch;
}
@@ -416,73 +410,73 @@ onBeforeUnmount(() => {
:deep(.ant-select) {
width: 100%;
height: 40px;
height: 36px;
.ant-select-selector {
height: 40px !important;
border-radius: 10px !important;
border-color: rgba(59, 130, 246, 0.2) !important;
background: rgba(255, 255, 255, 0.8) !important;
transition: all 0.3s ease !important;
height: 36px !important;
border-radius: 8px !important;
border-color: rgba(59, 130, 246, 0.12) !important;
background: rgba(255, 255, 255, 0.9) !important;
transition: all 0.2s ease !important;
&:hover {
border-color: #3b82f6 !important;
border-color: rgba(59, 130, 246, 0.25) !important;
}
}
&.ant-select-focused .ant-select-selector {
border-color: #3b82f6 !important;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
border-color: rgba(59, 130, 246, 0.3) !important;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.06) !important;
}
.ant-select-selection-item {
line-height: 38px !important;
line-height: 34px !important;
font-size: 13px;
}
.ant-select-selection-placeholder {
line-height: 38px !important;
line-height: 34px !important;
font-size: 13px;
}
}
}
.select-arrow {
color: #3b82f6;
color: #94a3b8;
transition: transform 0.3s ease;
}
/* 合成按钮 */
/* 合成按钮 - 柔和风格 */
.synthesize-btn {
height: 40px;
padding: 0 20px;
border-radius: 10px;
height: 36px;
padding: 0 16px;
border-radius: 8px;
border: none;
background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e1 100%);
background: rgba(59, 130, 246, 0.08);
color: #64748b;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.3s ease;
transition: all 0.25s ease;
white-space: nowrap;
&:hover:not(:disabled) {
background: linear-gradient(135deg, #cbd5e1 0%, #94a3b8 100%);
transform: translateY(-1px);
background: rgba(59, 130, 246, 0.12);
color: #475569;
}
&:disabled {
opacity: 0.6;
opacity: 0.5;
cursor: not-allowed;
}
&.btn-active {
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
color: white;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
&:hover:not(:disabled) {
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
background: rgba(59, 130, 246, 0.2);
}
}
@@ -493,46 +487,46 @@ onBeforeUnmount(() => {
/* 播放器区域 */
.player-section {
background: rgba(255, 255, 255, 0.6);
border-radius: 12px;
padding: 14px;
border: 1px solid rgba(59, 130, 246, 0.1);
background: rgba(59, 130, 246, 0.03);
border-radius: 10px;
padding: 12px;
border: 1px solid rgba(59, 130, 246, 0.08);
}
.aplayer-container {
:deep(.aplayer) {
border-radius: 10px;
border-radius: 8px;
box-shadow: none;
border: 1px solid rgba(0, 0, 0, 0.06);
border: 1px solid rgba(0, 0, 0, 0.04);
.aplayer-body {
border-radius: 10px;
border-radius: 8px;
}
}
}
.player-actions {
margin-top: 10px;
margin-top: 8px;
display: flex;
justify-content: flex-end;
}
.download-btn {
color: #3b82f6;
color: #94a3b8;
font-size: 12px;
padding: 4px 8px;
height: auto;
transition: all 0.2s ease;
&:hover {
color: #2563eb;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
background: rgba(59, 130, 246, 0.06);
}
}
/* 动画 */
.slide-fade-enter-active {
transition: all 0.3s ease-out;
transition: all 0.25s ease-out;
}
.slide-fade-leave-active {
@@ -540,12 +534,12 @@ onBeforeUnmount(() => {
}
.slide-fade-enter-from {
transform: translateY(-10px);
transform: translateY(-8px);
opacity: 0;
}
.slide-fade-leave-to {
transform: translateY(-10px);
transform: translateY(-8px);
opacity: 0;
}
</style>

View File

@@ -250,7 +250,7 @@ export function useTTS(options = {}) {
// 处理 Base64 音频
if (res.data?.audioBase64) {
const { blob, objectUrl } = decodeBase64Audio(res.data.audioBase64, res.data.format)
const audioData = { blob, objectUrl, format: res.data.format }
const audioData = { blob, objectUrl, format: res.data.format, audioBase64: res.data.audioBase64 }
cacheAudio(cacheKey, audioData)
resetPreviewState()
if (opts.autoPlay !== false) playCachedAudio(audioData)

File diff suppressed because it is too large Load Diff

View File

@@ -55,6 +55,7 @@
:audio-duration-ms="store.timeline.audioDurationMs"
:face-start-time="store.timeline.faceStartTime"
:face-end-time="store.timeline.faceEndTime"
:validation-error="store.validationError"
/>
<!-- 积分预估 -->

View File

@@ -0,0 +1,515 @@
<template>
<Teleport to="body">
<transition name="popover-fade">
<div
v-if="visible"
class="popover-overlay"
@click.self="handleClose"
@mousedown.self="handleClose"
>
<div
class="popover-card"
:style="popoverStyle"
@click.stop
>
<!-- 头部 -->
<div class="popover-header">
<span class="popover-title">AI 文案生成</span>
<button class="close-btn" @click="handleClose">
<CloseOutlined />
</button>
</div>
<!-- 内容区 -->
<div class="popover-body">
<!-- 智能体选择 -->
<div class="form-item">
<label class="form-label">选择智能体</label>
<a-select
v-model:value="selectedAgentId"
:loading="loadingAgents"
placeholder="请选择智能体"
class="agent-select"
:bordered="false"
size="small"
>
<a-select-option
v-for="agent in agentList"
:key="agent.id"
:value="agent.id"
>
<div class="agent-option">
<img v-if="agent.icon" :src="agent.icon" class="agent-icon" />
<span class="agent-name">{{ agent.agentName }}</span>
</div>
</a-select-option>
</a-select>
</div>
<!-- 主题输入 -->
<div class="form-item">
<label class="form-label">文案主题</label>
<input
v-model="theme"
type="text"
class="theme-input"
placeholder="如:产品介绍、活动推广..."
:disabled="isGenerating"
@keydown.enter="handleGenerate"
/>
</div>
<!-- 生成结果预览 -->
<div v-if="generatedText" class="result-preview">
<div class="result-content">
{{ generatedText }}
<span v-if="isGenerating" class="cursor-blink">|</span>
</div>
</div>
</div>
<!-- 底部按钮 -->
<div class="popover-footer">
<button class="btn btn-cancel" @click="handleClose">取消</button>
<button
class="btn btn-primary"
:disabled="!canGenerate || isGenerating"
@click="handleGenerate"
>
<LoadingOutlined v-if="isGenerating" class="spin" />
<span>{{ isGenerating ? '生成中...' : '生成' }}</span>
</button>
</div>
</div>
</div>
</transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue'
import { CloseOutlined, LoadingOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { getAgentList, sendChatStream } from '@/api/agent'
// Props
const props = defineProps<{
visible: boolean
theme?: string
}>()
// Emits
const emit = defineEmits<{
'update:visible': [value: boolean]
'success': [text: string]
'error': [msg: string]
}>()
// 状态
const agentList = ref<any[]>([])
const loadingAgents = ref(false)
const selectedAgentId = ref<number | null>(null)
const theme = ref('')
const generatedText = ref('')
const isGenerating = ref(false)
const abortController = ref<AbortController | null>(null)
const popoverStyle = ref<Record<string, string>>({})
// 计算属性
const canGenerate = computed(() => {
return selectedAgentId.value && theme.value.trim().length > 0
})
// 获取智能体列表
const fetchAgents = async () => {
loadingAgents.value = true
try {
const res = await getAgentList()
if (res.code === 0 && res.data) {
agentList.value = res.data
// 默认选中第一个
if (res.data.length > 0 && !selectedAgentId.value) {
selectedAgentId.value = res.data[0].id
}
}
} catch (error) {
console.error('获取智能体列表失败:', error)
} finally {
loadingAgents.value = false
}
}
// 更新气泡位置
const updatePosition = () => {
// 找到触发按钮
const triggerBtn = document.querySelector('.generate-text-btn')
if (triggerBtn) {
const rect = triggerBtn.getBoundingClientRect()
const popoverWidth = 320
const popoverHeight = 280
// 计算位置,确保不超出视口
let left = rect.right - popoverWidth
let top = rect.bottom + 8
// 边界检测
if (left < 16) left = 16
if (left + popoverWidth > window.innerWidth - 16) {
left = window.innerWidth - popoverWidth - 16
}
if (top + popoverHeight > window.innerHeight - 16) {
top = rect.top - popoverHeight - 8
}
popoverStyle.value = {
position: 'fixed',
left: `${left}px`,
top: `${top}px`,
width: `${popoverWidth}px`,
}
}
}
// 生成文案
const handleGenerate = async () => {
if (!canGenerate.value || isGenerating.value) return
const selectedAgent = agentList.value.find(a => a.id === selectedAgentId.value)
if (!selectedAgent) {
message.warning('请选择智能体')
return
}
isGenerating.value = true
generatedText.value = ''
abortController.value = new AbortController()
const prompt = `请根据以下主题生成一段播报文案,要求:
1. 语言流畅自然,适合口播
2. 内容简洁有吸引力
3. 不要使用markdown格式直接输出纯文本
主题:${theme.value}`
try {
await sendChatStream({
agentId: selectedAgent.id,
content: prompt,
ctrl: abortController.value,
onMessage: (result: { event: string; content?: string; errorMessage?: string }) => {
if (result.event === 'message' && result.content) {
generatedText.value += result.content
} else if (result.event === 'error') {
message.error(result.errorMessage || '生成出错')
isGenerating.value = false
}
},
onError: () => {
message.error('生成失败,请重试')
isGenerating.value = false
},
onClose: () => {
isGenerating.value = false
// 生成完成,触发成功回调
if (generatedText.value) {
emit('success', generatedText.value.trim())
}
}
})
} catch (error: any) {
if (error.name !== 'AbortError') {
message.error('生成失败')
}
isGenerating.value = false
}
}
// 关闭弹窗
const handleClose = () => {
if (isGenerating.value) {
abortController.value?.abort()
isGenerating.value = false
}
generatedText.value = ''
theme.value = ''
emit('update:visible', false)
}
// 监听 visible 变化
watch(() => props.visible, (val) => {
if (val) {
fetchAgents()
updatePosition()
// 监听窗口大小变化
window.addEventListener('resize', updatePosition)
} else {
window.removeEventListener('resize', updatePosition)
}
})
// 清理
onUnmounted(() => {
window.removeEventListener('resize', updatePosition)
if (abortController.value) {
abortController.value.abort()
}
})
</script>
<style scoped lang="less">
// Notion 风格配色
@bg-popover: #ffffff;
@bg-hover: #f7f6f3;
@text-primary: #37352f;
@text-secondary: #787774;
@text-tertiary: #b4b4b4;
@border-color: #e9e9e7;
@primary-color: #2e75cc;
@primary-hover: #1f63cb;
// 过渡动画
.popover-fade-enter-active,
.popover-fade-leave-active {
transition: all 0.2s ease;
}
.popover-fade-enter-from,
.popover-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
// 遮罩层(透明,仅用于点击外部关闭)
.popover-overlay {
position: fixed;
inset: 0;
z-index: 1000;
}
// 气泡卡片
.popover-card {
background: @bg-popover;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 1px rgba(0, 0, 0, 0.1);
border: 1px solid @border-color;
overflow: hidden;
z-index: 1001;
}
// 头部
.popover-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid @border-color;
}
.popover-title {
font-size: 13px;
font-weight: 600;
color: @text-primary;
}
.close-btn {
width: 22px;
height: 22px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
color: @text-tertiary;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
transition: all 0.15s ease;
&:hover {
background: @bg-hover;
color: @text-secondary;
}
}
// 内容区
.popover-body {
padding: 14px;
}
.form-item {
margin-bottom: 14px;
&:last-child {
margin-bottom: 0;
}
}
.form-label {
display: block;
font-size: 12px;
font-weight: 500;
color: @text-secondary;
margin-bottom: 6px;
}
.agent-select {
width: 100%;
:deep(.ant-select-selector) {
background: @bg-hover !important;
border-radius: 6px !important;
padding: 4px 10px !important;
min-height: 32px !important;
display: flex !important;
align-items: center !important;
}
:deep(.ant-select-selection-item) {
font-size: 13px;
color: @text-primary;
line-height: 24px !important;
display: flex !important;
align-items: center !important;
}
}
.agent-option {
display: flex;
align-items: center;
gap: 8px;
}
.agent-icon {
width: 18px;
height: 18px;
border-radius: 4px;
object-fit: cover;
}
.agent-name {
font-size: 13px;
color: @text-primary;
}
.theme-input {
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid @border-color;
border-radius: 6px;
font-size: 13px;
color: @text-primary;
background: @bg-hover;
outline: none;
transition: all 0.15s ease;
&::placeholder {
color: @text-tertiary;
}
&:focus {
border-color: @primary-color;
box-shadow: 0 0 0 2px rgba(46, 117, 204, 0.1);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
// 生成结果预览
.result-preview {
margin-top: 12px;
padding: 10px;
background: @bg-hover;
border-radius: 6px;
max-height: 120px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: @text-tertiary;
border-radius: 2px;
}
}
.result-content {
font-size: 13px;
line-height: 1.5;
color: @text-primary;
white-space: pre-wrap;
word-break: break-word;
}
.cursor-blink {
color: @primary-color;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
// 底部按钮
.popover-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 14px;
border-top: 1px solid @border-color;
}
.btn {
height: 30px;
padding: 0 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: 4px;
&.btn-cancel {
background: transparent;
border: none;
color: @text-secondary;
&:hover {
background: @bg-hover;
color: @text-primary;
}
}
&.btn-primary {
background: @primary-color;
border: none;
color: #fff;
&:hover:not(:disabled) {
background: @primary-hover;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
.spin {
font-size: 12px;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -2,10 +2,11 @@
<div class="timeline-panel">
<div class="timeline-header">
<span class="timeline-title">时间轴对比</span>
<span v-if="showDurations" class="duration-info">
人脸: {{ formatDuration(faceDurationMs) }}
<span v-if="showDurations" class="duration-badge">
人脸 {{ formatDuration(faceDurationMs) }}
<template v-if="audioDurationMs > 0">
| 音频: {{ formatDuration(audioDurationMs) }}
<span class="divider"></span>
音频 {{ formatDuration(audioDurationMs) }}
</template>
</span>
</div>
@@ -21,28 +22,42 @@
<span class="ruler-label">{{ mark.label }}</span>
<span class="ruler-tick"></span>
</div>
<!-- 音频结束位置标记 -->
<div
v-if="audioDurationMs > 0 && isExceed"
class="audio-end-marker"
:style="{ left: audioEndPosition + '%' }"
>
<span class="audio-marker-label">{{ (audioDurationMs / 1000).toFixed(1) }}s</span>
<span class="audio-marker-line"></span>
</div>
</div>
<!-- 轨道区域 -->
<div class="timeline-tracks">
<!-- 视频轨道 -->
<div class="track video-track">
<div class="track">
<div class="track-info">
<span class="track-icon">📹</span>
<span class="track-label">视频</span>
</div>
<div class="track-bar">
<div
class="track-fill video-fill"
:style="{ width: videoBarWidth + '%' }"
>
<span class="track-time">{{ formatDuration(faceDurationMs) }}</span>
<span v-if="videoBarWidth > 15" class="track-time">{{ formatDuration(faceDurationMs) }}</span>
</div>
</div>
</div>
<!-- 音频轨道 -->
<div class="track audio-track">
<span class="track-icon">🎵</span>
<div class="track">
<div class="track-info">
<span class="track-icon">🎙</span>
<span class="track-label">音频</span>
</div>
<div class="track-bar">
<div
v-if="audioDurationMs > 0"
@@ -50,22 +65,27 @@
:class="{ 'audio-exceed': isExceed }"
:style="{ width: audioBarWidth + '%' }"
>
<span class="track-time">{{ formatDuration(audioDurationMs) }}</span>
<span v-if="audioBarWidth > 15" class="track-time">{{ formatDuration(audioDurationMs) }}</span>
</div>
<span v-else class="track-placeholder">等待生成音频</span>
</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'">
<CheckCircleOutlined class="diff-icon" />
<span>时长匹配良好</span>
<span>时长匹配良好可以生成</span>
</template>
<template v-else-if="diffStatus === 'exceed'">
<ExclamationCircleOutlined class="diff-icon" />
<span>音频超出 {{ formatDuration(diffMs) }}缩短文案</span>
<span>音频超出 {{ formatDuration(diffMs) }}建议缩短文案</span>
</template>
<template v-else-if="diffStatus === 'short'">
<InfoCircleOutlined class="diff-icon" />
@@ -77,7 +97,7 @@
<script setup lang="ts">
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'
interface Props {
@@ -85,11 +105,13 @@ interface Props {
audioDurationMs: number
faceStartTime?: number
faceEndTime?: number
validationError?: string | null
}
const props = withDefaults(defineProps<Props>(), {
faceStartTime: 0,
faceEndTime: 0,
validationError: null,
})
const maxDuration = computed(() => {
@@ -105,6 +127,10 @@ const audioBarWidth = computed(() =>
Math.min(100, (props.audioDurationMs / maxDuration.value) * 100)
)
const audioEndPosition = computed(() =>
Math.min(100, (props.audioDurationMs / maxDuration.value) * 100)
)
const isExceed = computed(() => props.audioDurationMs > props.faceDurationMs)
const diffMs = computed(() => Math.abs(props.audioDurationMs - props.faceDurationMs))
@@ -151,11 +177,25 @@ const formatDuration = formatDurationMs
</script>
<style scoped lang="less">
// 蓝紫主题配色 - 与主页面协调
@text-primary: #1e293b;
@text-secondary: #64748b;
@text-tertiary: #94a3b8;
@bg-subtle: rgba(59, 130, 246, 0.04);
@bg-hover: rgba(59, 130, 246, 0.08);
@border-light: rgba(59, 130, 246, 0.1);
@border-medium: rgba(59, 130, 246, 0.15);
@accent-blue: #3b82f6;
@accent-purple: #8b5cf6;
@accent-green: #10b981;
@accent-red: #ef4444;
@accent-orange: #f59e0b;
.timeline-panel {
background: #f8fafc;
background: @bg-subtle;
border-radius: 10px;
padding: 16px;
margin-top: 16px;
padding: 14px 18px;
margin-top: 8px;
}
.timeline-header {
@@ -163,25 +203,37 @@ const formatDuration = formatDurationMs
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.timeline-title {
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.duration-info {
.timeline-title {
font-size: 12px;
color: #64748b;
font-weight: 600;
color: @text-secondary;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.duration-badge {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: @text-tertiary;
.divider {
width: 4px;
height: 4px;
background: @border-medium;
border-radius: 50%;
}
}
// 刻度尺
.timeline-ruler {
position: relative;
height: 20px;
margin-bottom: 8px;
margin-left: 70px; // 为图标和标签留空间
height: 18px;
margin-bottom: 10px;
margin-left: 80px;
}
.ruler-mark {
@@ -194,7 +246,7 @@ const formatDuration = formatDurationMs
.ruler-label {
font-size: 10px;
color: #94a3b8;
color: @text-tertiary;
margin-bottom: 2px;
}
@@ -202,7 +254,35 @@ const formatDuration = formatDurationMs
display: block;
width: 1px;
height: 4px;
background: #cbd5e1;
background: @border-medium;
}
// 音频结束位置标记
.audio-end-marker {
position: absolute;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
z-index: 10;
top: 0;
}
.audio-marker-label {
font-size: 9px;
font-weight: 600;
color: @accent-red;
background: rgba(235, 87, 87, 0.1);
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
margin-bottom: 2px;
}
.audio-marker-line {
width: 1px;
height: 6px;
background: @accent-red;
}
// 轨道区域
@@ -215,25 +295,32 @@ const formatDuration = formatDurationMs
.track {
display: flex;
align-items: center;
gap: 8px;
gap: 12px;
}
.track-info {
display: flex;
align-items: center;
gap: 6px;
width: 68px;
flex-shrink: 0;
}
.track-icon {
font-size: 14px;
width: 20px;
text-align: center;
line-height: 1;
}
.track-label {
width: 34px;
font-size: 12px;
color: #64748b;
flex-shrink: 0;
color: @text-secondary;
font-weight: 500;
}
.track-bar {
flex: 1;
height: 24px;
background: #e2e8f0;
height: 22px;
background: rgba(55, 53, 47, 0.06);
border-radius: 4px;
position: relative;
overflow: hidden;
@@ -247,68 +334,76 @@ const formatDuration = formatDurationMs
display: flex;
align-items: center;
justify-content: center;
transition: width 0.3s ease;
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.track-time {
font-size: 11px;
color: white;
font-weight: 500;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
font-size: 10px;
color: rgba(255, 255, 255, 0.95);
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
letter-spacing: 0.3px;
}
.track-placeholder {
font-size: 11px;
color: #94a3b8;
padding-left: 12px;
}
color: @text-tertiary;
padding-left: 14px;
}
.video-fill {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
background: linear-gradient(90deg, @accent-blue 0%, @accent-purple 100%);
}
.audio-fill {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
background: linear-gradient(90deg, @accent-green 0%, #059669 100%);
&.audio-exceed {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
animation: pulse 1.5s ease-in-out infinite;
background: linear-gradient(90deg, @accent-red 0%, #dc2626 100%);
animation: pulse-warning 2s ease-in-out infinite;
}
}
@keyframes pulse {
@keyframes pulse-warning {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
50% { opacity: 0.75; }
}
// 差异提示
.timeline-diff {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
margin-top: 12px;
padding: 8px 12px;
border-radius: 6px;
padding: 10px 14px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
.diff-icon {
font-size: 14px;
flex-shrink: 0;
}
&.match {
background: #dcfce7;
color: #166534;
background: rgba(16, 185, 129, 0.08);
color: @accent-green;
}
&.exceed {
background: #fee2e2;
color: #dc2626;
background: rgba(239, 68, 68, 0.08);
color: @accent-red;
}
&.short {
background: #fef3c7;
color: #92400e;
background: rgba(245, 158, 11, 0.08);
color: @accent-orange;
}
&.error {
background: rgba(239, 68, 68, 0.12);
color: @accent-red;
}
}
</style>

View File

@@ -56,6 +56,7 @@
:audio-duration-ms="store.timeline.audioDurationMs"
:face-start-time="store.timeline.faceStartTime"
:face-end-time="store.timeline.faceEndTime"
:validation-error="store.validationError"
/>
<!-- 生成音频按钮 -->

View File

@@ -21,7 +21,7 @@ import type { VoiceMeta, Video, PipelinePhase, VideoStep, AudioStep, CreateStep,
/** 音频数据 */
interface AudioData {
audioBase64: string
audioUrl: string // 预签名 URL
format: string
durationMs: number
}
@@ -126,10 +126,22 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
const canGenerate = computed(() => {
if (!isVideoReady.value || !isAudioReady.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
})
/** 校验失败原因 */
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(() => {
if (!timeline.value || timeline.value.audioDurationMs === 0) return 'none'
@@ -239,8 +251,20 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
videoFile.value = null
videoSource.value = 'select'
videoSelectorVisible.value = false
// 素材列表返回的 fileUrl 已带签名,直接使用
videoPreviewUrl.value = video.fileUrl
// 获取带签名的视频播放URL
try {
const urlRes = await MaterialService.getVideoPlayUrl(Number(video.id))
if (urlRes.code !== 0 || !urlRes.data) {
throw new Error(urlRes.msg || '获取播放链接失败')
}
videoPreviewUrl.value = urlRes.data
} catch (err: any) {
videoStep.value = 'error'
error.value = err.message || '获取播放链接失败'
message.error(error.value)
return
}
// 自动识别
await recognizeVideo()
@@ -306,8 +330,8 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
/** 识别已存在的视频 */
async function recognizeExistingVideo(video: Video): Promise<IdentifyData> {
// 素材列表返回的 fileUrl 已带签名,直接使用
return performFaceRecognition(video.id, video.fileUrl, false)
// 使用已获取的带签名预览URL
return performFaceRecognition(video.id, videoPreviewUrl.value, false)
}
/** 执行人脸识别 */
@@ -361,14 +385,20 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
providerType: DEFAULT_VOICE_PROVIDER,
} as any)
if (res.code !== 0 || !res.data?.audioBase64) {
if (res.code !== 0 || !res.data?.audioUrl) {
throw new Error(res.msg || '音频生成失败')
}
// 使用 URL性能优化
const audioUrl = res.data.audioUrl
if (!audioUrl) {
throw new Error('音频生成失败未返回音频URL')
}
const durationMs = await parseAudioDuration(res.data.audioBase64)
// 获取音频时长
const durationMs = await getAudioDurationFromUrl(audioUrl)
audioData.value = {
audioBase64: res.data.audioBase64,
audioUrl: audioUrl,
format: 'mp3',
durationMs,
}
@@ -421,7 +451,9 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
const taskRes = await createLipSyncTask({
taskName: `数字人任务_${Date.now()}`,
videoFileId: identifyData.value.fileId,
// 根据视频来源选择传递方式
videoUrl: videoSource.value === 'select' ? videoPreviewUrl.value : undefined,
videoFileId: videoSource.value === 'upload' ? identifyData.value.fileId : undefined,
inputText: text.value,
speechRate: speechRate.value,
volume: 0,
@@ -433,10 +465,8 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
kling_face_end_time: identifyData.value.faceEndTime,
ai_provider: 'kling',
voiceConfigId: voiceId,
pre_generated_audio: {
audioBase64: audioData.value.audioBase64,
format: audioData.value.format,
},
// 使用预生成的音频 URL性能优化
audio_url: audioData.value.audioUrl,
sound_end_time: audioData.value.durationMs,
})
@@ -557,37 +587,28 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
// ==================== 工具方法 ====================
/** 解析音频时长 */
async function parseAudioDuration(base64Data: string): Promise<number> {
const base64 = base64Data.includes(',') ? base64Data.split(',')[1] : base64Data
const binaryString = window.atob(base64)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < bytes.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
/** 从 URL 获取音频时长 */
async function getAudioDurationFromUrl(url: string): Promise<number> {
return new Promise((resolve, reject) => {
const blob = new Blob([bytes], { type: 'audio/mp3' })
const audio = new Audio()
const objectUrl = URL.createObjectURL(blob)
const timeoutId = setTimeout(() => {
cleanup()
reject(new Error('音频解析超时'))
}, 15000)
let resolved = false
let lastDuration = 0
function cleanup() {
const timeoutId = setTimeout(() => {
if (!resolved) {
cleanup()
reject(new Error('音频加载超时'))
}
}, 15000)
const cleanup = () => {
clearTimeout(timeoutId)
URL.revokeObjectURL(objectUrl)
audio.pause()
audio.src = ''
}
function tryResolve(duration: number, source: string) {
if (resolved) return
if (!isFinite(duration) || duration <= 0) return
const tryResolve = (duration: number, source: string) => {
if (resolved || !isFinite(duration) || duration <= 0) return
lastDuration = duration
if (source === 'canplaythrough') {
@@ -601,19 +622,18 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
audio.oncanplay = () => tryResolve(audio.duration, 'canplay')
audio.oncanplaythrough = () => tryResolve(audio.duration, 'canplaythrough')
audio.onerror = () => {
if (!resolved) {
if (resolved) return
if (lastDuration > 0) {
resolved = true
cleanup()
resolve(Math.floor(lastDuration * 1000) - 200)
} else {
cleanup()
reject(new Error('音频解析失败'))
}
reject(new Error('音频加载失败'))
}
}
audio.src = objectUrl
audio.src = url
audio.load()
})
}
@@ -650,6 +670,7 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
isAudioReady,
canGoNext,
canGenerate,
validationError,
timelineMatch,
faceDurationMs,
audioDurationMs,
@@ -693,6 +714,5 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
// ========== 工具函数 ==========
function extractId(str: string): string {
const match = str.match(/[\w-]+$/)
return match ? match[0] : str
return str.match(/[\w-]+$/)?.[0] ?? str
}

View File

@@ -95,9 +95,9 @@ export interface AudioState {
* 音频数据接口
*/
export interface AudioData {
audioBase64: string
audioUrl?: string
audioUrl: string // 预签名 URL性能优化
format?: string
durationMs?: number // 音频时长(毫秒)
}
/**
@@ -253,9 +253,6 @@ export interface LipSyncTaskData {
ai_provider: string
voiceId?: string // 系统预置音色ID
voiceConfigId?: string // 用户配音IDtik_user_voice.id
pre_generated_audio?: {
audioBase64: string
format: string
}
audio_url?: string // 预生成音频 URL性能优化
sound_end_time?: number
}

View File

@@ -101,7 +101,7 @@
type="link"
size="small"
class="action-btn action-btn--primary"
@click="handlePreview(record)"
@click="openVideoUrl(record)"
>
<PlayCircleOutlined /> 预览
</a-button>
@@ -111,7 +111,7 @@
type="link"
size="small"
class="action-btn action-btn--success"
@click="handleDownload(record)"
@click="openVideoUrl(record)"
>
<DownloadOutlined /> 下载
</a-button>
@@ -132,13 +132,6 @@
</a-table>
</a-spin>
</div>
<!-- 视频预览弹窗 -->
<VideoPreviewModal
v-model:open="previewVisible"
:video-url="previewUrl"
:title="previewTitle"
/>
</div>
</template>
@@ -146,13 +139,12 @@
import { ref, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { SearchOutlined, DownloadOutlined, DeleteOutlined, PlayCircleOutlined } from '@ant-design/icons-vue'
import { getDigitalHumanTaskPage, cancelTask, deleteTask, getSignedUrls } from '@/api/digitalHuman'
import { getDigitalHumanTaskPage, cancelTask, deleteTask } from '@/api/digitalHuman'
import { formatDate } from '@/utils/file'
import { useTaskList } from '@/views/system/task-management/composables/useTaskList'
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling'
import TaskStatusTag from '@/views/system/task-management/components/TaskStatusTag.vue'
import VideoPreviewModal from '@/components/VideoPreviewModal.vue'
// 进度状态映射
const PROGRESS_STATUS = {
@@ -175,45 +167,11 @@ const rowSelection = {
onChange: (keys) => { selectedRowKeys.value = keys }
}
// 视频预览状态
const previewVisible = ref(false)
const previewUrl = ref('')
const previewTitle = ref('')
// 状态判断
const isStatus = (status, target) => status === target || status === target.toUpperCase()
// 预览视频
const handlePreview = async (record) => {
if (!record.id) {
message.warning('任务信息不完整')
return
}
// 显示加载提示
const hideLoading = message.loading('正在获取视频地址...', 0)
try {
// 调用后端API获取带签名的视频URL
const res = await getSignedUrls(record.id)
hideLoading()
if (res.code === 0 && res.data && res.data.length > 0) {
previewUrl.value = res.data[0]
previewTitle.value = record.taskName || '视频预览'
previewVisible.value = true
} else {
message.error(res.msg || '获取视频地址失败')
}
} catch (error) {
hideLoading()
console.error('获取视频播放URL失败:', error)
message.error('获取视频地址失败,请重试')
}
}
// 下载视频
const handleDownload = (record) => {
// 打开视频链接(预览/下载共用)
const openVideoUrl = (record) => {
if (!record.resultVideoUrl) {
message.warning('该任务暂无视频结果,请稍后再试')
return

View File

@@ -113,11 +113,21 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
task.setPendingRecordId(pendingRecordId); // 保存预扣记录ID
taskMapper.insert(task);
// 4. ✅ 立即处理预生成音频(保存为临时文件,供后续步骤使用
// 4. ✅ 立即处理预生成音频(优先使用 URL降级使用 base64
Long taskId = task.getId();
if (reqVO.getPreGeneratedAudio() != null && StrUtil.isNotBlank(reqVO.getPreGeneratedAudio().getAudioBase64())) {
// 优先使用前端传递的 audioUrl性能优化避免 base64 编解码)
if (StrUtil.isNotBlank(reqVO.getAudioUrl())) {
TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO();
updateObj.setId(taskId);
updateObj.setAudioUrl(reqVO.getAudioUrl());
taskMapper.updateById(updateObj);
log.info("[createTask][任务({})使用前端传递的音频URL][audioUrl={}]", taskId, reqVO.getAudioUrl());
}
// 降级:处理 preGeneratedAudio.base64兼容旧版本
else if (reqVO.getPreGeneratedAudio() != null && StrUtil.isNotBlank(reqVO.getPreGeneratedAudio().getAudioBase64())) {
try {
log.info("[createTask][任务({})正在保存预生成音频...]", taskId);
log.info("[createTask][任务({})正在保存预生成音频(base64)...]", taskId);
String audioUrl = saveTempAudioFile(reqVO.getPreGeneratedAudio().getAudioBase64(),
reqVO.getPreGeneratedAudio().getFormat());
// 更新任务记录保存音频URL
@@ -370,7 +380,7 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
*/
private void validateUserFile(Long fileId, Long userId, String fileType) {
TikUserFileDO userFile = userFileMapper.selectOne(new LambdaQueryWrapperX<TikUserFileDO>()
.eq(TikUserFileDO::getFileId, fileId) // 查询fileId字段指向infra_file.id
.eq(TikUserFileDO::getId, fileId) // 用主键ID查询前端传递的是userFileId
.eq(TikUserFileDO::getUserId, userId));
if (userFile == null) {
throw ServiceExceptionUtil.exception(ErrorCodeConstants.FILE_NOT_EXISTS, fileType + "文件不存在");
@@ -525,11 +535,15 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
// 处理视频文件音频由实时TTS生成无需准备
if (task.getVideoFileId() != null) {
FileDO videoFile = fileMapper.selectById(task.getVideoFileId());
// 先查询 tik_user_file 获取 infra_file.id再查询 infra_file
TikUserFileDO userFile = userFileMapper.selectById(task.getVideoFileId());
if (userFile != null && userFile.getFileId() != null) {
FileDO videoFile = fileMapper.selectById(userFile.getFileId());
if (videoFile != null) {
task.setVideoUrl(fileApi.presignGetUrl(videoFile.getUrl(), PRESIGN_URL_EXPIRATION_SECONDS));
}
}
}
// 验证视频文件URL音频是实时生成无需验证
if (StrUtil.isBlank(task.getVideoUrl())) {

View File

@@ -234,10 +234,10 @@ public class LatentsyncPollingService {
TenantUtils.executeIgnore(() -> taskMapper.updateById(updateObj));
// 3. 确认预扣(任务成功,实际扣费)
// 3. 确认预扣(任务成功,实际扣费)- 使用 TenantUtils.executeIgnore 避免 MyBatis Plus 租户过滤
if (task != null && task.getPendingRecordId() != null) {
try {
pointsService.confirmPendingDeduct(task.getPendingRecordId());
TenantUtils.executeIgnore(() -> pointsService.confirmPendingDeduct(task.getPendingRecordId()));
log.info("[completeTask][任务({})成功确认扣费预扣记录ID({})]", taskId, task.getPendingRecordId());
} catch (Exception e) {
log.error("[completeTask][确认扣费失败taskId={}recordId={}]", taskId, task.getPendingRecordId(), e);
@@ -276,10 +276,10 @@ public class LatentsyncPollingService {
TenantUtils.executeIgnore(() -> taskMapper.updateById(updateObj));
// 3. 取消预扣(任务失败,不扣费)
// 3. 取消预扣(任务失败,不扣费)- 使用 TenantUtils.executeIgnore 避免 MyBatis Plus 租户过滤
if (task != null && task.getPendingRecordId() != null) {
try {
pointsService.cancelPendingDeduct(task.getPendingRecordId());
TenantUtils.executeIgnore(() -> pointsService.cancelPendingDeduct(task.getPendingRecordId()));
log.info("[markTaskFailed][任务({})失败取消预扣预扣记录ID({})]", taskId, task.getPendingRecordId());
} catch (Exception e) {
log.error("[markTaskFailed][取消预扣失败taskId={}recordId={}]", taskId, task.getPendingRecordId(), e);
@@ -308,10 +308,10 @@ public class LatentsyncPollingService {
if ("SUCCESS".equals(status)) {
updateObj.setFinishTime(LocalDateTime.now());
// 确认预扣(任务成功)
// 确认预扣(任务成功)- 使用 TenantUtils.executeIgnore 避免 MyBatis Plus 租户过滤
if (task != null && task.getPendingRecordId() != null) {
try {
pointsService.confirmPendingDeduct(task.getPendingRecordId());
TenantUtils.executeIgnore(() -> pointsService.confirmPendingDeduct(task.getPendingRecordId()));
log.info("[updateTaskStatus][任务({})成功,确认扣费]", taskId);
} catch (Exception e) {
log.error("[updateTaskStatus][确认扣费失败taskId={}]", taskId, e);
@@ -322,10 +322,10 @@ public class LatentsyncPollingService {
} else if ("FAILED".equals(status)) {
updateObj.setErrorMessage(errorMessage);
updateObj.setFinishTime(LocalDateTime.now());
// 取消预扣(任务失败)
// 取消预扣(任务失败)- 使用 TenantUtils.executeIgnore 避免 MyBatis Plus 租户过滤
if (task != null && task.getPendingRecordId() != null) {
try {
pointsService.cancelPendingDeduct(task.getPendingRecordId());
TenantUtils.executeIgnore(() -> pointsService.cancelPendingDeduct(task.getPendingRecordId()));
log.info("[updateTaskStatus][任务({})失败,取消预扣]", taskId);
} catch (Exception e) {
log.error("[updateTaskStatus][取消预扣失败taskId={}]", taskId, e);

View File

@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.tik.voice.service;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil;
@@ -445,21 +446,21 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
log.error("[synthesizeVoice][积分扣减失败: {}]", e.getMessage());
}
// 不暴露OSS链接直接返回Base64编码的音频数据
String audioBase64 = Base64.getEncoder().encodeToString(ttsResult.getAudio());
// 保存到 OSS 临时目录,返回预签名 URL性能优化避免 base64 编码
String audioUrl = saveToTempOss(ttsResult.getAudio(), format);
log.info("[synthesizeVoice][合成成功,配音编号({})voiceId({})format({})audioSize={}]",
voiceConfigId, finalVoiceId, format, ttsResult.getAudio().length);
AppTikVoiceTtsRespVO respVO = new AppTikVoiceTtsRespVO();
respVO.setFileId(null); // 不返回fileId避免暴露
respVO.setAudioBase64(audioBase64); // 返回Base64数据,前端可直接播放
respVO.setAudioUrl(audioUrl); // 返回预签名 URL,前端可直接播放
respVO.setFormat(format);
respVO.setSampleRate(ttsResult.getSampleRate());
respVO.setRequestId(""); // 不返回Request ID避免暴露技术细节
respVO.setVoiceId(finalVoiceId);
saveSynthCache(cacheKey, new SynthCacheEntry(
Base64.getEncoder().encodeToString(ttsResult.getAudio()),
audioUrl, // 缓存 URL 而不是 base64
format,
ttsResult.getSampleRate(),
ttsResult.getRequestId(),
@@ -579,43 +580,6 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
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去除查询参数和锚点
*/
@@ -631,6 +595,24 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
}
}
/**
* 保存音频到临时 OSS 目录(预签名 URL1小时过期
*
* @param audioBytes 音频字节数组
* @param format 音频格式(如 mp3
* @return 预签名 URL
*/
private String saveToTempOss(byte[] audioBytes, String format) {
String fileName = "temp/tts/" + IdUtil.fastSimpleUUID() + "." + format;
String mimeType = "audio/" + format;
// 上传到 OSS返回文件路径
String filePath = fileApi.createFile(audioBytes, fileName, "temp/tts", mimeType);
// 返回预签名 URL1小时过期
return fileApi.presignGetUrl(filePath, 3600);
}
private String buildCacheKey(String prefix,
String voiceId,
String fileUrl,
@@ -714,13 +696,13 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
}
private AppTikVoiceTtsRespVO buildSynthResponseFromCache(AppTikVoiceTtsReqVO reqVO, SynthCacheEntry cache) {
// 直接使用缓存的Base64数据不上传OSS
// 使用缓存的 URL性能优化避免重复上传
String format = defaultFormat(cache.getFormat(), reqVO.getAudioFormat());
String voiceId = StrUtil.blankToDefault(reqVO.getVoiceId(), cache.getVoiceId());
AppTikVoiceTtsRespVO respVO = new AppTikVoiceTtsRespVO();
respVO.setFileId(null); // 不返回fileId避免暴露
respVO.setAudioBase64(cache.getAudioBase64()); // 返回Base64数据
respVO.setAudioUrl(cache.getAudioUrl()); // 返回预签名 URL
respVO.setFormat(format);
respVO.setSampleRate(cache.getSampleRate());
respVO.setRequestId(""); // 不返回Request ID避免暴露技术细节
@@ -753,7 +735,7 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
@lombok.NoArgsConstructor
@lombok.AllArgsConstructor
private static class SynthCacheEntry {
private String audioBase64;
private String audioUrl; // 改为存储 URL
private String format;
private Integer sampleRate;
private String requestId;

View File

@@ -95,10 +95,14 @@ public class AppTikDigitalHumanCreateReqVO {
@JsonProperty("pre_generated_audio")
private PreGeneratedAudioVO preGeneratedAudio;
@Schema(description = "预生成音频URL与 preGeneratedAudio 二选一,优先使用)", example = "https://oss.example.com/temp/tts/xxx.mp3")
@JsonProperty("audio_url")
private String audioUrl;
@Data
@Schema(description = "预生成音频信息")
public static class PreGeneratedAudioVO {
@Schema(description = "音频Base64数据", example = "data:audio/mp3;base64,...")
@Schema(description = "音频Base64数据(降级方案)", example = "data:audio/mp3;base64,...")
private String audioBase64;
@Schema(description = "音频格式", example = "mp3")

View File

@@ -10,11 +10,10 @@ public class AppTikVoiceTtsRespVO {
@Schema(description = "用户文件编号", example = "1024")
private Long fileId;
@Schema(description = "音频Base64数据可直接播放,使用 data:audio/...;base64,... 格式")
@Schema(description = "音频Base64数据降级方案,优先使用 audioUrl")
private String audioBase64;
@Schema(description = "音频播放地址(预签名 URL已废弃,不推荐使用")
@Deprecated
@Schema(description = "音频播放地址(预签名 URL1小时过期")
private String audioUrl;
@Schema(description = "音频格式", example = "mp3")