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 voiceStore = useVoiceCopyStore()
|
||||||
|
|
||||||
const emit = defineEmits(['select'])
|
const emit = defineEmits(['select', 'audioGenerated'])
|
||||||
|
|
||||||
let player = null
|
let player = null
|
||||||
const playerContainer = ref(null)
|
const playerContainer = ref(null)
|
||||||
@@ -182,9 +182,7 @@ const handleVoiceChange = (value, option) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSynthesize = () => {
|
const handleSynthesize = () => {
|
||||||
if (!selectedVoiceId.value) return
|
if (!selectedVoiceId.value || isPlayerInitializing.value) return
|
||||||
// 防止在播放器初始化过程中重复点击
|
|
||||||
if (isPlayerInitializing.value) return
|
|
||||||
|
|
||||||
const voice = userVoiceCards.value.find(v => v.id === selectedVoiceId.value)
|
const voice = userVoiceCards.value.find(v => v.id === selectedVoiceId.value)
|
||||||
if (!voice) return
|
if (!voice) return
|
||||||
@@ -211,10 +209,8 @@ const handlePlayVoiceSample = (voice) => {
|
|||||||
if (!url) return
|
if (!url) return
|
||||||
initPlayer(url)
|
initPlayer(url)
|
||||||
},
|
},
|
||||||
(error) => {
|
undefined, // 错误静默处理
|
||||||
// 音频播放失败,静默处理
|
{ autoPlay: false }
|
||||||
},
|
|
||||||
{ autoPlay: false } // 禁用自动播放,由 APlayer 控制
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,11 +257,18 @@ const initPlayer = (url) => {
|
|||||||
|
|
||||||
player.on('error', (e) => {
|
player.on('error', (e) => {
|
||||||
console.error('APlayer 播放错误:', e)
|
console.error('APlayer 播放错误:', e)
|
||||||
message.error('音频播放失败,请重试')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
player.on('canplay', () => {
|
player.on('canplay', () => {
|
||||||
isPlayerInitializing.value = false
|
isPlayerInitializing.value = false
|
||||||
|
// 发送音频时长和 URL 给父组件
|
||||||
|
const durationMs = Math.floor(player.audio.duration * 1000)
|
||||||
|
if (durationMs > 0) {
|
||||||
|
emit('audioGenerated', {
|
||||||
|
durationMs,
|
||||||
|
audioUrl: audioUrl.value // 使用 URL(性能优化)
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('APlayer 初始化失败:', e)
|
console.error('APlayer 初始化失败:', e)
|
||||||
@@ -276,29 +279,21 @@ const initPlayer = (url) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 下载音频
|
|
||||||
*/
|
|
||||||
const downloadAudio = () => {
|
const downloadAudio = () => {
|
||||||
if (!audioUrl.value) return
|
if (!audioUrl.value) return
|
||||||
|
|
||||||
const filename = `${currentVoiceName.value || '语音合成'}.mp3`
|
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = audioUrl.value
|
link.href = audioUrl.value
|
||||||
link.download = filename
|
link.download = `${currentVoiceName.value || '语音合成'}.mp3`
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 销毁播放器
|
|
||||||
*/
|
|
||||||
const destroyPlayer = () => {
|
const destroyPlayer = () => {
|
||||||
isPlayerInitializing.value = false
|
isPlayerInitializing.value = false
|
||||||
if (player) {
|
if (player) {
|
||||||
try {
|
try {
|
||||||
// 先暂停播放,防止销毁过程中出错
|
|
||||||
player.pause()
|
player.pause()
|
||||||
player.destroy()
|
player.destroy()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -306,8 +301,7 @@ const destroyPlayer = () => {
|
|||||||
}
|
}
|
||||||
player = null
|
player = null
|
||||||
}
|
}
|
||||||
// 只对 blob URL 调用 revokeObjectURL
|
if (audioUrl.value?.startsWith('blob:')) {
|
||||||
if (audioUrl.value && audioUrl.value.startsWith('blob:')) {
|
|
||||||
URL.revokeObjectURL(audioUrl.value)
|
URL.revokeObjectURL(audioUrl.value)
|
||||||
}
|
}
|
||||||
audioUrl.value = ''
|
audioUrl.value = ''
|
||||||
@@ -338,25 +332,25 @@ onBeforeUnmount(() => {
|
|||||||
.voice-selector-wrapper {
|
.voice-selector-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 音色卡片 */
|
/* 音色卡片 - 柔和风格 */
|
||||||
.voice-card {
|
.voice-card {
|
||||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%);
|
background: rgba(59, 130, 246, 0.03);
|
||||||
border: 1px solid rgba(59, 130, 246, 0.15);
|
border: 1px solid rgba(59, 130, 246, 0.1);
|
||||||
border-radius: 12px;
|
border-radius: 10px;
|
||||||
padding: 16px;
|
padding: 14px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.25s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: rgba(59, 130, 246, 0.3);
|
border-color: rgba(59, 130, 246, 0.18);
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1);
|
background: rgba(59, 130, 246, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.has-audio {
|
&.has-audio {
|
||||||
border-color: rgba(59, 130, 246, 0.4);
|
border-color: rgba(59, 130, 246, 0.2);
|
||||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%);
|
background: rgba(59, 130, 246, 0.06);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,40 +359,40 @@ onBeforeUnmount(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-icon {
|
.header-icon {
|
||||||
width: 28px;
|
width: 26px;
|
||||||
height: 28px;
|
height: 26px;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
background: rgba(59, 130, 246, 0.1);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: white;
|
color: #3b82f6;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 16px;
|
width: 14px;
|
||||||
height: 16px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-badge {
|
.header-badge {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding: 2px 10px;
|
padding: 2px 8px;
|
||||||
background: rgba(59, 130, 246, 0.1);
|
background: rgba(59, 130, 246, 0.08);
|
||||||
border-radius: 12px;
|
border-radius: 10px;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
max-width: 120px;
|
max-width: 100px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -407,7 +401,7 @@ onBeforeUnmount(() => {
|
|||||||
/* 卡片主体 */
|
/* 卡片主体 */
|
||||||
.voice-card-body {
|
.voice-card-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,73 +410,73 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
:deep(.ant-select) {
|
:deep(.ant-select) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 36px;
|
||||||
|
|
||||||
.ant-select-selector {
|
.ant-select-selector {
|
||||||
height: 40px !important;
|
height: 36px !important;
|
||||||
border-radius: 10px !important;
|
border-radius: 8px !important;
|
||||||
border-color: rgba(59, 130, 246, 0.2) !important;
|
border-color: rgba(59, 130, 246, 0.12) !important;
|
||||||
background: rgba(255, 255, 255, 0.8) !important;
|
background: rgba(255, 255, 255, 0.9) !important;
|
||||||
transition: all 0.3s ease !important;
|
transition: all 0.2s ease !important;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: #3b82f6 !important;
|
border-color: rgba(59, 130, 246, 0.25) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.ant-select-focused .ant-select-selector {
|
&.ant-select-focused .ant-select-selector {
|
||||||
border-color: #3b82f6 !important;
|
border-color: rgba(59, 130, 246, 0.3) !important;
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.06) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-select-selection-item {
|
.ant-select-selection-item {
|
||||||
line-height: 38px !important;
|
line-height: 34px !important;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-select-selection-placeholder {
|
.ant-select-selection-placeholder {
|
||||||
line-height: 38px !important;
|
line-height: 34px !important;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-arrow {
|
.select-arrow {
|
||||||
color: #3b82f6;
|
color: #94a3b8;
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 合成按钮 */
|
/* 合成按钮 - 柔和风格 */
|
||||||
.synthesize-btn {
|
.synthesize-btn {
|
||||||
height: 40px;
|
height: 36px;
|
||||||
padding: 0 20px;
|
padding: 0 16px;
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
border: none;
|
border: none;
|
||||||
background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e1 100%);
|
background: rgba(59, 130, 246, 0.08);
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.25s ease;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: linear-gradient(135deg, #cbd5e1 0%, #94a3b8 100%);
|
background: rgba(59, 130, 246, 0.12);
|
||||||
transform: translateY(-1px);
|
color: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.btn-active {
|
&.btn-active {
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
background: rgba(59, 130, 246, 0.15);
|
||||||
color: white;
|
color: #3b82f6;
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
|
background: rgba(59, 130, 246, 0.2);
|
||||||
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,46 +487,46 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
/* 播放器区域 */
|
/* 播放器区域 */
|
||||||
.player-section {
|
.player-section {
|
||||||
background: rgba(255, 255, 255, 0.6);
|
background: rgba(59, 130, 246, 0.03);
|
||||||
border-radius: 12px;
|
border-radius: 10px;
|
||||||
padding: 14px;
|
padding: 12px;
|
||||||
border: 1px solid rgba(59, 130, 246, 0.1);
|
border: 1px solid rgba(59, 130, 246, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aplayer-container {
|
.aplayer-container {
|
||||||
:deep(.aplayer) {
|
:deep(.aplayer) {
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
.aplayer-body {
|
.aplayer-body {
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-actions {
|
.player-actions {
|
||||||
margin-top: 10px;
|
margin-top: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.download-btn {
|
.download-btn {
|
||||||
color: #3b82f6;
|
color: #94a3b8;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
height: auto;
|
height: auto;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #2563eb;
|
color: #3b82f6;
|
||||||
background: rgba(59, 130, 246, 0.1);
|
background: rgba(59, 130, 246, 0.06);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 动画 */
|
/* 动画 */
|
||||||
.slide-fade-enter-active {
|
.slide-fade-enter-active {
|
||||||
transition: all 0.3s ease-out;
|
transition: all 0.25s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-fade-leave-active {
|
.slide-fade-leave-active {
|
||||||
@@ -540,12 +534,12 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.slide-fade-enter-from {
|
.slide-fade-enter-from {
|
||||||
transform: translateY(-10px);
|
transform: translateY(-8px);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-fade-leave-to {
|
.slide-fade-leave-to {
|
||||||
transform: translateY(-10px);
|
transform: translateY(-8px);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ export function useTTS(options = {}) {
|
|||||||
// 处理 Base64 音频
|
// 处理 Base64 音频
|
||||||
if (res.data?.audioBase64) {
|
if (res.data?.audioBase64) {
|
||||||
const { blob, objectUrl } = decodeBase64Audio(res.data.audioBase64, res.data.format)
|
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)
|
cacheAudio(cacheKey, audioData)
|
||||||
resetPreviewState()
|
resetPreviewState()
|
||||||
if (opts.autoPlay !== false) playCachedAudio(audioData)
|
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"
|
:audio-duration-ms="store.timeline.audioDurationMs"
|
||||||
:face-start-time="store.timeline.faceStartTime"
|
:face-start-time="store.timeline.faceStartTime"
|
||||||
:face-end-time="store.timeline.faceEndTime"
|
:face-end-time="store.timeline.faceEndTime"
|
||||||
|
:validation-error="store.validationError"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 积分预估 -->
|
<!-- 积分预估 -->
|
||||||
|
|||||||
@@ -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-panel">
|
||||||
<div class="timeline-header">
|
<div class="timeline-header">
|
||||||
<span class="timeline-title">时间轴对比</span>
|
<span class="timeline-title">时间轴对比</span>
|
||||||
<span v-if="showDurations" class="duration-info">
|
<span v-if="showDurations" class="duration-badge">
|
||||||
人脸: {{ formatDuration(faceDurationMs) }}
|
人脸 {{ formatDuration(faceDurationMs) }}
|
||||||
<template v-if="audioDurationMs > 0">
|
<template v-if="audioDurationMs > 0">
|
||||||
| 音频: {{ formatDuration(audioDurationMs) }}
|
<span class="divider"></span>
|
||||||
|
音频 {{ formatDuration(audioDurationMs) }}
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -21,28 +22,42 @@
|
|||||||
<span class="ruler-label">{{ mark.label }}</span>
|
<span class="ruler-label">{{ mark.label }}</span>
|
||||||
<span class="ruler-tick"></span>
|
<span class="ruler-tick"></span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<!-- 轨道区域 -->
|
<!-- 轨道区域 -->
|
||||||
<div class="timeline-tracks">
|
<div class="timeline-tracks">
|
||||||
<!-- 视频轨道 -->
|
<!-- 视频轨道 -->
|
||||||
<div class="track video-track">
|
<div class="track">
|
||||||
<span class="track-icon">📹</span>
|
<div class="track-info">
|
||||||
<span class="track-label">视频</span>
|
<span class="track-icon">📹</span>
|
||||||
|
<span class="track-label">视频</span>
|
||||||
|
</div>
|
||||||
<div class="track-bar">
|
<div class="track-bar">
|
||||||
<div
|
<div
|
||||||
class="track-fill video-fill"
|
class="track-fill video-fill"
|
||||||
:style="{ width: videoBarWidth + '%' }"
|
: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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 音频轨道 -->
|
<!-- 音频轨道 -->
|
||||||
<div class="track audio-track">
|
<div class="track">
|
||||||
<span class="track-icon">🎵</span>
|
<div class="track-info">
|
||||||
<span class="track-label">音频</span>
|
<span class="track-icon">🎙️</span>
|
||||||
|
<span class="track-label">音频</span>
|
||||||
|
</div>
|
||||||
<div class="track-bar">
|
<div class="track-bar">
|
||||||
<div
|
<div
|
||||||
v-if="audioDurationMs > 0"
|
v-if="audioDurationMs > 0"
|
||||||
@@ -50,22 +65,27 @@
|
|||||||
:class="{ 'audio-exceed': isExceed }"
|
:class="{ 'audio-exceed': isExceed }"
|
||||||
:style="{ width: audioBarWidth + '%' }"
|
:style="{ width: audioBarWidth + '%' }"
|
||||||
>
|
>
|
||||||
<span class="track-time">{{ formatDuration(audioDurationMs) }}</span>
|
<span v-if="audioBarWidth > 15" class="track-time">{{ formatDuration(audioDurationMs) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="track-placeholder">等待生成音频</span>
|
<span v-else class="track-placeholder">等待生成音频</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 校验错误提示 -->
|
||||||
|
<div v-if="validationError" class="timeline-diff error">
|
||||||
|
<CloseCircleOutlined class="diff-icon" />
|
||||||
|
<span>{{ validationError }}</span>
|
||||||
|
</div>
|
||||||
<!-- 时长差异提示 -->
|
<!-- 时长差异提示 -->
|
||||||
<div v-if="audioDurationMs > 0" class="timeline-diff" :class="diffStatus">
|
<div v-else-if="audioDurationMs > 0" class="timeline-diff" :class="diffStatus">
|
||||||
<template v-if="diffStatus === 'match'">
|
<template v-if="diffStatus === 'match'">
|
||||||
<CheckCircleOutlined class="diff-icon" />
|
<CheckCircleOutlined class="diff-icon" />
|
||||||
<span>时长匹配良好</span>
|
<span>时长匹配良好,可以生成</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="diffStatus === 'exceed'">
|
<template v-else-if="diffStatus === 'exceed'">
|
||||||
<ExclamationCircleOutlined class="diff-icon" />
|
<ExclamationCircleOutlined class="diff-icon" />
|
||||||
<span>音频超出 {{ formatDuration(diffMs) }},请缩短文案</span>
|
<span>音频超出 {{ formatDuration(diffMs) }},建议缩短文案</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="diffStatus === 'short'">
|
<template v-else-if="diffStatus === 'short'">
|
||||||
<InfoCircleOutlined class="diff-icon" />
|
<InfoCircleOutlined class="diff-icon" />
|
||||||
@@ -77,7 +97,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { CheckCircleOutlined, ExclamationCircleOutlined, InfoCircleOutlined } from '@ant-design/icons-vue'
|
import { CheckCircleOutlined, ExclamationCircleOutlined, InfoCircleOutlined, CloseCircleOutlined } from '@ant-design/icons-vue'
|
||||||
import { formatDurationMs } from '../utils/format'
|
import { formatDurationMs } from '../utils/format'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -85,11 +105,13 @@ interface Props {
|
|||||||
audioDurationMs: number
|
audioDurationMs: number
|
||||||
faceStartTime?: number
|
faceStartTime?: number
|
||||||
faceEndTime?: number
|
faceEndTime?: number
|
||||||
|
validationError?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
faceStartTime: 0,
|
faceStartTime: 0,
|
||||||
faceEndTime: 0,
|
faceEndTime: 0,
|
||||||
|
validationError: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const maxDuration = computed(() => {
|
const maxDuration = computed(() => {
|
||||||
@@ -105,6 +127,10 @@ const audioBarWidth = computed(() =>
|
|||||||
Math.min(100, (props.audioDurationMs / maxDuration.value) * 100)
|
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 isExceed = computed(() => props.audioDurationMs > props.faceDurationMs)
|
||||||
|
|
||||||
const diffMs = computed(() => Math.abs(props.audioDurationMs - props.faceDurationMs))
|
const diffMs = computed(() => Math.abs(props.audioDurationMs - props.faceDurationMs))
|
||||||
@@ -151,11 +177,25 @@ const formatDuration = formatDurationMs
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<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 {
|
.timeline-panel {
|
||||||
background: #f8fafc;
|
background: @bg-subtle;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 16px;
|
padding: 14px 18px;
|
||||||
margin-top: 16px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-header {
|
.timeline-header {
|
||||||
@@ -163,25 +203,37 @@ const formatDuration = formatDurationMs
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.timeline-title {
|
.timeline-title {
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1e293b;
|
color: @text-secondary;
|
||||||
}
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
.duration-info {
|
.duration-badge {
|
||||||
font-size: 12px;
|
display: flex;
|
||||||
color: #64748b;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: @text-tertiary;
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
background: @border-medium;
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刻度尺
|
// 刻度尺
|
||||||
.timeline-ruler {
|
.timeline-ruler {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 20px;
|
height: 18px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 10px;
|
||||||
margin-left: 70px; // 为图标和标签留空间
|
margin-left: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ruler-mark {
|
.ruler-mark {
|
||||||
@@ -194,7 +246,7 @@ const formatDuration = formatDurationMs
|
|||||||
|
|
||||||
.ruler-label {
|
.ruler-label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: #94a3b8;
|
color: @text-tertiary;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +254,35 @@ const formatDuration = formatDurationMs
|
|||||||
display: block;
|
display: block;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 4px;
|
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,100 +295,115 @@ const formatDuration = formatDurationMs
|
|||||||
.track {
|
.track {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.track-icon {
|
.track-info {
|
||||||
font-size: 14px;
|
display: flex;
|
||||||
width: 20px;
|
align-items: center;
|
||||||
text-align: center;
|
gap: 6px;
|
||||||
}
|
width: 68px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.track-label {
|
.track-icon {
|
||||||
width: 34px;
|
font-size: 14px;
|
||||||
font-size: 12px;
|
line-height: 1;
|
||||||
color: #64748b;
|
}
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-bar {
|
.track-label {
|
||||||
flex: 1;
|
font-size: 12px;
|
||||||
height: 24px;
|
color: @text-secondary;
|
||||||
background: #e2e8f0;
|
font-weight: 500;
|
||||||
border-radius: 4px;
|
}
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-fill {
|
.track-bar {
|
||||||
height: 100%;
|
flex: 1;
|
||||||
border-radius: 4px;
|
height: 22px;
|
||||||
display: flex;
|
background: rgba(55, 53, 47, 0.06);
|
||||||
align-items: center;
|
border-radius: 4px;
|
||||||
justify-content: center;
|
position: relative;
|
||||||
transition: width 0.3s ease;
|
overflow: hidden;
|
||||||
}
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.track-time {
|
.track-fill {
|
||||||
font-size: 11px;
|
height: 100%;
|
||||||
color: white;
|
border-radius: 4px;
|
||||||
font-weight: 500;
|
display: flex;
|
||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
align-items: center;
|
||||||
}
|
justify-content: center;
|
||||||
|
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.track-placeholder {
|
.track-time {
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
color: #94a3b8;
|
color: rgba(255, 255, 255, 0.95);
|
||||||
padding-left: 12px;
|
font-weight: 600;
|
||||||
}
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-placeholder {
|
||||||
|
font-size: 11px;
|
||||||
|
color: @text-tertiary;
|
||||||
|
padding-left: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-fill {
|
.video-fill {
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
background: linear-gradient(90deg, @accent-blue 0%, @accent-purple 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-fill {
|
.audio-fill {
|
||||||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
background: linear-gradient(90deg, @accent-green 0%, #059669 100%);
|
||||||
|
|
||||||
&.audio-exceed {
|
&.audio-exceed {
|
||||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
background: linear-gradient(90deg, @accent-red 0%, #dc2626 100%);
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
animation: pulse-warning 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse-warning {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.7; }
|
50% { opacity: 0.75; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 差异提示
|
// 差异提示
|
||||||
.timeline-diff {
|
.timeline-diff {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding: 8px 12px;
|
padding: 10px 14px;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
.diff-icon {
|
.diff-icon {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.match {
|
&.match {
|
||||||
background: #dcfce7;
|
background: rgba(16, 185, 129, 0.08);
|
||||||
color: #166534;
|
color: @accent-green;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.exceed {
|
&.exceed {
|
||||||
background: #fee2e2;
|
background: rgba(239, 68, 68, 0.08);
|
||||||
color: #dc2626;
|
color: @accent-red;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.short {
|
&.short {
|
||||||
background: #fef3c7;
|
background: rgba(245, 158, 11, 0.08);
|
||||||
color: #92400e;
|
color: @accent-orange;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
color: @accent-red;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
:audio-duration-ms="store.timeline.audioDurationMs"
|
:audio-duration-ms="store.timeline.audioDurationMs"
|
||||||
:face-start-time="store.timeline.faceStartTime"
|
:face-start-time="store.timeline.faceStartTime"
|
||||||
:face-end-time="store.timeline.faceEndTime"
|
:face-end-time="store.timeline.faceEndTime"
|
||||||
|
:validation-error="store.validationError"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 生成音频按钮 -->
|
<!-- 生成音频按钮 -->
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import type { VoiceMeta, Video, PipelinePhase, VideoStep, AudioStep, CreateStep,
|
|||||||
|
|
||||||
/** 音频数据 */
|
/** 音频数据 */
|
||||||
interface AudioData {
|
interface AudioData {
|
||||||
audioBase64: string
|
audioUrl: string // 预签名 URL
|
||||||
format: string
|
format: string
|
||||||
durationMs: number
|
durationMs: number
|
||||||
}
|
}
|
||||||
@@ -126,10 +126,22 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
|||||||
const canGenerate = computed(() => {
|
const canGenerate = computed(() => {
|
||||||
if (!isVideoReady.value || !isAudioReady.value) return false
|
if (!isVideoReady.value || !isAudioReady.value) return false
|
||||||
if (!timeline.value) return false
|
if (!timeline.value) return false
|
||||||
// 音频时长不能超过人脸时长
|
// 1. 人脸区间至少2秒
|
||||||
|
if (timeline.value.videoDurationMs < 2000) return false
|
||||||
|
// 2. 音频时长不能超过人脸时长
|
||||||
return timeline.value.audioDurationMs <= timeline.value.videoDurationMs
|
return timeline.value.audioDurationMs <= timeline.value.videoDurationMs
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 校验失败原因 */
|
||||||
|
const validationError = computed(() => {
|
||||||
|
if (!timeline.value) return '请先完成视频识别'
|
||||||
|
if (!isVideoReady.value) return '请先完成视频识别'
|
||||||
|
if (!isAudioReady.value) return '请先生成音频'
|
||||||
|
if (timeline.value.videoDurationMs < 2000) return '人脸区间不足2秒,无法生成对口型视频'
|
||||||
|
if (timeline.value.audioDurationMs > timeline.value.videoDurationMs) return '音频时长超过人脸时长,请缩短文案'
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
/** 时间轴匹配状态 */
|
/** 时间轴匹配状态 */
|
||||||
const timelineMatch = computed(() => {
|
const timelineMatch = computed(() => {
|
||||||
if (!timeline.value || timeline.value.audioDurationMs === 0) return 'none'
|
if (!timeline.value || timeline.value.audioDurationMs === 0) return 'none'
|
||||||
@@ -239,8 +251,20 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
|||||||
videoFile.value = null
|
videoFile.value = null
|
||||||
videoSource.value = 'select'
|
videoSource.value = 'select'
|
||||||
videoSelectorVisible.value = false
|
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()
|
await recognizeVideo()
|
||||||
@@ -306,8 +330,8 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
|||||||
|
|
||||||
/** 识别已存在的视频 */
|
/** 识别已存在的视频 */
|
||||||
async function recognizeExistingVideo(video: Video): Promise<IdentifyData> {
|
async function recognizeExistingVideo(video: Video): Promise<IdentifyData> {
|
||||||
// 素材列表返回的 fileUrl 已带签名,直接使用
|
// 使用已获取的带签名预览URL
|
||||||
return performFaceRecognition(video.id, video.fileUrl, false)
|
return performFaceRecognition(video.id, videoPreviewUrl.value, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 执行人脸识别 */
|
/** 执行人脸识别 */
|
||||||
@@ -361,14 +385,20 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
|||||||
providerType: DEFAULT_VOICE_PROVIDER,
|
providerType: DEFAULT_VOICE_PROVIDER,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
if (res.code !== 0 || !res.data?.audioBase64) {
|
if (res.code !== 0 || !res.data?.audioUrl) {
|
||||||
throw new Error(res.msg || '音频生成失败')
|
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 = {
|
audioData.value = {
|
||||||
audioBase64: res.data.audioBase64,
|
audioUrl: audioUrl,
|
||||||
format: 'mp3',
|
format: 'mp3',
|
||||||
durationMs,
|
durationMs,
|
||||||
}
|
}
|
||||||
@@ -421,7 +451,9 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
|||||||
|
|
||||||
const taskRes = await createLipSyncTask({
|
const taskRes = await createLipSyncTask({
|
||||||
taskName: `数字人任务_${Date.now()}`,
|
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,
|
inputText: text.value,
|
||||||
speechRate: speechRate.value,
|
speechRate: speechRate.value,
|
||||||
volume: 0,
|
volume: 0,
|
||||||
@@ -433,10 +465,8 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
|||||||
kling_face_end_time: identifyData.value.faceEndTime,
|
kling_face_end_time: identifyData.value.faceEndTime,
|
||||||
ai_provider: 'kling',
|
ai_provider: 'kling',
|
||||||
voiceConfigId: voiceId,
|
voiceConfigId: voiceId,
|
||||||
pre_generated_audio: {
|
// 使用预生成的音频 URL(性能优化)
|
||||||
audioBase64: audioData.value.audioBase64,
|
audio_url: audioData.value.audioUrl,
|
||||||
format: audioData.value.format,
|
|
||||||
},
|
|
||||||
sound_end_time: audioData.value.durationMs,
|
sound_end_time: audioData.value.durationMs,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -557,37 +587,28 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
|||||||
|
|
||||||
// ==================== 工具方法 ====================
|
// ==================== 工具方法 ====================
|
||||||
|
|
||||||
/** 解析音频时长 */
|
/** 从 URL 获取音频时长 */
|
||||||
async function parseAudioDuration(base64Data: string): Promise<number> {
|
async function getAudioDurationFromUrl(url: 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const blob = new Blob([bytes], { type: 'audio/mp3' })
|
|
||||||
const audio = new Audio()
|
const audio = new Audio()
|
||||||
const objectUrl = URL.createObjectURL(blob)
|
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
cleanup()
|
|
||||||
reject(new Error('音频解析超时'))
|
|
||||||
}, 15000)
|
|
||||||
|
|
||||||
let resolved = false
|
let resolved = false
|
||||||
let lastDuration = 0
|
let lastDuration = 0
|
||||||
|
|
||||||
function cleanup() {
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (!resolved) {
|
||||||
|
cleanup()
|
||||||
|
reject(new Error('音频加载超时'))
|
||||||
|
}
|
||||||
|
}, 15000)
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
URL.revokeObjectURL(objectUrl)
|
audio.pause()
|
||||||
|
audio.src = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryResolve(duration: number, source: string) {
|
const tryResolve = (duration: number, source: string) => {
|
||||||
if (resolved) return
|
if (resolved || !isFinite(duration) || duration <= 0) return
|
||||||
if (!isFinite(duration) || duration <= 0) return
|
|
||||||
|
|
||||||
lastDuration = duration
|
lastDuration = duration
|
||||||
|
|
||||||
if (source === 'canplaythrough') {
|
if (source === 'canplaythrough') {
|
||||||
@@ -601,19 +622,18 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
|||||||
audio.oncanplay = () => tryResolve(audio.duration, 'canplay')
|
audio.oncanplay = () => tryResolve(audio.duration, 'canplay')
|
||||||
audio.oncanplaythrough = () => tryResolve(audio.duration, 'canplaythrough')
|
audio.oncanplaythrough = () => tryResolve(audio.duration, 'canplaythrough')
|
||||||
audio.onerror = () => {
|
audio.onerror = () => {
|
||||||
if (!resolved) {
|
if (resolved) return
|
||||||
if (lastDuration > 0) {
|
if (lastDuration > 0) {
|
||||||
resolved = true
|
resolved = true
|
||||||
cleanup()
|
cleanup()
|
||||||
resolve(Math.floor(lastDuration * 1000) - 200)
|
resolve(Math.floor(lastDuration * 1000) - 200)
|
||||||
} else {
|
} else {
|
||||||
cleanup()
|
cleanup()
|
||||||
reject(new Error('音频解析失败'))
|
reject(new Error('音频加载失败'))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
audio.src = objectUrl
|
audio.src = url
|
||||||
audio.load()
|
audio.load()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -650,6 +670,7 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
|||||||
isAudioReady,
|
isAudioReady,
|
||||||
canGoNext,
|
canGoNext,
|
||||||
canGenerate,
|
canGenerate,
|
||||||
|
validationError,
|
||||||
timelineMatch,
|
timelineMatch,
|
||||||
faceDurationMs,
|
faceDurationMs,
|
||||||
audioDurationMs,
|
audioDurationMs,
|
||||||
@@ -693,6 +714,5 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
|||||||
// ========== 工具函数 ==========
|
// ========== 工具函数 ==========
|
||||||
|
|
||||||
function extractId(str: string): string {
|
function extractId(str: string): string {
|
||||||
const match = str.match(/[\w-]+$/)
|
return str.match(/[\w-]+$/)?.[0] ?? str
|
||||||
return match ? match[0] : str
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ export interface AudioState {
|
|||||||
* 音频数据接口
|
* 音频数据接口
|
||||||
*/
|
*/
|
||||||
export interface AudioData {
|
export interface AudioData {
|
||||||
audioBase64: string
|
audioUrl: string // 预签名 URL(性能优化)
|
||||||
audioUrl?: string
|
|
||||||
format?: string
|
format?: string
|
||||||
|
durationMs?: number // 音频时长(毫秒)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -253,9 +253,6 @@ export interface LipSyncTaskData {
|
|||||||
ai_provider: string
|
ai_provider: string
|
||||||
voiceId?: string // 系统预置音色ID
|
voiceId?: string // 系统预置音色ID
|
||||||
voiceConfigId?: string // 用户配音ID(tik_user_voice.id)
|
voiceConfigId?: string // 用户配音ID(tik_user_voice.id)
|
||||||
pre_generated_audio?: {
|
audio_url?: string // 预生成音频 URL(性能优化)
|
||||||
audioBase64: string
|
|
||||||
format: string
|
|
||||||
}
|
|
||||||
sound_end_time?: number
|
sound_end_time?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
class="action-btn action-btn--primary"
|
class="action-btn action-btn--primary"
|
||||||
@click="handlePreview(record)"
|
@click="openVideoUrl(record)"
|
||||||
>
|
>
|
||||||
<PlayCircleOutlined /> 预览
|
<PlayCircleOutlined /> 预览
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
class="action-btn action-btn--success"
|
class="action-btn action-btn--success"
|
||||||
@click="handleDownload(record)"
|
@click="openVideoUrl(record)"
|
||||||
>
|
>
|
||||||
<DownloadOutlined /> 下载
|
<DownloadOutlined /> 下载
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -132,13 +132,6 @@
|
|||||||
</a-table>
|
</a-table>
|
||||||
</a-spin>
|
</a-spin>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 视频预览弹窗 -->
|
|
||||||
<VideoPreviewModal
|
|
||||||
v-model:open="previewVisible"
|
|
||||||
:video-url="previewUrl"
|
|
||||||
:title="previewTitle"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -146,13 +139,12 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import { SearchOutlined, DownloadOutlined, DeleteOutlined, PlayCircleOutlined } from '@ant-design/icons-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 { formatDate } from '@/utils/file'
|
||||||
import { useTaskList } from '@/views/system/task-management/composables/useTaskList'
|
import { useTaskList } from '@/views/system/task-management/composables/useTaskList'
|
||||||
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
|
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
|
||||||
import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling'
|
import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling'
|
||||||
import TaskStatusTag from '@/views/system/task-management/components/TaskStatusTag.vue'
|
import TaskStatusTag from '@/views/system/task-management/components/TaskStatusTag.vue'
|
||||||
import VideoPreviewModal from '@/components/VideoPreviewModal.vue'
|
|
||||||
|
|
||||||
// 进度状态映射
|
// 进度状态映射
|
||||||
const PROGRESS_STATUS = {
|
const PROGRESS_STATUS = {
|
||||||
@@ -175,45 +167,11 @@ const rowSelection = {
|
|||||||
onChange: (keys) => { selectedRowKeys.value = keys }
|
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 isStatus = (status, target) => status === target || status === target.toUpperCase()
|
||||||
|
|
||||||
// 预览视频
|
// 打开视频链接(预览/下载共用)
|
||||||
const handlePreview = async (record) => {
|
const openVideoUrl = (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) => {
|
|
||||||
if (!record.resultVideoUrl) {
|
if (!record.resultVideoUrl) {
|
||||||
message.warning('该任务暂无视频结果,请稍后再试')
|
message.warning('该任务暂无视频结果,请稍后再试')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -113,11 +113,21 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
|
|||||||
task.setPendingRecordId(pendingRecordId); // 保存预扣记录ID
|
task.setPendingRecordId(pendingRecordId); // 保存预扣记录ID
|
||||||
taskMapper.insert(task);
|
taskMapper.insert(task);
|
||||||
|
|
||||||
// 4. ✅ 立即处理预生成音频(保存为临时文件,供后续步骤使用)
|
// 4. ✅ 立即处理预生成音频(优先使用 URL,降级使用 base64)
|
||||||
Long taskId = task.getId();
|
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 {
|
try {
|
||||||
log.info("[createTask][任务({})正在保存预生成音频...]", taskId);
|
log.info("[createTask][任务({})正在保存预生成音频(base64)...]", taskId);
|
||||||
String audioUrl = saveTempAudioFile(reqVO.getPreGeneratedAudio().getAudioBase64(),
|
String audioUrl = saveTempAudioFile(reqVO.getPreGeneratedAudio().getAudioBase64(),
|
||||||
reqVO.getPreGeneratedAudio().getFormat());
|
reqVO.getPreGeneratedAudio().getFormat());
|
||||||
// 更新任务记录,保存音频URL
|
// 更新任务记录,保存音频URL
|
||||||
@@ -370,7 +380,7 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
|
|||||||
*/
|
*/
|
||||||
private void validateUserFile(Long fileId, Long userId, String fileType) {
|
private void validateUserFile(Long fileId, Long userId, String fileType) {
|
||||||
TikUserFileDO userFile = userFileMapper.selectOne(new LambdaQueryWrapperX<TikUserFileDO>()
|
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));
|
.eq(TikUserFileDO::getUserId, userId));
|
||||||
if (userFile == null) {
|
if (userFile == null) {
|
||||||
throw ServiceExceptionUtil.exception(ErrorCodeConstants.FILE_NOT_EXISTS, fileType + "文件不存在");
|
throw ServiceExceptionUtil.exception(ErrorCodeConstants.FILE_NOT_EXISTS, fileType + "文件不存在");
|
||||||
@@ -525,9 +535,13 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
|
|||||||
|
|
||||||
// 处理视频文件(音频由实时TTS生成,无需准备)
|
// 处理视频文件(音频由实时TTS生成,无需准备)
|
||||||
if (task.getVideoFileId() != null) {
|
if (task.getVideoFileId() != null) {
|
||||||
FileDO videoFile = fileMapper.selectById(task.getVideoFileId());
|
// 先查询 tik_user_file 获取 infra_file.id,再查询 infra_file
|
||||||
if (videoFile != null) {
|
TikUserFileDO userFile = userFileMapper.selectById(task.getVideoFileId());
|
||||||
task.setVideoUrl(fileApi.presignGetUrl(videoFile.getUrl(), PRESIGN_URL_EXPIRATION_SECONDS));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -234,10 +234,10 @@ public class LatentsyncPollingService {
|
|||||||
|
|
||||||
TenantUtils.executeIgnore(() -> taskMapper.updateById(updateObj));
|
TenantUtils.executeIgnore(() -> taskMapper.updateById(updateObj));
|
||||||
|
|
||||||
// 3. 确认预扣(任务成功,实际扣费)
|
// 3. 确认预扣(任务成功,实际扣费)- 使用 TenantUtils.executeIgnore 避免 MyBatis Plus 租户过滤
|
||||||
if (task != null && task.getPendingRecordId() != null) {
|
if (task != null && task.getPendingRecordId() != null) {
|
||||||
try {
|
try {
|
||||||
pointsService.confirmPendingDeduct(task.getPendingRecordId());
|
TenantUtils.executeIgnore(() -> pointsService.confirmPendingDeduct(task.getPendingRecordId()));
|
||||||
log.info("[completeTask][任务({})成功,确认扣费,预扣记录ID({})]", taskId, task.getPendingRecordId());
|
log.info("[completeTask][任务({})成功,确认扣费,预扣记录ID({})]", taskId, task.getPendingRecordId());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[completeTask][确认扣费失败,taskId={},recordId={}]", taskId, task.getPendingRecordId(), e);
|
log.error("[completeTask][确认扣费失败,taskId={},recordId={}]", taskId, task.getPendingRecordId(), e);
|
||||||
@@ -276,10 +276,10 @@ public class LatentsyncPollingService {
|
|||||||
|
|
||||||
TenantUtils.executeIgnore(() -> taskMapper.updateById(updateObj));
|
TenantUtils.executeIgnore(() -> taskMapper.updateById(updateObj));
|
||||||
|
|
||||||
// 3. 取消预扣(任务失败,不扣费)
|
// 3. 取消预扣(任务失败,不扣费)- 使用 TenantUtils.executeIgnore 避免 MyBatis Plus 租户过滤
|
||||||
if (task != null && task.getPendingRecordId() != null) {
|
if (task != null && task.getPendingRecordId() != null) {
|
||||||
try {
|
try {
|
||||||
pointsService.cancelPendingDeduct(task.getPendingRecordId());
|
TenantUtils.executeIgnore(() -> pointsService.cancelPendingDeduct(task.getPendingRecordId()));
|
||||||
log.info("[markTaskFailed][任务({})失败,取消预扣,预扣记录ID({})]", taskId, task.getPendingRecordId());
|
log.info("[markTaskFailed][任务({})失败,取消预扣,预扣记录ID({})]", taskId, task.getPendingRecordId());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[markTaskFailed][取消预扣失败,taskId={},recordId={}]", taskId, task.getPendingRecordId(), e);
|
log.error("[markTaskFailed][取消预扣失败,taskId={},recordId={}]", taskId, task.getPendingRecordId(), e);
|
||||||
@@ -308,10 +308,10 @@ public class LatentsyncPollingService {
|
|||||||
|
|
||||||
if ("SUCCESS".equals(status)) {
|
if ("SUCCESS".equals(status)) {
|
||||||
updateObj.setFinishTime(LocalDateTime.now());
|
updateObj.setFinishTime(LocalDateTime.now());
|
||||||
// 确认预扣(任务成功)
|
// 确认预扣(任务成功)- 使用 TenantUtils.executeIgnore 避免 MyBatis Plus 租户过滤
|
||||||
if (task != null && task.getPendingRecordId() != null) {
|
if (task != null && task.getPendingRecordId() != null) {
|
||||||
try {
|
try {
|
||||||
pointsService.confirmPendingDeduct(task.getPendingRecordId());
|
TenantUtils.executeIgnore(() -> pointsService.confirmPendingDeduct(task.getPendingRecordId()));
|
||||||
log.info("[updateTaskStatus][任务({})成功,确认扣费]", taskId);
|
log.info("[updateTaskStatus][任务({})成功,确认扣费]", taskId);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[updateTaskStatus][确认扣费失败,taskId={}]", taskId, e);
|
log.error("[updateTaskStatus][确认扣费失败,taskId={}]", taskId, e);
|
||||||
@@ -322,10 +322,10 @@ public class LatentsyncPollingService {
|
|||||||
} else if ("FAILED".equals(status)) {
|
} else if ("FAILED".equals(status)) {
|
||||||
updateObj.setErrorMessage(errorMessage);
|
updateObj.setErrorMessage(errorMessage);
|
||||||
updateObj.setFinishTime(LocalDateTime.now());
|
updateObj.setFinishTime(LocalDateTime.now());
|
||||||
// 取消预扣(任务失败)
|
// 取消预扣(任务失败)- 使用 TenantUtils.executeIgnore 避免 MyBatis Plus 租户过滤
|
||||||
if (task != null && task.getPendingRecordId() != null) {
|
if (task != null && task.getPendingRecordId() != null) {
|
||||||
try {
|
try {
|
||||||
pointsService.cancelPendingDeduct(task.getPendingRecordId());
|
TenantUtils.executeIgnore(() -> pointsService.cancelPendingDeduct(task.getPendingRecordId()));
|
||||||
log.info("[updateTaskStatus][任务({})失败,取消预扣]", taskId);
|
log.info("[updateTaskStatus][任务({})失败,取消预扣]", taskId);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[updateTaskStatus][取消预扣失败,taskId={}]", taskId, e);
|
log.error("[updateTaskStatus][取消预扣失败,taskId={}]", taskId, e);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package cn.iocoder.yudao.module.tik.voice.service;
|
package cn.iocoder.yudao.module.tik.voice.service;
|
||||||
|
|
||||||
import cn.hutool.core.collection.CollUtil;
|
import cn.hutool.core.collection.CollUtil;
|
||||||
|
import cn.hutool.core.util.IdUtil;
|
||||||
import cn.hutool.core.util.ObjectUtil;
|
import cn.hutool.core.util.ObjectUtil;
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import cn.hutool.http.HttpUtil;
|
import cn.hutool.http.HttpUtil;
|
||||||
@@ -445,21 +446,21 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
|
|||||||
log.error("[synthesizeVoice][积分扣减失败: {}]", e.getMessage());
|
log.error("[synthesizeVoice][积分扣减失败: {}]", e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 不暴露OSS链接,直接返回Base64编码的音频数据
|
// 保存到 OSS 临时目录,返回预签名 URL(性能优化:避免 base64 编码)
|
||||||
String audioBase64 = Base64.getEncoder().encodeToString(ttsResult.getAudio());
|
String audioUrl = saveToTempOss(ttsResult.getAudio(), format);
|
||||||
log.info("[synthesizeVoice][合成成功,配音编号({}),voiceId({}),format({}),audioSize={}]",
|
log.info("[synthesizeVoice][合成成功,配音编号({}),voiceId({}),format({}),audioSize={}]",
|
||||||
voiceConfigId, finalVoiceId, format, ttsResult.getAudio().length);
|
voiceConfigId, finalVoiceId, format, ttsResult.getAudio().length);
|
||||||
|
|
||||||
AppTikVoiceTtsRespVO respVO = new AppTikVoiceTtsRespVO();
|
AppTikVoiceTtsRespVO respVO = new AppTikVoiceTtsRespVO();
|
||||||
respVO.setFileId(null); // 不返回fileId(避免暴露)
|
respVO.setFileId(null); // 不返回fileId(避免暴露)
|
||||||
respVO.setAudioBase64(audioBase64); // 返回Base64数据,前端可直接播放
|
respVO.setAudioUrl(audioUrl); // 返回预签名 URL,前端可直接播放
|
||||||
respVO.setFormat(format);
|
respVO.setFormat(format);
|
||||||
respVO.setSampleRate(ttsResult.getSampleRate());
|
respVO.setSampleRate(ttsResult.getSampleRate());
|
||||||
respVO.setRequestId(""); // 不返回Request ID(避免暴露技术细节)
|
respVO.setRequestId(""); // 不返回Request ID(避免暴露技术细节)
|
||||||
respVO.setVoiceId(finalVoiceId);
|
respVO.setVoiceId(finalVoiceId);
|
||||||
|
|
||||||
saveSynthCache(cacheKey, new SynthCacheEntry(
|
saveSynthCache(cacheKey, new SynthCacheEntry(
|
||||||
Base64.getEncoder().encodeToString(ttsResult.getAudio()),
|
audioUrl, // 缓存 URL 而不是 base64
|
||||||
format,
|
format,
|
||||||
ttsResult.getSampleRate(),
|
ttsResult.getSampleRate(),
|
||||||
ttsResult.getRequestId(),
|
ttsResult.getRequestId(),
|
||||||
@@ -579,43 +580,6 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
|
|||||||
StrUtil.blankToDefault(requestFormat, getDefaultFormat()));
|
StrUtil.blankToDefault(requestFormat, getDefaultFormat()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildFileName(String voiceId, String format) {
|
|
||||||
String safeVoice = StrUtil.blankToDefault(voiceId, "voice")
|
|
||||||
.replaceAll("[^a-zA-Z0-9_-]", "");
|
|
||||||
return safeVoice + "-" + System.currentTimeMillis() + "." + format;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveContentType(String format) {
|
|
||||||
if (format == null) {
|
|
||||||
return "audio/mpeg";
|
|
||||||
}
|
|
||||||
return switch (format.toLowerCase()) {
|
|
||||||
case "wav" -> "audio/wav";
|
|
||||||
case "flac" -> "audio/flac";
|
|
||||||
default -> "audio/mpeg";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private String determineSynthesisText(String transcriptionText, String inputText, boolean allowFallback) {
|
|
||||||
StringBuilder builder = new StringBuilder();
|
|
||||||
if (StrUtil.isNotBlank(transcriptionText)) {
|
|
||||||
builder.append(transcriptionText.trim());
|
|
||||||
}
|
|
||||||
if (StrUtil.isNotBlank(inputText)) {
|
|
||||||
if (builder.length() > 0) {
|
|
||||||
builder.append("\n");
|
|
||||||
}
|
|
||||||
builder.append(inputText.trim());
|
|
||||||
}
|
|
||||||
if (builder.length() > 0) {
|
|
||||||
return builder.toString();
|
|
||||||
}
|
|
||||||
if (allowFallback) {
|
|
||||||
return getPreviewText();
|
|
||||||
}
|
|
||||||
throw exception(VOICE_TTS_FAILED, "请提供需要合成的文本内容");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从URL中提取原始URL(去除查询参数和锚点)
|
* 从URL中提取原始URL(去除查询参数和锚点)
|
||||||
*/
|
*/
|
||||||
@@ -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,
|
private String buildCacheKey(String prefix,
|
||||||
String voiceId,
|
String voiceId,
|
||||||
String fileUrl,
|
String fileUrl,
|
||||||
@@ -714,13 +696,13 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private AppTikVoiceTtsRespVO buildSynthResponseFromCache(AppTikVoiceTtsReqVO reqVO, SynthCacheEntry cache) {
|
private AppTikVoiceTtsRespVO buildSynthResponseFromCache(AppTikVoiceTtsReqVO reqVO, SynthCacheEntry cache) {
|
||||||
// 直接使用缓存的Base64数据,不上传OSS
|
// 使用缓存的 URL(性能优化:避免重复上传)
|
||||||
String format = defaultFormat(cache.getFormat(), reqVO.getAudioFormat());
|
String format = defaultFormat(cache.getFormat(), reqVO.getAudioFormat());
|
||||||
String voiceId = StrUtil.blankToDefault(reqVO.getVoiceId(), cache.getVoiceId());
|
String voiceId = StrUtil.blankToDefault(reqVO.getVoiceId(), cache.getVoiceId());
|
||||||
|
|
||||||
AppTikVoiceTtsRespVO respVO = new AppTikVoiceTtsRespVO();
|
AppTikVoiceTtsRespVO respVO = new AppTikVoiceTtsRespVO();
|
||||||
respVO.setFileId(null); // 不返回fileId(避免暴露)
|
respVO.setFileId(null); // 不返回fileId(避免暴露)
|
||||||
respVO.setAudioBase64(cache.getAudioBase64()); // 返回Base64数据
|
respVO.setAudioUrl(cache.getAudioUrl()); // 返回预签名 URL
|
||||||
respVO.setFormat(format);
|
respVO.setFormat(format);
|
||||||
respVO.setSampleRate(cache.getSampleRate());
|
respVO.setSampleRate(cache.getSampleRate());
|
||||||
respVO.setRequestId(""); // 不返回Request ID(避免暴露技术细节)
|
respVO.setRequestId(""); // 不返回Request ID(避免暴露技术细节)
|
||||||
@@ -753,7 +735,7 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
|
|||||||
@lombok.NoArgsConstructor
|
@lombok.NoArgsConstructor
|
||||||
@lombok.AllArgsConstructor
|
@lombok.AllArgsConstructor
|
||||||
private static class SynthCacheEntry {
|
private static class SynthCacheEntry {
|
||||||
private String audioBase64;
|
private String audioUrl; // 改为存储 URL
|
||||||
private String format;
|
private String format;
|
||||||
private Integer sampleRate;
|
private Integer sampleRate;
|
||||||
private String requestId;
|
private String requestId;
|
||||||
|
|||||||
@@ -95,10 +95,14 @@ public class AppTikDigitalHumanCreateReqVO {
|
|||||||
@JsonProperty("pre_generated_audio")
|
@JsonProperty("pre_generated_audio")
|
||||||
private PreGeneratedAudioVO preGeneratedAudio;
|
private PreGeneratedAudioVO preGeneratedAudio;
|
||||||
|
|
||||||
|
@Schema(description = "预生成音频URL(与 preGeneratedAudio 二选一,优先使用)", example = "https://oss.example.com/temp/tts/xxx.mp3")
|
||||||
|
@JsonProperty("audio_url")
|
||||||
|
private String audioUrl;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Schema(description = "预生成音频信息")
|
@Schema(description = "预生成音频信息")
|
||||||
public static class PreGeneratedAudioVO {
|
public static class PreGeneratedAudioVO {
|
||||||
@Schema(description = "音频Base64数据", example = "data:audio/mp3;base64,...")
|
@Schema(description = "音频Base64数据(降级方案)", example = "data:audio/mp3;base64,...")
|
||||||
private String audioBase64;
|
private String audioBase64;
|
||||||
|
|
||||||
@Schema(description = "音频格式", example = "mp3")
|
@Schema(description = "音频格式", example = "mp3")
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ public class AppTikVoiceTtsRespVO {
|
|||||||
@Schema(description = "用户文件编号", example = "1024")
|
@Schema(description = "用户文件编号", example = "1024")
|
||||||
private Long fileId;
|
private Long fileId;
|
||||||
|
|
||||||
@Schema(description = "音频Base64数据(可直接播放,使用 data:audio/...;base64,... 格式)")
|
@Schema(description = "音频Base64数据(降级方案,优先使用 audioUrl)")
|
||||||
private String audioBase64;
|
private String audioBase64;
|
||||||
|
|
||||||
@Schema(description = "音频播放地址(预签名 URL,已废弃,不推荐使用)")
|
@Schema(description = "音频播放地址(预签名 URL,1小时过期)")
|
||||||
@Deprecated
|
|
||||||
private String audioUrl;
|
private String audioUrl;
|
||||||
|
|
||||||
@Schema(description = "音频格式", example = "mp3")
|
@Schema(description = "音频格式", example = "mp3")
|
||||||
|
|||||||
Reference in New Issue
Block a user