feat: 功能
This commit is contained in:
@@ -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'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -264,12 +264,12 @@ const initPlayer = (url) => {
|
|||||||
|
|
||||||
player.on('canplay', () => {
|
player.on('canplay', () => {
|
||||||
isPlayerInitializing.value = false
|
isPlayerInitializing.value = false
|
||||||
// 发送音频时长和 base64 数据给父组件
|
// 发送音频时长和 URL 给父组件
|
||||||
const durationMs = Math.floor(player.audio.duration * 1000)
|
const durationMs = Math.floor(player.audio.duration * 1000)
|
||||||
if (durationMs > 0) {
|
if (durationMs > 0) {
|
||||||
emit('audioGenerated', {
|
emit('audioGenerated', {
|
||||||
durationMs,
|
durationMs,
|
||||||
audioBase64: currentAudioBase64.value
|
audioUrl: audioUrl.value // 使用 URL(性能优化)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -326,13 +326,13 @@ function clearVideo() {
|
|||||||
store.resetProcess()
|
store.resetProcess()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAudioGenerated(data: { durationMs: number; audioBase64: string }) {
|
function handleAudioGenerated(data: { durationMs: number; audioUrl: string }) {
|
||||||
if (store.timeline && data.durationMs > 0) {
|
if (store.timeline && data.durationMs > 0) {
|
||||||
store.timeline.audioDurationMs = data.durationMs
|
store.timeline.audioDurationMs = data.durationMs
|
||||||
}
|
}
|
||||||
if (data.audioBase64) {
|
if (data.audioUrl) {
|
||||||
store.audioData = {
|
store.audioData = {
|
||||||
audioBase64: data.audioBase64,
|
audioUrl: data.audioUrl,
|
||||||
format: 'mp3',
|
format: 'mp3',
|
||||||
durationMs: data.durationMs
|
durationMs: data.durationMs
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -373,14 +373,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,
|
||||||
}
|
}
|
||||||
@@ -447,10 +453,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,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -571,32 +575,24 @@ 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)
|
|
||||||
let resolved = false
|
let resolved = false
|
||||||
let lastDuration = 0
|
let lastDuration = 0
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
cleanup()
|
cleanup()
|
||||||
reject(new Error('音频解析超时'))
|
reject(new Error('音频加载超时'))
|
||||||
}
|
}
|
||||||
}, 15000)
|
}, 15000)
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
URL.revokeObjectURL(objectUrl)
|
audio.pause()
|
||||||
|
audio.src = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const tryResolve = (duration: number, source: string) => {
|
const tryResolve = (duration: number, source: string) => {
|
||||||
@@ -621,11 +617,11 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
|||||||
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()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,41 +167,16 @@ 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 handlePreview = (record) => {
|
||||||
if (!record.id) {
|
if (!record.resultVideoUrl) {
|
||||||
message.warning('任务信息不完整')
|
message.warning('该任务暂无视频结果,请稍后再试')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
window.open(record.resultVideoUrl, '_blank')
|
||||||
// 显示加载提示
|
|
||||||
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('获取视频地址失败,请重试')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载视频
|
// 下载视频
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
@@ -631,6 +632,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 +733,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 +772,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