feat: 功能优化
This commit is contained in:
@@ -43,8 +43,10 @@ public class AppTikUserFileController {
|
||||
@Parameter(description = "文件分类(video/generate/audio/mix/voice)", required = true)
|
||||
@RequestParam("fileCategory") String fileCategory,
|
||||
@Parameter(description = "视频封面 base64(可选,data URI 格式)")
|
||||
@RequestParam(value = "coverBase64", required = false) String coverBase64) {
|
||||
return success(userFileService.uploadFile(file, fileCategory, coverBase64));
|
||||
@RequestParam(value = "coverBase64", required = false) String coverBase64,
|
||||
@Parameter(description = "视频时长(秒)")
|
||||
@RequestParam(value = "duration", required = false) Integer duration) {
|
||||
return success(userFileService.uploadFile(file, fileCategory, coverBase64, duration));
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
|
||||
@@ -79,5 +79,9 @@ public class TikUserFileDO extends TenantBaseDO {
|
||||
* 文件描述
|
||||
*/
|
||||
private String description;
|
||||
/**
|
||||
* 视频时长(秒)
|
||||
*/
|
||||
private Integer duration;
|
||||
|
||||
}
|
||||
|
||||
@@ -20,9 +20,10 @@ public interface TikUserFileService {
|
||||
* @param file 文件
|
||||
* @param fileCategory 文件分类(video/generate/audio/mix/voice)
|
||||
* @param coverBase64 视频封面 base64(可选,data URI 格式)
|
||||
* @param duration 视频时长(秒,可选)
|
||||
* @return 文件编号
|
||||
*/
|
||||
Long uploadFile(MultipartFile file, String fileCategory, String coverBase64);
|
||||
Long uploadFile(MultipartFile file, String fileCategory, String coverBase64, Integer duration);
|
||||
|
||||
/**
|
||||
* 分页查询文件列表
|
||||
|
||||
@@ -73,7 +73,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
private FileConfigService fileConfigService;
|
||||
|
||||
@Override
|
||||
public Long uploadFile(MultipartFile file, String fileCategory, String coverBase64) {
|
||||
public Long uploadFile(MultipartFile file, String fileCategory, String coverBase64, Integer duration) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
|
||||
@@ -151,7 +151,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
|
||||
// ========== 第三阶段:保存数据库(在事务中,如果失败则删除OSS文件) ==========
|
||||
try {
|
||||
return saveFileRecord(userId, file, fileCategory, fileUrl, filePath, coverBase64, baseDirectory, infraFileId);
|
||||
return saveFileRecord(userId, file, fileCategory, fileUrl, filePath, coverBase64, baseDirectory, infraFileId, duration);
|
||||
} catch (Exception e) {
|
||||
// 数据库保存失败,删除已上传的OSS文件
|
||||
log.error("[uploadFile][保存数据库失败]", e);
|
||||
@@ -165,7 +165,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long saveFileRecord(Long userId, MultipartFile file, String fileCategory,
|
||||
String fileUrl, String filePath, String coverBase64, String baseDirectory, Long infraFileId) {
|
||||
String fileUrl, String filePath, String coverBase64, String baseDirectory, Long infraFileId, Integer duration) {
|
||||
// 7. 验证 infraFileId 不为空(必须在保存记录之前检查)
|
||||
if (infraFileId == null) {
|
||||
log.error("[saveFileRecord][infra_file.id 为空,无法保存文件记录,用户({}),URL({})]", userId, fileUrl);
|
||||
@@ -231,7 +231,8 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
.setFileUrl(fileUrl)
|
||||
.setFilePath(filePath) // 保存完整的OSS路径(由FileService生成)
|
||||
.setCoverUrl(coverUrl) // 设置封面URL(如果有)
|
||||
.setCoverBase64(StrUtil.isNotBlank(coverBase64) ? coverBase64 : null); // 保存原始base64数据(如果有)
|
||||
.setCoverBase64(StrUtil.isNotBlank(coverBase64) ? coverBase64 : null) // 保存原始base64数据(如果有)
|
||||
.setDuration(duration); // 设置视频时长(如果有)
|
||||
|
||||
userFileMapper.insert(userFile);
|
||||
|
||||
|
||||
@@ -56,6 +56,9 @@ public class AppTikUserFileRespVO {
|
||||
@Schema(description = "文件描述")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "视频时长(秒)")
|
||||
private Integer duration;
|
||||
|
||||
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@@ -24,5 +24,8 @@ public class AppTikUserFileUploadReqVO {
|
||||
@Schema(description = "文件描述", example = "测试视频")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "视频时长(秒)", example = "60")
|
||||
private Integer duration;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.mix.vo.MixTaskSaveReqVO;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
// 成功视频
|
||||
@@ -205,4 +207,193 @@ public class BatchProduceAlignment {
|
||||
return jobIdWithUrl.split(" : ")[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算裁剪参数
|
||||
*
|
||||
* @param sourceWidth 源素材宽度
|
||||
* @param sourceHeight 源素材高度
|
||||
* @param cropMode 裁剪模式:center(居中裁剪)、smart(智能裁剪)、fill(填充模式)
|
||||
* @return 裁剪参数Map,包含X、Y、Width、Height
|
||||
*/
|
||||
private Map<String, Integer> calculateCropParams(int sourceWidth, int sourceHeight, String cropMode) {
|
||||
Map<String, Integer> cropParams = new HashMap<>();
|
||||
double targetRatio = 9.0 / 16.0; // 9:16竖屏比例
|
||||
|
||||
if ("fill".equals(cropMode)) {
|
||||
// 填充模式:不裁剪,保持原尺寸
|
||||
cropParams.put("X", 0);
|
||||
cropParams.put("Y", 0);
|
||||
cropParams.put("Width", sourceWidth);
|
||||
cropParams.put("Height", sourceHeight);
|
||||
} else if ("smart".equals(cropMode)) {
|
||||
// 智能裁剪功能暂未开放,自动降级为居中裁剪
|
||||
log.info("[裁剪模式] smart模式暂未开放,自动降级为center模式");
|
||||
double cropHeight = sourceHeight;
|
||||
double cropWidth = cropHeight * targetRatio;
|
||||
int cropX = (int) Math.round((sourceWidth - cropWidth) / 2);
|
||||
int cropY = 0;
|
||||
|
||||
cropParams.put("X", cropX);
|
||||
cropParams.put("Y", cropY);
|
||||
cropParams.put("Width", (int) Math.round(cropWidth));
|
||||
cropParams.put("Height", (int) Math.round(cropHeight));
|
||||
} else {
|
||||
// center模式:居中裁剪(默认)
|
||||
double cropHeight = sourceHeight;
|
||||
double cropWidth = cropHeight * targetRatio;
|
||||
int cropX = (int) Math.round((sourceWidth - cropWidth) / 2);
|
||||
int cropY = 0;
|
||||
|
||||
cropParams.put("X", cropX);
|
||||
cropParams.put("Y", cropY);
|
||||
cropParams.put("Width", (int) Math.round(cropWidth));
|
||||
cropParams.put("Height", (int) Math.round(cropHeight));
|
||||
}
|
||||
|
||||
log.debug("[裁剪计算] 源尺寸={}x{}, 模式={}, 裁剪参数={}", sourceWidth, sourceHeight, cropMode, cropParams);
|
||||
return cropParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成单个视频(支持随机截取起始点)
|
||||
*
|
||||
* 多视频差异化原理:
|
||||
* - 每个视频使用随机截取起点,确保内容完全不同
|
||||
* - 支持不同长度的素材,ICE自动容错处理
|
||||
* - 容错机制:如果起点超出素材长度,从0开始截取
|
||||
*
|
||||
* @param materials 素材列表(包含fileUrl和duration)
|
||||
* @param videoIndex 视频序号(0开始),用于生成随机种子
|
||||
* @param userId 用户ID
|
||||
* @param cropMode 裁剪模式:center(居中裁剪)、smart(智能裁剪)、fill(填充模式)
|
||||
* @return jobId : outputUrl 格式字符串
|
||||
*/
|
||||
public String produceSingleVideoWithOffset(List<MixTaskSaveReqVO.MaterialItem> materials,
|
||||
int videoIndex, Long userId, String cropMode) throws Exception {
|
||||
if (iceClient == null) {
|
||||
initClient();
|
||||
}
|
||||
|
||||
JSONArray videoClipArray = new JSONArray();
|
||||
JSONArray audioClipArray = new JSONArray();
|
||||
float timelinePos = 0;
|
||||
|
||||
for (int i = 0; i < materials.size(); i++) {
|
||||
MixTaskSaveReqVO.MaterialItem material = materials.get(i);
|
||||
String videoUrl = material.getFileUrl();
|
||||
int duration = material.getDuration();
|
||||
|
||||
// 验证视频URL必须是阿里云OSS地址
|
||||
if (!videoUrl.contains(".aliyuncs.com")) {
|
||||
log.error("[ICE][视频URL不是阿里云OSS地址][视频{}: {}]", i + 1, videoUrl);
|
||||
throw new IllegalArgumentException("视频URL必须是阿里云OSS地址,当前URL: " + videoUrl);
|
||||
}
|
||||
|
||||
// 计算随机截取起点
|
||||
// 优先使用前端传入的素材实际时长,无则从0开始截取(兜底)
|
||||
Integer fileDuration = material.getFileDuration();
|
||||
int startOffset = 0;
|
||||
int endOffset = duration;
|
||||
|
||||
if (fileDuration != null && fileDuration > duration) {
|
||||
// 有实际时长且足够:随机起点范围 0 到 (实际时长 - 截取时长)
|
||||
long randomSeed = ((material.getFileId() != null ? material.getFileId() : i) * 1000000L) +
|
||||
(videoIndex * 10000L) + (material.getFileUrl().hashCode() % 1000);
|
||||
Random random = new Random(randomSeed);
|
||||
int maxStartOffset = fileDuration - duration;
|
||||
startOffset = random.nextInt(maxStartOffset + 1);
|
||||
endOffset = startOffset + duration;
|
||||
log.debug("[ICE][随机截取] fileId={}, fileDuration={}s, In={}, Out={}",
|
||||
material.getFileId(), fileDuration, startOffset, endOffset);
|
||||
} else {
|
||||
// 无时长或时长不足:从0开始截取(兜底)
|
||||
log.debug("[ICE][兜底截取] fileId={}, fileDuration={}, In=0, Out={}",
|
||||
material.getFileId(), fileDuration, duration);
|
||||
}
|
||||
|
||||
log.debug("[ICE][添加视频片段][视频{}: {}, In={}, Out={}, TimelineIn={}, TimelineOut={}]",
|
||||
videoIndex + 1, videoUrl, startOffset, endOffset, timelinePos, timelinePos + duration);
|
||||
|
||||
// 构建视频片段(带 In/Out 参数)
|
||||
JSONObject videoClip = new JSONObject();
|
||||
videoClip.put("MediaURL", videoUrl);
|
||||
videoClip.put("In", startOffset);
|
||||
videoClip.put("Out", endOffset);
|
||||
videoClip.put("TimelineIn", timelinePos);
|
||||
videoClip.put("TimelineOut", timelinePos + duration);
|
||||
|
||||
// 添加裁剪效果(9:16竖屏输出)
|
||||
// 假设源素材为1920x1080(16:9),可根据实际情况调整
|
||||
int sourceWidth = 1920;
|
||||
int sourceHeight = 1080;
|
||||
|
||||
if (cropMode != null && !"fill".equals(cropMode)) {
|
||||
// 非填充模式需要裁剪
|
||||
Map<String, Integer> cropParams = calculateCropParams(sourceWidth, sourceHeight, cropMode);
|
||||
|
||||
JSONArray effects = new JSONArray();
|
||||
JSONObject cropEffect = new JSONObject();
|
||||
cropEffect.put("Type", "Crop");
|
||||
cropEffect.put("X", cropParams.get("X"));
|
||||
cropEffect.put("Y", cropParams.get("Y"));
|
||||
cropEffect.put("Width", cropParams.get("Width"));
|
||||
cropEffect.put("Height", cropParams.get("Height"));
|
||||
effects.add(cropEffect);
|
||||
|
||||
videoClip.put("Effects", effects);
|
||||
log.debug("[裁剪效果] 视频{}应用裁剪,模式={}, 参数={}", i + 1, cropMode, cropParams);
|
||||
}
|
||||
|
||||
videoClipArray.add(videoClip);
|
||||
|
||||
// 为每个视频片段添加静音的音频轨道
|
||||
JSONObject audioClip = new JSONObject();
|
||||
audioClip.put("MediaURL", videoUrl);
|
||||
audioClip.put("In", startOffset);
|
||||
audioClip.put("Out", endOffset);
|
||||
audioClip.put("TimelineIn", timelinePos);
|
||||
audioClip.put("TimelineOut", timelinePos + duration);
|
||||
audioClip.put("Effects", new JSONArray() {{
|
||||
add(new JSONObject() {{
|
||||
put("Type", "Volume");
|
||||
put("Gain", 0); // 静音
|
||||
}});
|
||||
}});
|
||||
audioClipArray.add(audioClip);
|
||||
|
||||
timelinePos += duration;
|
||||
}
|
||||
|
||||
// 构建时间线
|
||||
String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":" + videoClipArray.toJSONString() +
|
||||
"}],\"AudioTracks\":[{\"AudioTrackClips\":" + audioClipArray.toJSONString() + "}]}";
|
||||
|
||||
// 生成输出文件路径
|
||||
String targetFileName = UUID.randomUUID().toString().replace("-", "");
|
||||
String mixDirectory = ossInitService.getOssDirectoryByCategory(userId, "mix");
|
||||
String dateDir = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"));
|
||||
String outputMediaPath = mixDirectory + "/" + dateDir + "/" + targetFileName + ".mp4";
|
||||
|
||||
String bucketEndpoint = "https://" + properties.getBucket() + ".oss-" + properties.getRegionId() + ".aliyuncs.com";
|
||||
String outputMediaUrl = bucketEndpoint + "/" + outputMediaPath;
|
||||
|
||||
int width = 720;
|
||||
int height = 1280;
|
||||
int bitrate = 2000;
|
||||
String outputMediaConfig = "{\"MediaURL\":\"" + outputMediaUrl + "\",\"Width\":" + width +
|
||||
",\"Height\":" + height + ",\"Bitrate\":" + bitrate + "}";
|
||||
|
||||
SubmitMediaProducingJobRequest request = new SubmitMediaProducingJobRequest();
|
||||
request.setTimeline(timeline);
|
||||
request.setOutputMediaConfig(outputMediaConfig);
|
||||
|
||||
log.info("[ICE][提交任务][videoIndex={}, 素材数量={}, 总时长={}s]",
|
||||
videoIndex, materials.size(), (int)timelinePos);
|
||||
SubmitMediaProducingJobResponse response = iceClient.submitMediaProducingJob(request);
|
||||
|
||||
String jobId = response.getBody().getJobId();
|
||||
log.info("[ICE][任务提交成功][videoIndex={}, jobId={}, outputUrl={}]", videoIndex, jobId, outputMediaUrl);
|
||||
return jobId + " : " + outputMediaUrl;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -24,9 +24,9 @@ public class MixTaskConstants {
|
||||
|
||||
/**
|
||||
* 定时任务配置
|
||||
* 改为每2分钟检查一次,降低API调用频率
|
||||
* 改为每30秒检查一次,提供更实时的进度更新
|
||||
*/
|
||||
public static final String CRON_CHECK_STATUS = "0 */2 * * * ?";
|
||||
public static final String CRON_CHECK_STATUS = "*/30 * * * * ?";
|
||||
|
||||
/**
|
||||
* 任务状态检查优化配置
|
||||
|
||||
@@ -46,6 +46,12 @@ public class MixTaskDO extends TenantBaseDO {
|
||||
@TableField("video_urls")
|
||||
private String videoUrls;
|
||||
|
||||
/**
|
||||
* 素材配置JSON(包含fileId、fileUrl、duration)
|
||||
*/
|
||||
@TableField("materials_json")
|
||||
private String materialsJson;
|
||||
|
||||
/**
|
||||
* 背景音乐URL列表(逗号分隔)
|
||||
*/
|
||||
@@ -162,4 +168,18 @@ public class MixTaskDO extends TenantBaseDO {
|
||||
public void setOutputUrlList(List<String> outputUrls) {
|
||||
this.outputUrls = outputUrls == null || outputUrls.isEmpty() ? null : String.join(",", outputUrls);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取素材配置JSON
|
||||
*/
|
||||
public String getMaterialsJson() {
|
||||
return materialsJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置素材配置JSON
|
||||
*/
|
||||
public void setMaterialsJson(String materialsJson) {
|
||||
this.materialsJson = materialsJson;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.tik.mix.service;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.infra.service.file.FileService;
|
||||
import cn.iocoder.yudao.module.tik.mix.client.IceClient;
|
||||
@@ -46,8 +47,11 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createMixTask(MixTaskSaveReqVO createReqVO, Long userId) {
|
||||
log.info("[MixTask][创建任务] userId={}, title={}, videoCount={}, produceCount={}",
|
||||
userId, createReqVO.getTitle(), createReqVO.getVideoUrls().size(), createReqVO.getProduceCount());
|
||||
// 1. 校验时长
|
||||
validateDuration(createReqVO);
|
||||
|
||||
log.info("[MixTask][创建任务] userId={}, title={}, materialCount={}, produceCount={}",
|
||||
userId, createReqVO.getTitle(), createReqVO.getMaterials().size(), createReqVO.getProduceCount());
|
||||
|
||||
// 1. 创建初始任务对象
|
||||
MixTaskDO task = MixTaskUtils.createInitialTask(createReqVO, userId);
|
||||
@@ -168,10 +172,29 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
// 3. 重新提交到ICE
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
// 手动构建请求对象(纯画面模式:无需text和bgMusicUrls)
|
||||
// 从 materialsJson 重建请求对象
|
||||
List<MixTaskSaveReqVO.MaterialItem> materials = null;
|
||||
if (StrUtil.isNotEmpty(existTask.getMaterialsJson())) {
|
||||
materials = JsonUtils.parseArray(existTask.getMaterialsJson(), MixTaskSaveReqVO.MaterialItem.class);
|
||||
} else if (existTask.getVideoUrlList() != null && !existTask.getVideoUrlList().isEmpty()) {
|
||||
// 兼容旧版本:从 videoUrls 重建(默认3秒时长)
|
||||
materials = existTask.getVideoUrlList().stream()
|
||||
.map(url -> {
|
||||
MixTaskSaveReqVO.MaterialItem item = new MixTaskSaveReqVO.MaterialItem();
|
||||
item.setFileUrl(url);
|
||||
item.setDuration(3); // 默认3秒
|
||||
return item;
|
||||
})
|
||||
.collect(ArrayList::new, ArrayList::add, ArrayList::addAll);
|
||||
}
|
||||
|
||||
if (materials == null || materials.isEmpty()) {
|
||||
throw new IllegalArgumentException("无法重建素材列表");
|
||||
}
|
||||
|
||||
MixTaskSaveReqVO saveReqVO = new MixTaskSaveReqVO();
|
||||
saveReqVO.setTitle(existTask.getTitle());
|
||||
saveReqVO.setVideoUrls(existTask.getVideoUrlList());
|
||||
saveReqVO.setMaterials(materials);
|
||||
saveReqVO.setProduceCount(existTask.getProduceCount());
|
||||
submitToICE(id, saveReqVO, existTask.getUserId());
|
||||
} catch (Exception e) {
|
||||
@@ -353,24 +376,32 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
|
||||
/**
|
||||
* 提交任务到阿里云 ICE
|
||||
*
|
||||
* 多视频差异化逻辑:
|
||||
* - 每个视频使用相同的素材顺序和时长
|
||||
* - 但截取起始点不同(videoIndex * duration)
|
||||
* - 生成内容不同的多个视频
|
||||
*/
|
||||
private void submitToICE(Long taskId, MixTaskSaveReqVO createReqVO, Long userId) {
|
||||
try {
|
||||
// 1. 转换为ICE需要的参数格式
|
||||
String[] videoArray = createReqVO.getVideoUrls().toArray(new String[0]);
|
||||
List<String> jobIdWithUrls = new ArrayList<>();
|
||||
int produceCount = createReqVO.getProduceCount();
|
||||
|
||||
// 2. 调用ICE批量生成接口(纯画面模式:无需text和bgMusic)
|
||||
List<String> jobIdWithUrls = batchProduceAlignment.batchProduceAlignment(
|
||||
createReqVO.getTitle(),
|
||||
videoArray,
|
||||
createReqVO.getProduceCount(),
|
||||
userId
|
||||
);
|
||||
// 循环生成多个视频,每个视频使用不同的截取起始点
|
||||
for (int videoIndex = 0; videoIndex < produceCount; videoIndex++) {
|
||||
String jobIdWithUrl = batchProduceAlignment.produceSingleVideoWithOffset(
|
||||
createReqVO.getMaterials(),
|
||||
videoIndex,
|
||||
userId,
|
||||
createReqVO.getCropMode()
|
||||
);
|
||||
jobIdWithUrls.add(jobIdWithUrl);
|
||||
}
|
||||
|
||||
// 3. 解析jobId和输出URL
|
||||
// 解析jobId和输出URL
|
||||
MixTaskUtils.JobIdUrlPair jobIdUrlPair = MixTaskUtils.parseJobIdsAndUrls(jobIdWithUrls);
|
||||
|
||||
// 4. 更新任务信息(包含状态和进度)
|
||||
// 更新任务信息
|
||||
updateTaskWithResults(taskId, jobIdUrlPair.getJobIds(), jobIdUrlPair.getOutputUrls(),
|
||||
MixTaskConstants.STATUS_RUNNING, MixTaskConstants.PROGRESS_UPLOADED);
|
||||
|
||||
@@ -498,4 +529,36 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验混剪任务时长
|
||||
*/
|
||||
private void validateDuration(MixTaskSaveReqVO req) {
|
||||
// 1. 素材列表不能为空
|
||||
if (req.getMaterials() == null || req.getMaterials().isEmpty()) {
|
||||
throw new IllegalArgumentException("素材列表不能为空");
|
||||
}
|
||||
|
||||
// 2. 计算总时长
|
||||
int totalDuration = req.getMaterials().stream()
|
||||
.mapToInt(MixTaskSaveReqVO.MaterialItem::getDuration)
|
||||
.sum();
|
||||
|
||||
// 3. 总时长校验(15s-30s)
|
||||
if (totalDuration < 15) {
|
||||
throw new IllegalArgumentException("总时长不能小于15秒,当前:" + totalDuration + "秒");
|
||||
}
|
||||
if (totalDuration > 30) {
|
||||
throw new IllegalArgumentException("总时长不能超过30秒,当前:" + totalDuration + "秒");
|
||||
}
|
||||
|
||||
// 4. 单个素材时长校验(3s-5s)
|
||||
for (MixTaskSaveReqVO.MaterialItem item : req.getMaterials()) {
|
||||
if (item.getDuration() < 3 || item.getDuration() > 5) {
|
||||
throw new IllegalArgumentException("单个素材时长需在3-5秒之间,当前:" + item.getDuration() + "秒");
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[MixTask][时长校验通过] totalDuration={}s, materialCount={}", totalDuration, req.getMaterials().size());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.iocoder.yudao.module.tik.mix.util;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.tik.mix.constants.MixTaskConstants;
|
||||
import cn.iocoder.yudao.module.tik.mix.dal.dataobject.MixTaskDO;
|
||||
import cn.iocoder.yudao.module.tik.mix.vo.MixTaskSaveReqVO;
|
||||
@@ -27,7 +28,19 @@ public class MixTaskUtils {
|
||||
task.setUserId(userId);
|
||||
task.setTitle(reqVO.getTitle());
|
||||
task.setText(null); // 纯画面模式,不需要文案
|
||||
task.setVideoUrlList(reqVO.getVideoUrls());
|
||||
|
||||
// 存储素材配置JSON
|
||||
String materialsJson = JsonUtils.toJsonString(reqVO.getMaterials());
|
||||
task.setMaterialsJson(materialsJson);
|
||||
|
||||
// 兼容旧版本:同时存储 videoUrls(取第一个视频的URL,用于兼容查询)
|
||||
if (reqVO.getMaterials() != null && !reqVO.getMaterials().isEmpty()) {
|
||||
List<String> videoUrls = reqVO.getMaterials().stream()
|
||||
.map(MixTaskSaveReqVO.MaterialItem::getFileUrl)
|
||||
.collect(ArrayList::new, ArrayList::add, ArrayList::addAll);
|
||||
task.setVideoUrlList(videoUrls);
|
||||
}
|
||||
|
||||
task.setBgMusicUrlList(null); // 纯画面模式,不需要背景音乐
|
||||
task.setProduceCount(reqVO.getProduceCount());
|
||||
task.setStatus(MixTaskConstants.STATUS_PENDING);
|
||||
@@ -127,4 +140,54 @@ public class MixTaskUtils {
|
||||
return outputUrls;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 ICE Timeline
|
||||
*
|
||||
* @param materials 素材列表
|
||||
* @return ICE Timeline JSON 字符串
|
||||
*/
|
||||
public static String buildTimeline(List<MixTaskSaveReqVO.MaterialItem> materials) {
|
||||
StringBuilder tracks = new StringBuilder();
|
||||
float currentTime = 0;
|
||||
|
||||
for (int i = 0; i < materials.size(); i++) {
|
||||
MixTaskSaveReqVO.MaterialItem material = materials.get(i);
|
||||
if (i > 0) {
|
||||
tracks.append(",");
|
||||
}
|
||||
|
||||
tracks.append(String.format("""
|
||||
{
|
||||
"MediaURL": "%s",
|
||||
"In": 0,
|
||||
"Out": %d,
|
||||
"TimelineIn": %.2f,
|
||||
"TimelineOut": %.2f
|
||||
}
|
||||
""",
|
||||
material.getFileUrl(),
|
||||
material.getDuration(),
|
||||
currentTime,
|
||||
currentTime + material.getDuration()
|
||||
));
|
||||
|
||||
currentTime += material.getDuration();
|
||||
}
|
||||
|
||||
return buildFullTimeline(tracks.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整的 ICE Timeline
|
||||
*/
|
||||
private static String buildFullTimeline(String tracks) {
|
||||
return String.format("""
|
||||
{
|
||||
"VideoTracks": [{
|
||||
"TrackItems": [%s]
|
||||
}]
|
||||
}
|
||||
""", tracks);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package cn.iocoder.yudao.module.tik.mix.vo;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
@@ -16,11 +18,36 @@ public class MixTaskSaveReqVO {
|
||||
@NotBlank(message = "视频标题不能为空")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "视频素材URL列表", required = true)
|
||||
@NotEmpty(message = "视频素材不能为空")
|
||||
private List<String> videoUrls;
|
||||
@Schema(description = "素材配置列表", required = true)
|
||||
@NotEmpty(message = "素材列表不能为空")
|
||||
private List<MaterialItem> materials;
|
||||
|
||||
@Schema(description = "生成数量", required = true, example = "1")
|
||||
@NotNull(message = "生成数量不能为空")
|
||||
private Integer produceCount = 1; // 默认生成1个
|
||||
|
||||
@Schema(description = "裁剪模式", example = "center")
|
||||
private String cropMode = "center"; // 默认居中裁剪
|
||||
|
||||
@Schema(description = "素材项")
|
||||
@Data
|
||||
public static class MaterialItem {
|
||||
|
||||
@Schema(description = "素材文件ID", required = true, example = "12345")
|
||||
@NotNull(message = "素材文件ID不能为空")
|
||||
private Long fileId;
|
||||
|
||||
@Schema(description = "素材URL", required = true, example = "https://xxx.com/video1.mp4")
|
||||
@NotBlank(message = "素材URL不能为空")
|
||||
private String fileUrl;
|
||||
|
||||
@Schema(description = "截取时长(秒)", required = true, example = "3")
|
||||
@Min(value = 3, message = "单个素材时长不能小于3秒")
|
||||
@Max(value = 5, message = "单个素材时长不能超过5秒")
|
||||
@NotNull(message = "素材时长不能为空")
|
||||
private Integer duration;
|
||||
|
||||
@Schema(description = "素材实际时长(秒)", example = "60")
|
||||
private Integer fileDuration;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,9 +61,9 @@ public class CosyVoiceProperties {
|
||||
private Duration connectTimeout = Duration.ofSeconds(10);
|
||||
|
||||
/**
|
||||
* 读取超时时间
|
||||
* 读取超时时间(改为3分钟,提升语音合成成功率)
|
||||
*/
|
||||
private Duration readTimeout = Duration.ofSeconds(60);
|
||||
private Duration readTimeout = Duration.ofSeconds(180);
|
||||
|
||||
/**
|
||||
* 是否启用
|
||||
|
||||
@@ -56,9 +56,9 @@ public class LatentsyncProperties {
|
||||
private Duration connectTimeout = Duration.ofSeconds(10);
|
||||
|
||||
/**
|
||||
* 读取超时时间
|
||||
* 读取超时时间(改为3分钟,提升语音合成成功率)
|
||||
*/
|
||||
private Duration readTimeout = Duration.ofSeconds(60);
|
||||
private Duration readTimeout = Duration.ofSeconds(180);
|
||||
|
||||
/**
|
||||
* 是否打开调用
|
||||
|
||||
Reference in New Issue
Block a user