可令对口型
This commit is contained in:
@@ -103,7 +103,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
String fileUrl;
|
||||
String filePath;
|
||||
Long infraFileId;
|
||||
|
||||
|
||||
try {
|
||||
// 1. 处理文件名和类型
|
||||
String fileName = file.getOriginalFilename();
|
||||
@@ -142,8 +142,8 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
.setSize((int) file.getSize());
|
||||
fileMapper.insert(infraFile);
|
||||
infraFileId = infraFile.getId(); // MyBatis Plus 会自动填充自增ID
|
||||
|
||||
log.info("[uploadFile][文件上传成功,文件编号({}),路径({})]", infraFileId, filePath);
|
||||
|
||||
log.info("[uploadFile][文件上传成功,文件编号({})]", infraFileId);
|
||||
} catch (Exception e) {
|
||||
log.error("[uploadFile][上传OSS失败]", e);
|
||||
throw exception(FILE_NOT_EXISTS, "上传OSS失败:" + e.getMessage());
|
||||
@@ -154,7 +154,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
return saveFileRecord(userId, file, fileCategory, fileUrl, filePath, coverBase64, baseDirectory, infraFileId);
|
||||
} catch (Exception e) {
|
||||
// 数据库保存失败,删除已上传的OSS文件
|
||||
log.error("[uploadFile][保存数据库失败,准备删除OSS文件,URL({})]", fileUrl, e);
|
||||
log.error("[uploadFile][保存数据库失败]", e);
|
||||
deleteOssFile(infraFileId, filePath, fileUrl);
|
||||
throw e; // 重新抛出异常
|
||||
}
|
||||
@@ -232,12 +232,14 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
.setFilePath(filePath) // 保存完整的OSS路径(由FileService生成)
|
||||
.setCoverUrl(coverUrl) // 设置封面URL(如果有)
|
||||
.setCoverBase64(StrUtil.isNotBlank(coverBase64) ? coverBase64 : null); // 保存原始base64数据(如果有)
|
||||
|
||||
userFileMapper.insert(userFile);
|
||||
|
||||
// 10. 更新配额
|
||||
quotaService.increaseUsedStorage(userId, file.getSize());
|
||||
|
||||
log.info("[saveFileRecord][用户({})保存文件记录成功,文件编号({}),infra文件编号({})]", userId, userFile.getId(), infraFileId);
|
||||
log.info("[saveFileRecord][用户({})保存文件记录成功,文件编号({}),infra文件编号({})]",
|
||||
userId, userFile.getId(), infraFileId);
|
||||
// 返回 infra_file.id,保持与现有配音功能的兼容性
|
||||
return infraFileId;
|
||||
}
|
||||
@@ -412,32 +414,44 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getVideoPlayUrl(Long fileId) {
|
||||
public String getVideoPlayUrl(Long infraFileId) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 查询文件
|
||||
TikUserFileDO file = userFileMapper.selectById(fileId);
|
||||
if (file == null || !file.getUserId().equals(userId)) {
|
||||
throw exception(FILE_NOT_EXISTS);
|
||||
// 查询文件(根据 infraFileId 字段查询)
|
||||
TikUserFileDO file = userFileMapper.selectOne(new LambdaQueryWrapperX<TikUserFileDO>()
|
||||
.eq(TikUserFileDO::getFileId, infraFileId)
|
||||
.eq(TikUserFileDO::getUserId, userId));
|
||||
|
||||
if (file == null) {
|
||||
throw exception(FILE_NOT_EXISTS, "文件不存在");
|
||||
}
|
||||
|
||||
// 校验文件URL是否为空
|
||||
if (StrUtil.isBlank(file.getFileUrl())) {
|
||||
throw exception(FILE_NOT_EXISTS, "文件URL为空");
|
||||
}
|
||||
|
||||
// 校验是否为视频文件
|
||||
if (!StrUtil.containsIgnoreCase(file.getFileType(), "video")) {
|
||||
boolean isVideo = StrUtil.containsIgnoreCase(file.getFileType(), "video");
|
||||
if (!isVideo) {
|
||||
throw exception(FILE_CATEGORY_INVALID, "文件不是视频类型");
|
||||
}
|
||||
|
||||
// 生成预签名URL(1小时有效期)
|
||||
// 生成预签名URL(24小时有效期)
|
||||
return getCachedPresignUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAudioPlayUrl(Long fileId) {
|
||||
public String getAudioPlayUrl(Long infraFileId) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 查询文件
|
||||
TikUserFileDO file = userFileMapper.selectById(fileId);
|
||||
if (file == null || !file.getUserId().equals(userId)) {
|
||||
throw exception(FILE_NOT_EXISTS);
|
||||
// 查询文件(根据 infraFileId 字段查询)
|
||||
TikUserFileDO file = userFileMapper.selectOne(new LambdaQueryWrapperX<TikUserFileDO>()
|
||||
.eq(TikUserFileDO::getFileId, infraFileId)
|
||||
.eq(TikUserFileDO::getUserId, userId));
|
||||
|
||||
if (file == null) {
|
||||
throw exception(FILE_NOT_EXISTS, "文件不存在");
|
||||
}
|
||||
|
||||
// 校验是否为音频文件
|
||||
@@ -453,7 +467,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
public String getPreviewUrl(Long fileId, String type) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 查询文件
|
||||
// 查询文件(根据主键id查询)
|
||||
TikUserFileDO file = userFileMapper.selectById(fileId);
|
||||
if (file == null || !file.getUserId().equals(userId)) {
|
||||
throw exception(FILE_NOT_EXISTS);
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.client;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.module.tik.kling.dto.KlingIdentifyFaceRequest;
|
||||
import cn.iocoder.yudao.module.tik.kling.dto.KlingIdentifyFaceResponse;
|
||||
import cn.iocoder.yudao.module.tik.kling.dto.KlingLipSyncCreateRequest;
|
||||
import cn.iocoder.yudao.module.tik.kling.dto.KlingLipSyncCreateResponse;
|
||||
import cn.iocoder.yudao.module.tik.kling.dto.KlingLipSyncQueryResponse;
|
||||
import cn.iocoder.yudao.module.tik.voice.config.LatentsyncProperties;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception0;
|
||||
import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.LATENTSYNC_SUBMIT_FAILED;
|
||||
|
||||
/**
|
||||
* 302AI 可灵客户端
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class KlingClient {
|
||||
|
||||
private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
|
||||
|
||||
private final LatentsyncProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private volatile OkHttpClient httpClient;
|
||||
|
||||
/**
|
||||
* 人脸识别 - Identify-Face
|
||||
*/
|
||||
public KlingIdentifyFaceResponse identifyFace(KlingIdentifyFaceRequest request) {
|
||||
validateEnabled();
|
||||
validateRequest(request);
|
||||
|
||||
Map<String, Object> payload = buildPayload(request);
|
||||
try {
|
||||
String body = objectMapper.writeValueAsString(payload);
|
||||
String url = properties.getBaseUrl() + "/klingai/v1/videos/identify-face";
|
||||
|
||||
Request httpRequest = new Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Authorization", "Bearer " + properties.getApiKey())
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(RequestBody.create(body.getBytes(StandardCharsets.UTF_8), JSON))
|
||||
.build();
|
||||
|
||||
try {
|
||||
KlingIdentifyFaceResponse response = executeRequest(httpRequest, "identify-face", KlingIdentifyFaceResponse.class);
|
||||
// 验证sessionId
|
||||
if (StrUtil.isBlank(response.getData() == null ? null : response.getData().getSessionId())) {
|
||||
throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "可灵返回 sessionId 为空");
|
||||
}
|
||||
return response;
|
||||
} catch (ServiceException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
log.error("[Kling][identify-face exception]", ex);
|
||||
throw exception(LATENTSYNC_SUBMIT_FAILED);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.error("[Kling][build request exception]", ex);
|
||||
throw exception(LATENTSYNC_SUBMIT_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建口型同步任务 - Advanced-Lip-Sync
|
||||
*/
|
||||
public KlingLipSyncCreateResponse createLipSyncTask(KlingLipSyncCreateRequest request) {
|
||||
validateEnabled();
|
||||
validateLipSyncRequest(request);
|
||||
|
||||
try {
|
||||
String body = objectMapper.writeValueAsString(request);
|
||||
String url = properties.getBaseUrl() + "/klingai/v1/videos/advanced-lip-sync";
|
||||
|
||||
Request httpRequest = new Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Authorization", "Bearer " + properties.getApiKey())
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(RequestBody.create(body.getBytes(StandardCharsets.UTF_8), JSON))
|
||||
.build();
|
||||
|
||||
try {
|
||||
KlingLipSyncCreateResponse response = executeRequest(httpRequest, "create-lip-sync", KlingLipSyncCreateResponse.class);
|
||||
// 验证taskId
|
||||
if (StrUtil.isBlank(response.getData() == null ? null : response.getData().getTaskId())) {
|
||||
throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "可灵返回 taskId 为空");
|
||||
}
|
||||
return response;
|
||||
} catch (ServiceException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
log.error("[Kling][create-lip-sync exception]", ex);
|
||||
throw exception(LATENTSYNC_SUBMIT_FAILED);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.error("[Kling][build request exception]", ex);
|
||||
throw exception(LATENTSYNC_SUBMIT_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询口型同步任务 - Advanced-Lip-Sync
|
||||
*/
|
||||
public KlingLipSyncQueryResponse getLipSyncTask(String taskId) {
|
||||
validateEnabled();
|
||||
if (StrUtil.isBlank(taskId)) {
|
||||
throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "任务ID不能为空");
|
||||
}
|
||||
|
||||
try {
|
||||
String url = properties.getBaseUrl() + "/klingai/v1/videos/advanced-lip-sync/" + taskId;
|
||||
|
||||
Request httpRequest = new Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Authorization", "Bearer " + properties.getApiKey())
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.get()
|
||||
.build();
|
||||
|
||||
try {
|
||||
KlingLipSyncQueryResponse response = executeRequest(httpRequest, "get-lip-sync", KlingLipSyncQueryResponse.class);
|
||||
return response;
|
||||
} catch (ServiceException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
log.error("[Kling][get-lip-sync exception]", ex);
|
||||
throw exception(LATENTSYNC_SUBMIT_FAILED);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.error("[Kling][build request exception]", ex);
|
||||
throw exception(LATENTSYNC_SUBMIT_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateEnabled() {
|
||||
if (!properties.isEnabled()) {
|
||||
throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "未配置 Kling API Key");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRequest(KlingIdentifyFaceRequest request) {
|
||||
if (request == null) {
|
||||
throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "请求体不能为空");
|
||||
}
|
||||
if (StrUtil.isBlank(request.getVideoUrl())) {
|
||||
throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "视频URL不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateLipSyncRequest(KlingLipSyncCreateRequest request) {
|
||||
if (request == null) {
|
||||
throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "请求体不能为空");
|
||||
}
|
||||
if (StrUtil.isBlank(request.getSessionId())) {
|
||||
throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "sessionId不能为空");
|
||||
}
|
||||
if (request.getFaceChoose() == null || request.getFaceChoose().isEmpty()) {
|
||||
throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "face_choose不能为空");
|
||||
}
|
||||
// 验证每个face_choose项
|
||||
for (KlingLipSyncCreateRequest.FaceChoose faceChoose : request.getFaceChoose()) {
|
||||
if (StrUtil.isBlank(faceChoose.getFaceId())) {
|
||||
throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "face_id不能为空");
|
||||
}
|
||||
if (StrUtil.isBlank(faceChoose.getSoundFile())) {
|
||||
throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "sound_file不能为空");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> buildPayload(KlingIdentifyFaceRequest request) {
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("video_url", request.getVideoUrl());
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行HTTP请求的通用方法
|
||||
*/
|
||||
private <T> T executeRequest(Request httpRequest, String operation, Class<T> responseClass) {
|
||||
try (Response response = getHttpClient().newCall(httpRequest).execute()) {
|
||||
String responseBody = response.body() != null ? response.body().string() : "";
|
||||
if (!response.isSuccessful()) {
|
||||
log.error("[Kling][{} failed][status={}, body={}]", operation, response.code(), responseBody);
|
||||
throw buildException(responseBody);
|
||||
}
|
||||
log.info("[Kling][{} success][responseBody={}]", operation, responseBody);
|
||||
|
||||
return objectMapper.readValue(responseBody, responseClass);
|
||||
} catch (Exception ex) {
|
||||
log.error("[Kling][{} exception]", operation, ex);
|
||||
throw exception(LATENTSYNC_SUBMIT_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
private OkHttpClient getHttpClient() {
|
||||
if (httpClient == null) {
|
||||
synchronized (this) {
|
||||
if (httpClient == null) {
|
||||
Duration connect = defaultDuration(properties.getConnectTimeout(), 10);
|
||||
Duration read = defaultDuration(properties.getReadTimeout(), 60);
|
||||
httpClient = new OkHttpClient.Builder()
|
||||
.connectTimeout(connect.toMillis(), TimeUnit.MILLISECONDS)
|
||||
.readTimeout(read.toMillis(), TimeUnit.MILLISECONDS)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
return httpClient;
|
||||
}
|
||||
|
||||
private Duration defaultDuration(Duration duration, long seconds) {
|
||||
return duration == null ? Duration.ofSeconds(seconds) : duration;
|
||||
}
|
||||
|
||||
private ServiceException buildException(String body) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(body);
|
||||
// 尝试读取 message 字段(标准错误格式)
|
||||
String message = root.path("message").asText("");
|
||||
// 如果没有 message,尝试读取 detail 字段(302AI 的错误格式)
|
||||
if (StrUtil.isBlank(message)) {
|
||||
message = root.path("detail").asText("");
|
||||
}
|
||||
// 如果都没有,使用整个响应体
|
||||
if (StrUtil.isBlank(message)) {
|
||||
message = body;
|
||||
}
|
||||
return exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), message);
|
||||
} catch (Exception ignored) {
|
||||
return exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), body);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.controller;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.KlingIdentifyFaceReqVO;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.KlingIdentifyFaceRespVO;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.KlingLipSyncCreateReqVO;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.KlingLipSyncCreateRespVO;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.KlingLipSyncQueryRespVO;
|
||||
import cn.iocoder.yudao.module.tik.kling.service.KlingService;
|
||||
import cn.iocoder.yudao.module.tik.voice.service.DigitalHumanTaskService;
|
||||
import cn.iocoder.yudao.module.tik.voice.vo.AppTikDigitalHumanCreateReqVO;
|
||||
import cn.iocoder.yudao.module.tik.voice.vo.AppTikDigitalHumanPageReqVO;
|
||||
import cn.iocoder.yudao.module.tik.voice.vo.AppTikDigitalHumanRespVO;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
/**
|
||||
* 可灵控制器
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/tik/kling")
|
||||
@Tag(name = "可灵数字人", description = "302.ai可灵接口")
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
public class KlingController {
|
||||
|
||||
private final KlingService klingService;
|
||||
private final DigitalHumanTaskService digitalHumanTaskService;
|
||||
|
||||
@PostMapping("/identify-face")
|
||||
@Operation(summary = "人脸识别", description = "识别视频中的人脸,用于对口型服务")
|
||||
public CommonResult<KlingIdentifyFaceRespVO> identifyFace(@RequestBody @Valid KlingIdentifyFaceReqVO reqVO) {
|
||||
KlingIdentifyFaceRespVO respVO = klingService.identifyFace(reqVO);
|
||||
return CommonResult.success(respVO);
|
||||
}
|
||||
|
||||
@PostMapping("/lip-sync/create")
|
||||
@Operation(summary = "创建口型同步任务", description = "使用可灵高级对口型服务创建任务")
|
||||
public CommonResult<KlingLipSyncCreateRespVO> createLipSyncTask(@RequestBody @Valid KlingLipSyncCreateReqVO reqVO) {
|
||||
KlingLipSyncCreateRespVO respVO = klingService.createLipSyncTask(reqVO);
|
||||
return CommonResult.success(respVO);
|
||||
}
|
||||
|
||||
@GetMapping("/lip-sync/{taskId}")
|
||||
@Operation(summary = "查询口型同步任务", description = "查询可灵口型同步任务状态和结果")
|
||||
public CommonResult<KlingLipSyncQueryRespVO> getLipSyncTask(@PathVariable String taskId) {
|
||||
KlingLipSyncQueryRespVO respVO = klingService.getLipSyncTask(taskId);
|
||||
return CommonResult.success(respVO);
|
||||
}
|
||||
|
||||
@PostMapping("/task/create")
|
||||
@Operation(summary = "创建可灵任务", description = "创建数字人任务,使用可灵AI服务")
|
||||
public CommonResult<Long> createTask(@RequestBody @Valid AppTikDigitalHumanCreateReqVO reqVO) {
|
||||
// 设置 AI 供应商为可灵
|
||||
reqVO.setAiProvider("kling");
|
||||
Long taskId = digitalHumanTaskService.createTask(reqVO);
|
||||
return CommonResult.success(taskId);
|
||||
}
|
||||
|
||||
@GetMapping("/task/get")
|
||||
@Operation(summary = "获取任务详情", description = "获取可灵任务详情")
|
||||
public CommonResult<AppTikDigitalHumanRespVO> getTask(@RequestParam Long taskId) {
|
||||
AppTikDigitalHumanRespVO respVO = digitalHumanTaskService.getTask(taskId);
|
||||
return CommonResult.success(respVO);
|
||||
}
|
||||
|
||||
@GetMapping("/task/page")
|
||||
@Operation(summary = "分页查询任务列表", description = "分页查询可灵任务列表")
|
||||
public CommonResult<cn.iocoder.yudao.framework.common.pojo.PageResult<AppTikDigitalHumanRespVO>> getTaskPage(@Valid AppTikDigitalHumanPageReqVO pageReqVO) {
|
||||
cn.iocoder.yudao.framework.common.pojo.PageResult<AppTikDigitalHumanRespVO> result = digitalHumanTaskService.getTaskPage(pageReqVO);
|
||||
return CommonResult.success(result);
|
||||
}
|
||||
|
||||
@PostMapping("/task/cancel")
|
||||
@Operation(summary = "取消任务", description = "取消可灵任务")
|
||||
public CommonResult<Boolean> cancelTask(@RequestParam Long taskId) {
|
||||
digitalHumanTaskService.cancelTask(taskId);
|
||||
return CommonResult.success(true);
|
||||
}
|
||||
|
||||
@PostMapping("/task/retry")
|
||||
@Operation(summary = "重试任务", description = "重试可灵任务")
|
||||
public CommonResult<Boolean> retryTask(@RequestParam Long taskId) {
|
||||
digitalHumanTaskService.retryTask(taskId);
|
||||
return CommonResult.success(true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/task/delete")
|
||||
@Operation(summary = "删除任务", description = "删除可灵任务")
|
||||
public CommonResult<Boolean> deleteTask(@RequestParam Long taskId) {
|
||||
digitalHumanTaskService.deleteTask(taskId);
|
||||
return CommonResult.success(true);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 可灵人脸识别请求 DTO
|
||||
*/
|
||||
@Data
|
||||
public class KlingIdentifyFaceRequest {
|
||||
|
||||
/**
|
||||
* 视频URL
|
||||
*/
|
||||
@NotNull(message = "视频URL不能为空")
|
||||
@Size(min = 1, max = 1024, message = "视频URL长度不能超过 1024 个字符")
|
||||
@JsonProperty("video_url")
|
||||
private String videoUrl;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.dto;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.kling.dto.response.KlingIdentifyFaceData;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 可灵人脸识别响应 DTO
|
||||
*/
|
||||
@Data
|
||||
public class KlingIdentifyFaceResponse {
|
||||
|
||||
/**
|
||||
* 错误码
|
||||
*/
|
||||
private Integer code;
|
||||
|
||||
/**
|
||||
* 错误信息
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 请求ID
|
||||
*/
|
||||
private String requestId;
|
||||
|
||||
/**
|
||||
* 数据
|
||||
*/
|
||||
private KlingIdentifyFaceData data;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 可灵口型同步创建任务请求
|
||||
*/
|
||||
@Data
|
||||
public class KlingLipSyncCreateRequest {
|
||||
|
||||
/**
|
||||
* 面部选择和音频配置列表
|
||||
*/
|
||||
@JsonProperty("face_choose")
|
||||
private List<FaceChoose> faceChoose;
|
||||
|
||||
/**
|
||||
* 会话ID(从人脸识别返回)
|
||||
*/
|
||||
@JsonProperty("session_id")
|
||||
private String sessionId;
|
||||
|
||||
/**
|
||||
* 自定义任务ID(可选)
|
||||
*/
|
||||
@JsonProperty("external_task_id")
|
||||
private String externalTaskId;
|
||||
|
||||
/**
|
||||
* 面部选择和音频配置
|
||||
*/
|
||||
@Data
|
||||
public static class FaceChoose {
|
||||
|
||||
/**
|
||||
* 人脸ID(从人脸识别返回)
|
||||
*/
|
||||
@JsonProperty("face_id")
|
||||
private String faceId;
|
||||
|
||||
/**
|
||||
* 音频文件URL(支持Base64或URL)
|
||||
*/
|
||||
@JsonProperty("sound_file")
|
||||
private String soundFile;
|
||||
|
||||
/**
|
||||
* 音频裁剪起点时间(ms)
|
||||
*/
|
||||
@JsonProperty("sound_start_time")
|
||||
private Integer soundStartTime;
|
||||
|
||||
/**
|
||||
* 音频裁剪终点时间(ms)
|
||||
*/
|
||||
@JsonProperty("sound_end_time")
|
||||
private Integer soundEndTime;
|
||||
|
||||
/**
|
||||
* 音频插入时间(ms)
|
||||
*/
|
||||
@JsonProperty("sound_insert_time")
|
||||
private Integer soundInsertTime;
|
||||
|
||||
/**
|
||||
* 音频音量大小 [0, 2]
|
||||
*/
|
||||
@JsonProperty("sound_volume")
|
||||
private Double soundVolume;
|
||||
|
||||
/**
|
||||
* 原始视频音量大小 [0, 2]
|
||||
*/
|
||||
@JsonProperty("original_audio_volume")
|
||||
private Double originalAudioVolume;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 可灵口型同步创建任务响应
|
||||
*/
|
||||
@Data
|
||||
public class KlingLipSyncCreateResponse {
|
||||
|
||||
/**
|
||||
* 错误码
|
||||
*/
|
||||
private Integer code;
|
||||
|
||||
/**
|
||||
* 错误信息
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 请求ID
|
||||
*/
|
||||
@JsonProperty("request_id")
|
||||
private String requestId;
|
||||
|
||||
/**
|
||||
* 数据
|
||||
*/
|
||||
private KlingLipSyncCreateData data;
|
||||
|
||||
/**
|
||||
* 响应数据
|
||||
*/
|
||||
@Data
|
||||
public static class KlingLipSyncCreateData {
|
||||
|
||||
/**
|
||||
* 任务ID
|
||||
*/
|
||||
@JsonProperty("task_id")
|
||||
private String taskId;
|
||||
|
||||
/**
|
||||
* 任务信息
|
||||
*/
|
||||
@JsonProperty("task_info")
|
||||
private TaskInfo taskInfo;
|
||||
|
||||
/**
|
||||
* 任务状态
|
||||
*/
|
||||
@JsonProperty("task_status")
|
||||
private String taskStatus;
|
||||
|
||||
/**
|
||||
* 创建时间(ms)
|
||||
*/
|
||||
@JsonProperty("created_at")
|
||||
private Long createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间(ms)
|
||||
*/
|
||||
@JsonProperty("updated_at")
|
||||
private Long updatedAt;
|
||||
|
||||
/**
|
||||
* 任务信息
|
||||
*/
|
||||
@Data
|
||||
public static class TaskInfo {
|
||||
|
||||
/**
|
||||
* 客户自定义任务ID
|
||||
*/
|
||||
@JsonProperty("external_task_id")
|
||||
private String externalTaskId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.dto;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.response.KlingLipSyncQueryDataVO;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 可灵口型同步查询任务响应
|
||||
*/
|
||||
@Data
|
||||
public class KlingLipSyncQueryResponse {
|
||||
|
||||
/**
|
||||
* 错误码
|
||||
*/
|
||||
private Integer code;
|
||||
|
||||
/**
|
||||
* 错误信息
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 请求ID
|
||||
*/
|
||||
@JsonProperty("request_id")
|
||||
private String requestId;
|
||||
|
||||
/**
|
||||
* 数据
|
||||
*/
|
||||
private KlingLipSyncQueryDataVO data;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.dto.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 可灵人脸识别数据 DTO
|
||||
*/
|
||||
@Data
|
||||
public class KlingIdentifyFaceData {
|
||||
|
||||
/**
|
||||
* 会话ID
|
||||
*/
|
||||
@JsonProperty("session_id")
|
||||
private String sessionId;
|
||||
|
||||
/**
|
||||
* 人脸数据列表
|
||||
*/
|
||||
@JsonProperty("face_data")
|
||||
private List<KlingIdentifyFaceItem> faceData;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.dto.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 可灵人脸数据项 DTO
|
||||
*/
|
||||
@Data
|
||||
public class KlingIdentifyFaceItem {
|
||||
|
||||
/**
|
||||
* 人脸ID
|
||||
*/
|
||||
@JsonProperty("face_id")
|
||||
private String faceId;
|
||||
|
||||
/**
|
||||
* 人脸图片URL
|
||||
*/
|
||||
@JsonProperty("face_image")
|
||||
private String faceImage;
|
||||
|
||||
/**
|
||||
* 起始时间(毫秒)
|
||||
*/
|
||||
@JsonProperty("start_time")
|
||||
private Integer startTime;
|
||||
|
||||
/**
|
||||
* 结束时间(毫秒)
|
||||
*/
|
||||
@JsonProperty("end_time")
|
||||
private Integer endTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.service;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.KlingIdentifyFaceReqVO;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.KlingIdentifyFaceRespVO;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.KlingLipSyncCreateReqVO;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.KlingLipSyncCreateRespVO;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.KlingLipSyncQueryRespVO;
|
||||
|
||||
/**
|
||||
* 可灵服务接口
|
||||
*/
|
||||
public interface KlingService {
|
||||
|
||||
/**
|
||||
* 人脸识别
|
||||
*/
|
||||
KlingIdentifyFaceRespVO identifyFace(KlingIdentifyFaceReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 创建口型同步任务
|
||||
*/
|
||||
KlingLipSyncCreateRespVO createLipSyncTask(KlingLipSyncCreateReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 查询口型同步任务
|
||||
*/
|
||||
KlingLipSyncQueryRespVO getLipSyncTask(String taskId);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.service;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.module.tik.kling.client.KlingClient;
|
||||
import cn.iocoder.yudao.module.tik.kling.dto.KlingIdentifyFaceRequest;
|
||||
import cn.iocoder.yudao.module.tik.kling.dto.KlingIdentifyFaceResponse;
|
||||
import cn.iocoder.yudao.module.tik.kling.dto.KlingLipSyncCreateRequest;
|
||||
import cn.iocoder.yudao.module.tik.kling.dto.KlingLipSyncCreateResponse;
|
||||
import cn.iocoder.yudao.module.tik.kling.dto.KlingLipSyncQueryResponse;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.KlingIdentifyFaceReqVO;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.KlingIdentifyFaceRespVO;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.KlingLipSyncCreateReqVO;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.KlingLipSyncCreateRespVO;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.KlingLipSyncQueryRespVO;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.response.KlingIdentifyFaceDataVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 可灵服务实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KlingServiceImpl implements KlingService {
|
||||
|
||||
private final KlingClient klingClient;
|
||||
|
||||
@Override
|
||||
public KlingIdentifyFaceRespVO identifyFace(KlingIdentifyFaceReqVO reqVO) {
|
||||
// 转换请求对象
|
||||
KlingIdentifyFaceRequest request = BeanUtils.toBean(reqVO, KlingIdentifyFaceRequest.class);
|
||||
|
||||
// 调用302.ai API
|
||||
KlingIdentifyFaceResponse response = klingClient.identifyFace(request);
|
||||
|
||||
// 构建响应VO
|
||||
KlingIdentifyFaceRespVO respVO = new KlingIdentifyFaceRespVO();
|
||||
|
||||
// 转换data字段
|
||||
if (response.getData() != null) {
|
||||
KlingIdentifyFaceDataVO dataVO = BeanUtils.toBean(response.getData(), KlingIdentifyFaceDataVO.class);
|
||||
respVO.setData(dataVO);
|
||||
// 直接设置sessionId(扁平化结构)
|
||||
respVO.setSessionId(dataVO.getSessionId());
|
||||
}
|
||||
|
||||
log.info("[identify-face][识别完成][sessionId={}, faceCount={}]",
|
||||
response.getData() != null ? response.getData().getSessionId() : "null",
|
||||
response.getData() != null && response.getData().getFaceData() != null
|
||||
? response.getData().getFaceData().size() : 0);
|
||||
|
||||
return respVO;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KlingLipSyncCreateRespVO createLipSyncTask(KlingLipSyncCreateReqVO reqVO) {
|
||||
// 转换请求对象
|
||||
KlingLipSyncCreateRequest request = BeanUtils.toBean(reqVO, KlingLipSyncCreateRequest.class);
|
||||
|
||||
// 调用302.ai API
|
||||
KlingLipSyncCreateResponse response = klingClient.createLipSyncTask(request);
|
||||
|
||||
// 构建响应VO
|
||||
KlingLipSyncCreateRespVO respVO = BeanUtils.toBean(response, KlingLipSyncCreateRespVO.class);
|
||||
|
||||
log.info("[create-lip-sync][创建任务完成][taskId={}, status={}]",
|
||||
response.getData() != null ? response.getData().getTaskId() : "null",
|
||||
response.getData() != null ? response.getData().getTaskStatus() : "null");
|
||||
|
||||
return respVO;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KlingLipSyncQueryRespVO getLipSyncTask(String taskId) {
|
||||
// 调用302.ai API
|
||||
KlingLipSyncQueryResponse response = klingClient.getLipSyncTask(taskId);
|
||||
|
||||
// 构建响应VO
|
||||
KlingLipSyncQueryRespVO respVO = BeanUtils.toBean(response, KlingLipSyncQueryRespVO.class);
|
||||
|
||||
log.info("[get-lip-sync][查询任务完成][taskId={}, status={}]",
|
||||
response.getData() != null ? response.getData().getTaskId() : "null",
|
||||
response.getData() != null ? response.getData().getTaskStatus() : "null");
|
||||
|
||||
return respVO;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 可灵人脸识别请求 VO
|
||||
*/
|
||||
@Data
|
||||
@Schema(name = "可灵人脸识别请求")
|
||||
public class KlingIdentifyFaceReqVO {
|
||||
|
||||
@Schema(description = "视频URL", required = true, example = "https://example.com/video.mp4")
|
||||
@NotNull(message = "视频URL不能为空")
|
||||
@Size(min = 1, max = 1024, message = "视频URL长度不能超过 1024 个字符")
|
||||
@JsonProperty("video_url")
|
||||
private String videoUrl;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.vo;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.response.KlingIdentifyFaceDataVO;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 可灵人脸识别响应 VO
|
||||
*/
|
||||
@Data
|
||||
@Schema(name = "可灵人脸识别响应")
|
||||
public class KlingIdentifyFaceRespVO {
|
||||
|
||||
/**
|
||||
* 会话ID
|
||||
*/
|
||||
@Schema(description = "会话ID", required = true)
|
||||
private String sessionId;
|
||||
|
||||
/**
|
||||
* 人脸数据
|
||||
*/
|
||||
@Schema(description = "人脸数据", required = true)
|
||||
private KlingIdentifyFaceDataVO data;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 可灵口型同步创建任务请求VO
|
||||
*/
|
||||
@Data
|
||||
public class KlingLipSyncCreateReqVO {
|
||||
|
||||
/**
|
||||
* 面部选择和音频配置列表
|
||||
*/
|
||||
@JsonProperty("face_choose")
|
||||
private List<FaceChooseVO> faceChoose;
|
||||
|
||||
/**
|
||||
* 会话ID(从人脸识别返回)
|
||||
*/
|
||||
@JsonProperty("session_id")
|
||||
private String sessionId;
|
||||
|
||||
/**
|
||||
* 自定义任务ID(可选)
|
||||
*/
|
||||
@JsonProperty("external_task_id")
|
||||
private String externalTaskId;
|
||||
|
||||
/**
|
||||
* 面部选择和音频配置
|
||||
*/
|
||||
@Data
|
||||
public static class FaceChooseVO {
|
||||
|
||||
/**
|
||||
* 人脸ID(从人脸识别返回)
|
||||
*/
|
||||
@JsonProperty("face_id")
|
||||
private String faceId;
|
||||
|
||||
/**
|
||||
* 音频文件URL(支持Base64或URL)
|
||||
*/
|
||||
@JsonProperty("sound_file")
|
||||
private String soundFile;
|
||||
|
||||
/**
|
||||
* 音频裁剪起点时间(ms)
|
||||
*/
|
||||
@JsonProperty("sound_start_time")
|
||||
private Integer soundStartTime;
|
||||
|
||||
/**
|
||||
* 音频裁剪终点时间(ms)
|
||||
*/
|
||||
@JsonProperty("sound_end_time")
|
||||
private Integer soundEndTime;
|
||||
|
||||
/**
|
||||
* 音频插入时间(ms)
|
||||
*/
|
||||
@JsonProperty("sound_insert_time")
|
||||
private Integer soundInsertTime;
|
||||
|
||||
/**
|
||||
* 音频音量大小 [0, 2]
|
||||
*/
|
||||
@JsonProperty("sound_volume")
|
||||
private Double soundVolume;
|
||||
|
||||
/**
|
||||
* 原始视频音量大小 [0, 2]
|
||||
*/
|
||||
@JsonProperty("original_audio_volume")
|
||||
private Double originalAudioVolume;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 可灵口型同步创建任务响应VO
|
||||
*/
|
||||
@Data
|
||||
public class KlingLipSyncCreateRespVO {
|
||||
|
||||
/**
|
||||
* 错误码
|
||||
*/
|
||||
private Integer code;
|
||||
|
||||
/**
|
||||
* 错误信息
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 请求ID
|
||||
*/
|
||||
@JsonProperty("request_id")
|
||||
private String requestId;
|
||||
|
||||
/**
|
||||
* 数据
|
||||
*/
|
||||
private KlingLipSyncCreateDataVO data;
|
||||
|
||||
/**
|
||||
* 响应数据
|
||||
*/
|
||||
@Data
|
||||
public static class KlingLipSyncCreateDataVO {
|
||||
|
||||
/**
|
||||
* 任务ID
|
||||
*/
|
||||
@JsonProperty("task_id")
|
||||
private String taskId;
|
||||
|
||||
/**
|
||||
* 任务信息
|
||||
*/
|
||||
@JsonProperty("task_info")
|
||||
private TaskInfo taskInfo;
|
||||
|
||||
/**
|
||||
* 任务状态
|
||||
*/
|
||||
@JsonProperty("task_status")
|
||||
private String taskStatus;
|
||||
|
||||
/**
|
||||
* 创建时间(ms)
|
||||
*/
|
||||
@JsonProperty("created_at")
|
||||
private Long createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间(ms)
|
||||
*/
|
||||
@JsonProperty("updated_at")
|
||||
private Long updatedAt;
|
||||
|
||||
/**
|
||||
* 任务信息
|
||||
*/
|
||||
@Data
|
||||
public static class TaskInfo {
|
||||
|
||||
/**
|
||||
* 客户自定义任务ID
|
||||
*/
|
||||
@JsonProperty("external_task_id")
|
||||
private String externalTaskId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.vo;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.response.KlingLipSyncQueryDataVO;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 可灵口型同步查询任务响应VO
|
||||
*/
|
||||
@Data
|
||||
public class KlingLipSyncQueryRespVO {
|
||||
|
||||
/**
|
||||
* 错误码
|
||||
*/
|
||||
private Integer code;
|
||||
|
||||
/**
|
||||
* 错误信息
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 请求ID
|
||||
*/
|
||||
@JsonProperty("request_id")
|
||||
private String requestId;
|
||||
|
||||
/**
|
||||
* 数据
|
||||
*/
|
||||
private KlingLipSyncQueryDataVO data;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.vo.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 可灵人脸识别数据 VO
|
||||
*/
|
||||
@Data
|
||||
@Schema(name = "可灵人脸识别数据")
|
||||
public class KlingIdentifyFaceDataVO {
|
||||
|
||||
/**
|
||||
* 会话ID
|
||||
*/
|
||||
@JsonProperty("session_id")
|
||||
private String sessionId;
|
||||
|
||||
/**
|
||||
* 人脸数据列表
|
||||
*/
|
||||
@JsonProperty("face_data")
|
||||
private List<KlingIdentifyFaceItemVO> faceData;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.vo.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 可灵人脸数据项 VO
|
||||
*/
|
||||
@Data
|
||||
@Schema(name = "可灵人脸数据项")
|
||||
public class KlingIdentifyFaceItemVO {
|
||||
|
||||
/**
|
||||
* 人脸ID
|
||||
*/
|
||||
@Schema(description = "人脸ID", required = true)
|
||||
@JsonProperty("face_id")
|
||||
private String faceId;
|
||||
|
||||
/**
|
||||
* 人脸图片URL
|
||||
*/
|
||||
@Schema(description = "人脸图片URL", required = true)
|
||||
@JsonProperty("face_image")
|
||||
private String faceImage;
|
||||
|
||||
/**
|
||||
* 起始时间(毫秒)
|
||||
*/
|
||||
@Schema(description = "起始时间(毫秒)", required = true)
|
||||
@JsonProperty("start_time")
|
||||
private Integer startTime;
|
||||
|
||||
/**
|
||||
* 结束时间(毫秒)
|
||||
*/
|
||||
@Schema(description = "结束时间(毫秒)", required = true)
|
||||
@JsonProperty("end_time")
|
||||
private Integer endTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.vo.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 可灵口型同步原视频信息 VO
|
||||
*/
|
||||
@Data
|
||||
public class KlingLipSyncParentVideoVO {
|
||||
|
||||
/**
|
||||
* 原视频ID
|
||||
*/
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* 原视频URL
|
||||
*/
|
||||
private String url;
|
||||
|
||||
/**
|
||||
* 原视频时长(s)
|
||||
*/
|
||||
private String duration;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.vo.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 可灵口型同步查询任务数据 VO
|
||||
*/
|
||||
@Data
|
||||
public class KlingLipSyncQueryDataVO {
|
||||
|
||||
/**
|
||||
* 任务ID
|
||||
*/
|
||||
@JsonProperty("task_id")
|
||||
private String taskId;
|
||||
|
||||
/**
|
||||
* 任务状态
|
||||
*/
|
||||
@JsonProperty("task_status")
|
||||
private String taskStatus;
|
||||
|
||||
/**
|
||||
* 任务状态信息(失败时展示失败原因)
|
||||
*/
|
||||
@JsonProperty("task_status_msg")
|
||||
private String taskStatusMsg;
|
||||
|
||||
/**
|
||||
* 任务信息
|
||||
*/
|
||||
@JsonProperty("task_info")
|
||||
private KlingLipSyncTaskInfoVO taskInfo;
|
||||
|
||||
/**
|
||||
* 任务结果
|
||||
*/
|
||||
@JsonProperty("task_result")
|
||||
private KlingLipSyncTaskResultVO taskResult;
|
||||
|
||||
/**
|
||||
* 创建时间(ms)
|
||||
*/
|
||||
@JsonProperty("created_at")
|
||||
private Long createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间(ms)
|
||||
*/
|
||||
@JsonProperty("updated_at")
|
||||
private Long updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.vo.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 可灵口型同步任务信息 VO
|
||||
*/
|
||||
@Data
|
||||
public class KlingLipSyncTaskInfoVO {
|
||||
|
||||
/**
|
||||
* 原视频信息
|
||||
*/
|
||||
@JsonProperty("parent_video")
|
||||
private KlingLipSyncParentVideoVO parentVideo;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.vo.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 可灵口型同步任务结果 VO
|
||||
*/
|
||||
@Data
|
||||
public class KlingLipSyncTaskResultVO {
|
||||
|
||||
/**
|
||||
* 生成的视频列表
|
||||
*/
|
||||
private List<KlingLipSyncVideoVO> videos;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.iocoder.yudao.module.tik.kling.vo.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 可灵口型同步视频信息 VO
|
||||
*/
|
||||
@Data
|
||||
public class KlingLipSyncVideoVO {
|
||||
|
||||
/**
|
||||
* 视频ID
|
||||
*/
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* 视频URL
|
||||
*/
|
||||
private String url;
|
||||
|
||||
/**
|
||||
* 视频时长(s)
|
||||
*/
|
||||
private String duration;
|
||||
}
|
||||
@@ -135,4 +135,18 @@ public class TikDigitalHumanTaskDO extends TenantBaseDO {
|
||||
*/
|
||||
private LocalDateTime finishTime;
|
||||
|
||||
// ========== 可灵特有字段 ==========
|
||||
/**
|
||||
* 可灵人脸识别会话ID(从identify-face接口获取)
|
||||
*/
|
||||
private String klingSessionId;
|
||||
/**
|
||||
* 可灵选中的人脸ID(从identify-face返回的face_data中选择)
|
||||
*/
|
||||
private String klingFaceId;
|
||||
/**
|
||||
* 可灵口型同步任务ID(从advanced-lip-sync接口获取)
|
||||
*/
|
||||
private String klingTaskId;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package cn.iocoder.yudao.module.tik.voice.job;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.voice.service.LatentsyncPollingService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 数字人任务状态同步定时任务
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DigitalHumanTaskStatusSyncJob {
|
||||
|
||||
private final LatentsyncPollingService latentsyncPollingService;
|
||||
|
||||
/**
|
||||
* 每10秒检查一次Latentsync任务状态
|
||||
*/
|
||||
@Scheduled(fixedDelay = 10000)
|
||||
public void syncTaskStatus() {
|
||||
log.debug("开始同步数字人任务状态");
|
||||
try {
|
||||
latentsyncPollingService.pollLatentsyncTasks();
|
||||
} catch (Exception e) {
|
||||
log.error("同步数字人任务状态失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每天凌晨2点清理过期任务
|
||||
*/
|
||||
@Scheduled(cron = "0 0 2 * * ?")
|
||||
public void cleanupExpiredTasks() {
|
||||
log.info("开始清理过期轮询任务");
|
||||
try {
|
||||
latentsyncPollingService.cleanupExpiredTasks();
|
||||
} catch (Exception e) {
|
||||
log.error("清理过期轮询任务失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil;
|
||||
import cn.iocoder.yudao.module.infra.api.file.FileApi;
|
||||
import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
|
||||
import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper;
|
||||
import cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserFileDO;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserFileMapper;
|
||||
import cn.iocoder.yudao.module.tik.file.service.TikOssInitService;
|
||||
@@ -23,8 +24,9 @@ import cn.iocoder.yudao.module.tik.voice.enums.DigitalHumanTaskStatusEnum;
|
||||
import cn.iocoder.yudao.module.tik.voice.enums.DigitalHumanTaskStepEnum;
|
||||
import cn.iocoder.yudao.module.tik.voice.vo.*;
|
||||
import cn.iocoder.yudao.module.tik.voice.vo.AppTikLatentsyncResultRespVO;
|
||||
import cn.iocoder.yudao.module.tik.voice.service.LatentsyncService;
|
||||
import cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants;
|
||||
import cn.iocoder.yudao.module.tik.voice.strategy.LipSyncStrategy;
|
||||
import cn.iocoder.yudao.module.tik.voice.strategy.LipSyncStrategyFactory;
|
||||
import cn.iocoder.yudao.module.tik.kling.service.KlingService;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -56,9 +58,10 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
|
||||
private final FileMapper fileMapper;
|
||||
private final FileApi fileApi;
|
||||
private final TikUserVoiceService userVoiceService;
|
||||
private final LatentsyncService latentsyncService;
|
||||
private final TikOssInitService ossInitService;
|
||||
private final LatentsyncPollingService latentsyncPollingService;
|
||||
private final LipSyncStrategyFactory lipSyncStrategyFactory;
|
||||
private final KlingService klingService;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
/**
|
||||
@@ -371,6 +374,8 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
|
||||
.status("PENDING")
|
||||
.progress(0)
|
||||
.currentStep("prepare_files")
|
||||
.klingSessionId(reqVO.getKlingSessionId())
|
||||
.klingFaceId(reqVO.getKlingFaceId())
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -525,70 +530,29 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 口型同步
|
||||
* 口型同步 - 使用策略模式
|
||||
*/
|
||||
private String syncLip(TikDigitalHumanTaskDO task, String audioUrl) throws Exception {
|
||||
log.info("[syncLip][任务({})开始口型同步,使用AI供应商: {}]", task.getId(), task.getAiProvider());
|
||||
|
||||
String syncedVideoUrl;
|
||||
String aiProvider = task.getAiProvider();
|
||||
// 使用策略模式根据任务特性选择合适的策略
|
||||
LipSyncStrategy strategy = lipSyncStrategyFactory.getStrategyForTask(task);
|
||||
|
||||
// 根据AI供应商路由到不同的服务
|
||||
if ("302ai".equalsIgnoreCase(aiProvider)) {
|
||||
// 302AI Latentsync 服务
|
||||
syncedVideoUrl = syncWithLatentsync(task, audioUrl);
|
||||
} else if ("aliyun".equalsIgnoreCase(aiProvider)) {
|
||||
// TODO: 阿里云语音驱动视频服务
|
||||
log.warn("[syncLip][任务({})暂不支持阿里云AI供应商,使用原视频URL]", task.getId());
|
||||
syncedVideoUrl = task.getVideoUrl();
|
||||
} else if ("openai".equalsIgnoreCase(aiProvider)) {
|
||||
// TODO: OpenAI 语音驱动视频服务
|
||||
log.warn("[syncLip][任务({})暂不支持OpenAI AI供应商,使用原视频URL]", task.getId());
|
||||
syncedVideoUrl = task.getVideoUrl();
|
||||
} else if ("minimax".equalsIgnoreCase(aiProvider)) {
|
||||
// TODO: MiniMax 语音驱动视频服务
|
||||
log.warn("[syncLip][任务({})暂不支持MiniMax AI供应商,使用原视频URL]", task.getId());
|
||||
syncedVideoUrl = task.getVideoUrl();
|
||||
} else {
|
||||
log.error("[syncLip][任务({})不支持的AI供应商: {}]", task.getId(), aiProvider);
|
||||
throw new Exception("不支持的AI供应商: " + aiProvider);
|
||||
if (strategy == null) {
|
||||
log.error("[syncLip][任务({})找不到合适的策略,AI供应商: {}]", task.getId(), task.getAiProvider());
|
||||
throw new Exception("找不到合适的口型同步策略,AI供应商: " + task.getAiProvider());
|
||||
}
|
||||
|
||||
log.info("[syncLip][任务({})口型同步完成]", task.getId());
|
||||
log.info("[syncLip][任务({})使用策略: {}][描述: {}]",
|
||||
task.getId(), strategy.getStrategyName(), strategy.getDescription());
|
||||
|
||||
// 执行口型同步
|
||||
String syncedVideoUrl = strategy.syncLip(task, audioUrl);
|
||||
|
||||
log.info("[syncLip][任务({})口型同步完成][策略: {}]", task.getId(), strategy.getStrategyName());
|
||||
return syncedVideoUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用302AI Latentsync进行口型同步 - 异步处理
|
||||
* 提交任务后立即返回,由轮询服务异步检测状态
|
||||
*/
|
||||
private String syncWithLatentsync(TikDigitalHumanTaskDO task, String audioUrl) throws Exception {
|
||||
// 构建Latentsync请求VO
|
||||
AppTikLatentsyncSubmitReqVO reqVO = new AppTikLatentsyncSubmitReqVO();
|
||||
reqVO.setAudioUrl(audioUrl);
|
||||
reqVO.setVideoUrl(task.getVideoUrl());
|
||||
reqVO.setGuidanceScale(task.getGuidanceScale());
|
||||
reqVO.setSeed(task.getSeed());
|
||||
|
||||
// 调用Latentsync服务提交任务
|
||||
AppTikLatentsyncSubmitRespVO response = latentsyncService.submitTask(reqVO);
|
||||
String requestId = response.getRequestId();
|
||||
|
||||
log.info("[syncWithLatentsync][任务({})提交成功,requestId={}]", task.getId(), requestId);
|
||||
|
||||
// 将任务加入轮询队列(异步处理)
|
||||
latentsyncPollingService.addTaskToPollingQueue(task.getId(), requestId);
|
||||
|
||||
// 存储requestId与taskId的映射关系(用于轮询服务查找)
|
||||
String requestIdKey = "latentsync:polling:task_" + task.getId();
|
||||
stringRedisTemplate.opsForValue().set(requestIdKey, requestId, Duration.ofHours(1));
|
||||
|
||||
// 立即返回原视频URL,不等待Latentsync完成
|
||||
// 轮询服务会异步更新任务状态
|
||||
log.info("[syncWithLatentsync][任务({})已加入轮询队列,返回原视频URL]", task.getId());
|
||||
return task.getVideoUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务状态
|
||||
*/
|
||||
|
||||
@@ -16,12 +16,14 @@ import cn.iocoder.yudao.module.tik.file.service.TikOssInitService;
|
||||
import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikDigitalHumanTaskDO;
|
||||
import cn.iocoder.yudao.module.tik.voice.dal.mysql.TikDigitalHumanTaskMapper;
|
||||
import cn.iocoder.yudao.module.tik.voice.vo.AppTikLatentsyncResultRespVO;
|
||||
import cn.iocoder.yudao.module.tik.kling.service.KlingService;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.KlingLipSyncQueryRespVO;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.response.KlingLipSyncVideoVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.redisson.api.RLock;
|
||||
import org.redisson.api.RedissonClient;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -51,6 +53,7 @@ public class LatentsyncPollingService {
|
||||
private final TikUserFileMapper userFileMapper;
|
||||
private final FileMapper fileMapper;
|
||||
private final FileConfigService fileConfigService;
|
||||
private final KlingService klingService;
|
||||
|
||||
/**
|
||||
* Redis键前缀
|
||||
@@ -70,8 +73,8 @@ public class LatentsyncPollingService {
|
||||
/**
|
||||
* 定时轮询Latentsync任务状态 - 每10秒执行一次
|
||||
* 使用分布式锁防止并发执行
|
||||
* 注意:此方法现在由 DigitalHumanTaskStatusSyncJob 定时调用,不在服务内部使用 @Scheduled 注解
|
||||
*/
|
||||
@Scheduled(fixedDelay = 10000)
|
||||
public void pollLatentsyncTasks() {
|
||||
RLock lock = redissonClient.getLock(LOCK_KEY);
|
||||
// 尝试加锁(最大等待时间1秒,锁持有时间5秒)
|
||||
@@ -91,23 +94,25 @@ public class LatentsyncPollingService {
|
||||
*/
|
||||
private void executePollingTasks() {
|
||||
try {
|
||||
// 获取所有待轮询的任务ID
|
||||
// 轮询Latentsync任务
|
||||
List<String> taskIds = getPendingPollingTasks();
|
||||
if (taskIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!taskIds.isEmpty()) {
|
||||
log.debug("[pollLatentsyncTasks][开始轮询Latentsync任务][任务数量={}]", taskIds.size());
|
||||
|
||||
log.debug("[pollLatentsyncTasks][开始轮询][任务数量={}]", taskIds.size());
|
||||
|
||||
// 逐个处理任务
|
||||
for (String taskIdStr : taskIds) {
|
||||
try {
|
||||
Long taskId = Long.parseLong(taskIdStr);
|
||||
pollSingleTask(taskId);
|
||||
} catch (Exception e) {
|
||||
log.error("[pollLatentsyncTasks][轮询任务失败][taskId={}]", taskIdStr, e);
|
||||
// 逐个处理Latentsync任务
|
||||
for (String taskIdStr : taskIds) {
|
||||
try {
|
||||
Long taskId = Long.parseLong(taskIdStr);
|
||||
pollSingleTask(taskId);
|
||||
} catch (Exception e) {
|
||||
log.error("[pollLatentsyncTasks][轮询Latentsync任务失败][taskId={}]", taskIdStr, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 轮询可灵任务
|
||||
pollKlingTasks();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[pollLatentsyncTasks][轮询任务异常]", e);
|
||||
}
|
||||
@@ -348,8 +353,8 @@ public class LatentsyncPollingService {
|
||||
|
||||
/**
|
||||
* 清理过期任务(每天凌晨2点执行)
|
||||
* 注意:此方法现在由外部调度器调用,不在服务内部使用 @Scheduled 注解
|
||||
*/
|
||||
@Scheduled(cron = "0 0 2 * * ?")
|
||||
public void cleanupExpiredTasks() {
|
||||
try {
|
||||
log.info("[cleanupExpiredTasks][开始清理过期轮询任务]");
|
||||
@@ -537,4 +542,123 @@ public class LatentsyncPollingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询可灵任务状态
|
||||
*/
|
||||
private void pollKlingTasks() {
|
||||
try {
|
||||
// 查询所有有待轮询的可灵任务(状态为PROCESSING且有klingTaskId)
|
||||
List<TikDigitalHumanTaskDO> klingTasks = taskMapper.selectList(
|
||||
new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<TikDigitalHumanTaskDO>()
|
||||
.eq(TikDigitalHumanTaskDO::getStatus, "PROCESSING")
|
||||
.eq(TikDigitalHumanTaskDO::getAiProvider, "kling")
|
||||
.isNotNull(TikDigitalHumanTaskDO::getKlingTaskId)
|
||||
.ne(TikDigitalHumanTaskDO::getKlingTaskId, "")
|
||||
);
|
||||
|
||||
if (klingTasks.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("[pollKlingTasks][开始轮询可灵任务][任务数量={}]", klingTasks.size());
|
||||
|
||||
// 逐个处理可灵任务
|
||||
for (TikDigitalHumanTaskDO task : klingTasks) {
|
||||
try {
|
||||
pollKlingSingleTask(task);
|
||||
} catch (Exception e) {
|
||||
log.error("[pollKlingTasks][轮询可灵任务失败][taskId={}]", task.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[pollKlingTasks][轮询可灵任务异常]", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询单个可灵任务
|
||||
*/
|
||||
private void pollKlingSingleTask(TikDigitalHumanTaskDO task) {
|
||||
String klingTaskId = task.getKlingTaskId();
|
||||
if (StrUtil.isBlank(klingTaskId)) {
|
||||
log.warn("[pollKlingSingleTask][任务({})缺少klingTaskId]", task.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 查询可灵任务状态
|
||||
KlingLipSyncQueryRespVO response = klingService.getLipSyncTask(klingTaskId);
|
||||
String taskStatus = response.getData().getTaskStatus();
|
||||
String taskStatusMsg = response.getData().getTaskStatusMsg();
|
||||
|
||||
log.debug("[pollKlingSingleTask][任务({})状态更新][klingTaskId={}, status={}]",
|
||||
task.getId(), klingTaskId, taskStatus);
|
||||
|
||||
// 根据状态更新任务
|
||||
if ("succeed".equalsIgnoreCase(taskStatus)) {
|
||||
// 任务成功完成
|
||||
List<KlingLipSyncVideoVO> videos = response.getData().getTaskResult().getVideos();
|
||||
if (videos != null && !videos.isEmpty()) {
|
||||
String videoUrl = videos.get(0).getUrl();
|
||||
updateTaskStatus(task.getId(), "SUCCESS", "finishing", 100, "任务完成", videoUrl);
|
||||
log.info("[pollKlingSingleTask][任务({})完成][videoUrl={}]", task.getId(), videoUrl);
|
||||
} else {
|
||||
log.warn("[pollKlingSingleTask][任务({})成功但无视频结果]", task.getId());
|
||||
}
|
||||
|
||||
} else if ("failed".equalsIgnoreCase(taskStatus)) {
|
||||
// 任务失败
|
||||
String errorMsg = "可灵任务执行失败: " + (StrUtil.isNotBlank(taskStatusMsg) ? taskStatusMsg : "未知错误");
|
||||
updateTaskStatus(task.getId(), "FAILED", task.getCurrentStep(), task.getProgress(), errorMsg, null, errorMsg);
|
||||
log.error("[pollKlingSingleTask][任务({})失败][error={}]", task.getId(), errorMsg);
|
||||
|
||||
} else if ("submitted".equalsIgnoreCase(taskStatus) || "processing".equalsIgnoreCase(taskStatus)) {
|
||||
// 任务还在处理中,更新进度
|
||||
updateTaskStatus(task.getId(), "PROCESSING", "sync_lip", 70, "口型同步处理中", null);
|
||||
log.debug("[pollKlingSingleTask][任务({})处理中]", task.getId());
|
||||
|
||||
} else {
|
||||
log.warn("[pollKlingSingleTask][任务({})未知状态][status={}]", task.getId(), taskStatus);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[pollKlingSingleTask][任务({})查询失败]", task.getId(), e);
|
||||
// 不更新任务状态,避免误判
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务状态
|
||||
*/
|
||||
private void updateTaskStatus(Long taskId, String status, String currentStep, Integer progress,
|
||||
String message, String resultVideoUrl) {
|
||||
updateTaskStatus(taskId, status, currentStep, progress, message, resultVideoUrl, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务状态(带错误详情)
|
||||
*/
|
||||
private void updateTaskStatus(Long taskId, String status, String currentStep, Integer progress,
|
||||
String message, String resultVideoUrl, String errorDetail) {
|
||||
TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO();
|
||||
updateObj.setId(taskId);
|
||||
updateObj.setStatus(status);
|
||||
updateObj.setCurrentStep(currentStep);
|
||||
updateObj.setProgress(progress);
|
||||
|
||||
if ("SUCCESS".equals(status)) {
|
||||
updateObj.setResultVideoUrl(resultVideoUrl);
|
||||
updateObj.setFinishTime(LocalDateTime.now());
|
||||
} else if ("PROCESSING".equals(status)) {
|
||||
updateObj.setStartTime(LocalDateTime.now());
|
||||
} else if ("FAILED".equals(status)) {
|
||||
updateObj.setErrorMessage(message);
|
||||
updateObj.setErrorDetail(errorDetail);
|
||||
updateObj.setFinishTime(LocalDateTime.now());
|
||||
}
|
||||
|
||||
taskMapper.updateById(updateObj);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package cn.iocoder.yudao.module.tik.voice.strategy;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikDigitalHumanTaskDO;
|
||||
|
||||
/**
|
||||
* 口型同步策略接口
|
||||
*
|
||||
* 定义不同的AI供应商如何进行口型同步
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
public interface LipSyncStrategy {
|
||||
|
||||
/**
|
||||
* 执行口型同步
|
||||
*
|
||||
* @param task 数字人任务
|
||||
* @param audioUrl 音频文件URL
|
||||
* @return 同步后的视频URL(可能与原视频相同,因为是异步处理)
|
||||
* @throws Exception 同步过程中的异常
|
||||
*/
|
||||
String syncLip(TikDigitalHumanTaskDO task, String audioUrl) throws Exception;
|
||||
|
||||
/**
|
||||
* 获取策略名称
|
||||
*
|
||||
* @return 策略名称,用于识别不同的AI供应商
|
||||
*/
|
||||
String getStrategyName();
|
||||
|
||||
/**
|
||||
* 检查任务参数是否满足此策略的要求
|
||||
*
|
||||
* @param task 数字人任务
|
||||
* @return true 如果任务参数满足策略要求,false 需要回退到其他策略
|
||||
*/
|
||||
boolean supports(TikDigitalHumanTaskDO task);
|
||||
|
||||
/**
|
||||
* 获取策略优先级(数值越大优先级越高)
|
||||
*
|
||||
* @return 策略优先级
|
||||
*/
|
||||
int getPriority();
|
||||
|
||||
/**
|
||||
* 获取策略描述
|
||||
*
|
||||
* @return 策略描述,用于日志和文档
|
||||
*/
|
||||
String getDescription();
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package cn.iocoder.yudao.module.tik.voice.strategy;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikDigitalHumanTaskDO;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 口型同步策略工厂
|
||||
*
|
||||
* 负责创建和管理不同的口型同步策略
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Component
|
||||
public class LipSyncStrategyFactory {
|
||||
|
||||
/**
|
||||
* 策略注册表
|
||||
* key: 策略名称
|
||||
* value: 策略实例
|
||||
*/
|
||||
private final Map<String, LipSyncStrategy> strategies = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 注册策略
|
||||
*
|
||||
* @param strategy 策略实例
|
||||
*/
|
||||
public void registerStrategy(LipSyncStrategy strategy) {
|
||||
strategies.put(strategy.getStrategyName(), strategy);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取策略
|
||||
*
|
||||
* @param strategyName 策略名称
|
||||
* @return 策略实例
|
||||
*/
|
||||
public LipSyncStrategy getStrategy(String strategyName) {
|
||||
return strategies.get(strategyName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取适配的任务策略
|
||||
*
|
||||
* 根据任务参数和策略优先级,自动选择最适合的策略
|
||||
*
|
||||
* @param task 数字人任务
|
||||
* @return 适配的策略实例
|
||||
* @throws IllegalArgumentException 如果没有找到合适的策略
|
||||
*/
|
||||
public LipSyncStrategy getStrategyForTask(TikDigitalHumanTaskDO task) {
|
||||
// 收集所有支持此任务的策略
|
||||
List<LipSyncStrategy> supportedStrategies = new ArrayList<>();
|
||||
|
||||
for (LipSyncStrategy strategy : strategies.values()) {
|
||||
if (strategy.supports(task)) {
|
||||
supportedStrategies.add(strategy);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有支持的策略,抛出异常
|
||||
if (supportedStrategies.isEmpty()) {
|
||||
throw new IllegalArgumentException("没有找到适合任务(" + task.getId() + ")的策略,AI供应商: " + task.getAiProvider());
|
||||
}
|
||||
|
||||
// 按优先级排序(数值越大优先级越高)
|
||||
supportedStrategies.sort((s1, s2) -> Integer.compare(s2.getPriority(), s1.getPriority()));
|
||||
|
||||
// 返回优先级最高的策略
|
||||
LipSyncStrategy selectedStrategy = supportedStrategies.get(0);
|
||||
|
||||
return selectedStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册的策略
|
||||
*
|
||||
* @return 策略列表(只读)
|
||||
*/
|
||||
public List<LipSyncStrategy> getAllStrategies() {
|
||||
return Collections.unmodifiableList(new ArrayList<>(strategies.values()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否支持指定策略
|
||||
*
|
||||
* @param strategyName 策略名称
|
||||
* @return true 如果支持,false 否则
|
||||
*/
|
||||
public boolean supportsStrategy(String strategyName) {
|
||||
return strategies.containsKey(strategyName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取策略描述信息
|
||||
*
|
||||
* @return 所有策略的描述信息
|
||||
*/
|
||||
public String getStrategiesDescription() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("已注册的策略:\n");
|
||||
|
||||
for (LipSyncStrategy strategy : strategies.values()) {
|
||||
sb.append(String.format("- %s (优先级: %d): %s\n",
|
||||
strategy.getStrategyName(),
|
||||
strategy.getPriority(),
|
||||
strategy.getDescription()));
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package cn.iocoder.yudao.module.tik.voice.strategy.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.tik.kling.service.KlingService;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.KlingLipSyncCreateReqVO;
|
||||
import cn.iocoder.yudao.module.tik.kling.vo.KlingLipSyncCreateRespVO;
|
||||
import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikDigitalHumanTaskDO;
|
||||
import cn.iocoder.yudao.module.tik.voice.dal.mysql.TikDigitalHumanTaskMapper;
|
||||
import cn.iocoder.yudao.module.tik.voice.strategy.LipSyncStrategy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
|
||||
/**
|
||||
* 可灵口型同步策略
|
||||
*
|
||||
* 使用可灵 advanced-lip-sync 接口进行口型同步
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class KlingLipSyncStrategy implements LipSyncStrategy {
|
||||
|
||||
private final KlingService klingService;
|
||||
private final TikDigitalHumanTaskMapper taskMapper;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
/**
|
||||
* Redis键前缀
|
||||
*/
|
||||
private static final String REDIS_POLLING_PREFIX = "kling:polling:";
|
||||
|
||||
/**
|
||||
* 缓存过期时间
|
||||
*/
|
||||
private static final Duration CACHE_EXPIRE_TIME = Duration.ofHours(1);
|
||||
|
||||
@Override
|
||||
public String syncLip(TikDigitalHumanTaskDO task, String audioUrl) throws Exception {
|
||||
log.info("[KlingStrategy][任务({})开始使用可灵advanced-lip-sync][klingSessionId={}, klingFaceId={}]",
|
||||
task.getId(), task.getKlingSessionId(), task.getKlingFaceId());
|
||||
|
||||
// 构建可灵口型同步请求VO
|
||||
KlingLipSyncCreateReqVO reqVO = buildLipSyncRequest(task, audioUrl);
|
||||
|
||||
// 调用可灵服务创建任务
|
||||
KlingLipSyncCreateRespVO response = klingService.createLipSyncTask(reqVO);
|
||||
String klingTaskId = response.getData().getTaskId();
|
||||
|
||||
log.info("[KlingStrategy][任务({})提交成功][klingTaskId={}, status={}]",
|
||||
task.getId(), klingTaskId, response.getData().getTaskStatus());
|
||||
|
||||
// 保存klingTaskId到任务记录
|
||||
saveKlingTaskId(task.getId(), klingTaskId);
|
||||
|
||||
// 将任务加入轮询队列(异步处理)
|
||||
addToPollingQueue(task.getId(), klingTaskId);
|
||||
|
||||
// 返回原视频URL,任务完成后会更新到数据库
|
||||
log.info("[KlingStrategy][任务({})已加入轮询队列,返回原视频URL]", task.getId());
|
||||
return task.getVideoUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getStrategyName() {
|
||||
return "kling";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(TikDigitalHumanTaskDO task) {
|
||||
// 支持条件:
|
||||
// 1. AI供应商为 kling
|
||||
// 2. 有 klingSessionId
|
||||
// 3. 有 klingFaceId
|
||||
return "kling".equalsIgnoreCase(task.getAiProvider())
|
||||
&& StrUtil.isNotBlank(task.getKlingSessionId())
|
||||
&& StrUtil.isNotBlank(task.getKlingFaceId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPriority() {
|
||||
// 高优先级,因为这是可灵的专用接口
|
||||
return 100;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "可灵高级对口型服务,使用advanced-lip-sync接口进行口型同步";
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建可灵口型同步请求
|
||||
*/
|
||||
private KlingLipSyncCreateReqVO buildLipSyncRequest(TikDigitalHumanTaskDO task, String audioUrl) {
|
||||
KlingLipSyncCreateReqVO reqVO = new KlingLipSyncCreateReqVO();
|
||||
reqVO.setSessionId(task.getKlingSessionId());
|
||||
|
||||
// 初始化face_choose数组
|
||||
if (reqVO.getFaceChoose() == null) {
|
||||
reqVO.setFaceChoose(new ArrayList<>());
|
||||
}
|
||||
|
||||
// 构建face_choose数组
|
||||
KlingLipSyncCreateReqVO.FaceChooseVO faceChoose = new KlingLipSyncCreateReqVO.FaceChooseVO();
|
||||
faceChoose.setFaceId(task.getKlingFaceId());
|
||||
faceChoose.setSoundFile(audioUrl);
|
||||
faceChoose.setSoundStartTime(0);
|
||||
faceChoose.setSoundEndTime(0); // 0表示不裁剪
|
||||
faceChoose.setSoundInsertTime(0);
|
||||
faceChoose.setSoundVolume(1.0);
|
||||
faceChoose.setOriginalAudioVolume(1.0);
|
||||
|
||||
reqVO.getFaceChoose().add(faceChoose);
|
||||
|
||||
return reqVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存klingTaskId到任务记录
|
||||
*/
|
||||
private void saveKlingTaskId(Long taskId, String klingTaskId) {
|
||||
TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO();
|
||||
updateObj.setId(taskId);
|
||||
updateObj.setKlingTaskId(klingTaskId);
|
||||
taskMapper.updateById(updateObj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加到轮询队列
|
||||
*/
|
||||
private void addToPollingQueue(Long taskId, String klingTaskId) {
|
||||
String requestIdKey = REDIS_POLLING_PREFIX + "task_" + taskId;
|
||||
stringRedisTemplate.opsForValue().set(requestIdKey, klingTaskId, CACHE_EXPIRE_TIME);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package cn.iocoder.yudao.module.tik.voice.strategy.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.tik.voice.service.LatentsyncService;
|
||||
import cn.iocoder.yudao.module.tik.voice.service.LatentsyncPollingService;
|
||||
import cn.iocoder.yudao.module.tik.voice.vo.AppTikLatentsyncSubmitReqVO;
|
||||
import cn.iocoder.yudao.module.tik.voice.vo.AppTikLatentsyncSubmitRespVO;
|
||||
import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikDigitalHumanTaskDO;
|
||||
import cn.iocoder.yudao.module.tik.voice.strategy.LipSyncStrategy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Latentsync口型同步策略
|
||||
*
|
||||
* 使用302.ai Latentsync接口进行口型同步
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class LatentsyncLipSyncStrategy implements LipSyncStrategy {
|
||||
|
||||
private final LatentsyncService latentsyncService;
|
||||
private final LatentsyncPollingService latentsyncPollingService;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
/**
|
||||
* Redis键前缀
|
||||
*/
|
||||
private static final String REDIS_POLLING_PREFIX = "latentsync:polling:";
|
||||
|
||||
/**
|
||||
* 缓存过期时间
|
||||
*/
|
||||
private static final Duration CACHE_EXPIRE_TIME = Duration.ofHours(1);
|
||||
|
||||
@Override
|
||||
public String syncLip(TikDigitalHumanTaskDO task, String audioUrl) throws Exception {
|
||||
log.info("[LatentsyncStrategy][任务({})开始使用Latentsync接口][aiProvider={}]",
|
||||
task.getId(), task.getAiProvider());
|
||||
|
||||
// 构建Latentsync请求VO
|
||||
AppTikLatentsyncSubmitReqVO reqVO = buildLatentsyncRequest(task, audioUrl);
|
||||
|
||||
// 调用Latentsync服务提交任务
|
||||
AppTikLatentsyncSubmitRespVO response = latentsyncService.submitTask(reqVO);
|
||||
String requestId = response.getRequestId();
|
||||
|
||||
log.info("[LatentsyncStrategy][任务({})提交成功][requestId={}]", task.getId(), requestId);
|
||||
|
||||
// 将任务加入轮询队列(异步处理)
|
||||
latentsyncPollingService.addTaskToPollingQueue(task.getId(), requestId);
|
||||
|
||||
// 存储requestId与taskId的映射关系(用于轮询服务查找)
|
||||
saveRequestIdMapping(task.getId(), requestId);
|
||||
|
||||
// 返回原视频URL,任务完成后会更新到数据库
|
||||
log.info("[LatentsyncStrategy][任务({})已加入轮询队列,返回原视频URL]", task.getId());
|
||||
return task.getVideoUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getStrategyName() {
|
||||
return "latentsync";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(TikDigitalHumanTaskDO task) {
|
||||
// 支持条件:
|
||||
// 1. AI供应商为 302ai,或者
|
||||
// 2. AI供应商为 kling 但缺少可灵特有参数(回退条件)
|
||||
if ("302ai".equalsIgnoreCase(task.getAiProvider())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ("kling".equalsIgnoreCase(task.getAiProvider())) {
|
||||
// 如果是可灵供应商,但缺少可灵特有参数,则使用Latentsync作为回退
|
||||
return StrUtil.isBlank(task.getKlingSessionId())
|
||||
|| StrUtil.isBlank(task.getKlingFaceId());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPriority() {
|
||||
// 中等优先级(低优先级也可以,因为KlingStrategy会优先处理可灵任务)
|
||||
return 50;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "302.ai Latentsync接口,通用的口型同步服务,支持多种AI供应商";
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建Latentsync请求
|
||||
*/
|
||||
private AppTikLatentsyncSubmitReqVO buildLatentsyncRequest(TikDigitalHumanTaskDO task, String audioUrl) {
|
||||
AppTikLatentsyncSubmitReqVO reqVO = new AppTikLatentsyncSubmitReqVO();
|
||||
reqVO.setAudioUrl(audioUrl);
|
||||
reqVO.setVideoUrl(task.getVideoUrl());
|
||||
reqVO.setGuidanceScale(task.getGuidanceScale());
|
||||
reqVO.setSeed(task.getSeed());
|
||||
return reqVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存requestId映射
|
||||
*/
|
||||
private void saveRequestIdMapping(Long taskId, String requestId) {
|
||||
String requestIdKey = REDIS_POLLING_PREFIX + "task_" + taskId;
|
||||
stringRedisTemplate.opsForValue().set(requestIdKey, requestId, CACHE_EXPIRE_TIME);
|
||||
}
|
||||
}
|
||||
@@ -68,4 +68,11 @@ public class AppTikDigitalHumanCreateReqVO {
|
||||
@Schema(description = "指令(用于控制音色风格)", example = "请用温柔专业的语调朗读")
|
||||
private String instruction;
|
||||
|
||||
// ========== 可灵特有字段 ==========
|
||||
@Schema(description = "可灵人脸识别会话ID(可选)", example = "session_xxx")
|
||||
private String klingSessionId;
|
||||
|
||||
@Schema(description = "可灵选中的人脸ID(可选)", example = "0")
|
||||
private String klingFaceId;
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user