Compare commits
3 Commits
e046335900
...
52a1094144
| Author | SHA1 | Date | |
|---|---|---|---|
| 52a1094144 | |||
| dff90abbb4 | |||
| 9b132082d2 |
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
/>
|
||||
|
||||
<!-- 积分预估 -->
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
<!-- 生成音频按钮 -->
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 // 用户配音ID(tik_user_voice.id)
|
||||
pre_generated_audio?: {
|
||||
audioBase64: string
|
||||
format: string
|
||||
}
|
||||
audio_url?: string // 预生成音频 URL(性能优化)
|
||||
sound_end_time?: number
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 目录(预签名 URL,1小时过期)
|
||||
*
|
||||
* @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);
|
||||
|
||||
// 返回预签名 URL(1小时过期)
|
||||
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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = "音频播放地址(预签名 URL,1小时过期)")
|
||||
private String audioUrl;
|
||||
|
||||
@Schema(description = "音频格式", example = "mp3")
|
||||
|
||||
Reference in New Issue
Block a user