feat: 功能

This commit is contained in:
2026-03-05 23:21:00 +08:00
parent 9b132082d2
commit dff90abbb4
11 changed files with 87 additions and 104 deletions

View File

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

View File

@@ -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性能优化
}) })
} }
}) })

View File

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

View File

@@ -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()
}) })
} }

View File

@@ -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 // 用户配音IDtik_user_voice.id voiceConfigId?: string // 用户配音IDtik_user_voice.id
pre_generated_audio?: { audio_url?: string // 预生成音频 URL性能优化
audioBase64: string
format: string
}
sound_end_time?: number sound_end_time?: number
} }

View File

@@ -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('获取视频地址失败,请重试')
}
} }
// 下载视频 // 下载视频

View File

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

View File

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

View File

@@ -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 目录(预签名 URL1小时过期
*
* @param audioBytes 音频字节数组
* @param format 音频格式(如 mp3
* @return 预签名 URL
*/
private String saveToTempOss(byte[] audioBytes, String format) {
String fileName = "temp/tts/" + IdUtil.fastSimpleUUID() + "." + format;
String mimeType = "audio/" + format;
// 上传到 OSS返回文件路径
String filePath = fileApi.createFile(audioBytes, fileName, "temp/tts", mimeType);
// 返回预签名 URL1小时过期
return fileApi.presignGetUrl(filePath, 3600);
}
private String buildCacheKey(String prefix, 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;

View File

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

View File

@@ -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 = "音频播放地址(预签名 URL1小时过期")
@Deprecated
private String audioUrl; private String audioUrl;
@Schema(description = "音频格式", example = "mp3") @Schema(description = "音频格式", example = "mp3")