混剪优化

This commit is contained in:
2025-12-07 00:10:22 +08:00
parent 0fffd787bb
commit 7f7551f74f
14 changed files with 479 additions and 113 deletions

View File

@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.tik.media;
import cn.iocoder.yudao.module.infra.api.file.FileApi;
import cn.iocoder.yudao.module.tik.file.service.TikOssInitService;
import cn.iocoder.yudao.module.tik.mix.config.IceProperties;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
@@ -36,6 +37,7 @@ public class BatchProduceAlignment {
private final IceProperties properties;
private final FileApi fileApi;
private final TikOssInitService ossInitService;
private Client iceClient;
public void initClient() throws Exception {
@@ -60,7 +62,7 @@ public class BatchProduceAlignment {
}
}
public List<String> batchProduceAlignment(String title, String[] videoArray, int produceCount) throws Exception {
public List<String> batchProduceAlignment(String title, String[] videoArray, int produceCount, Long userId) throws Exception {
// 初始化 ICE 客户端
if (iceClient == null) {
initClient();
@@ -71,7 +73,7 @@ public class BatchProduceAlignment {
if (produceCount <= 1) {
// 生成1个视频包含所有片段
String jobIdWithUrl = produceSingleVideo(title, videoArray);
String jobIdWithUrl = produceSingleVideo(title, videoArray, userId);
jobIdWithUrls.add(jobIdWithUrl);
} else {
// 生成多个视频:将视频数组分成多份,每份生成一个视频
@@ -90,7 +92,7 @@ public class BatchProduceAlignment {
String[] groupVideos = Arrays.copyOfRange(videoArray, start, start + groupSize);
// 生成单个视频
String jobIdWithUrl = produceSingleVideo(title, groupVideos);
String jobIdWithUrl = produceSingleVideo(title, groupVideos, userId);
jobIdWithUrls.add(jobIdWithUrl);
start += groupSize;
@@ -101,7 +103,7 @@ public class BatchProduceAlignment {
return jobIdWithUrls;
}
public String produceSingleVideo(String title, String[] videoArray) throws Exception {
public String produceSingleVideo(String title, String[] videoArray, Long userId) throws Exception {
// 初始化 ICE 客户端
if (iceClient == null) {
initClient();
@@ -113,7 +115,6 @@ public class BatchProduceAlignment {
// 按顺序拼接视频片段(不随机打乱)
for (int i = 0; i < videoArray.length; i++) {
String clipId = "clip" + i;
String videoUrl = videoArray[i];
// 验证视频URL必须是阿里云OSS地址
@@ -130,32 +131,33 @@ public class BatchProduceAlignment {
// 为每个视频片段添加静音的音频轨道
JSONObject audioClip = new JSONObject();
audioClip.put("MediaURL", videoUrl);
// 添加静音效果
JSONObject volumeEffect = new JSONObject();
volumeEffect.put("Type", "Volume");
volumeEffect.put("Gain", 0); // 0 表示静音
JSONArray effects = new JSONArray();
effects.add(volumeEffect);
audioClip.put("Effects", effects);
audioClip.put("Effects", new JSONArray() {{
add(new JSONObject() {{
put("Type", "Volume");
put("Gain", 0); // 0 表示静音
}});
}});
audioClipArray.add(audioClip);
}
// 构建时间线,包含视频轨道和音频轨道
String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":"+videoClipArray.toJSONString()+"}],\"AudioTracks\":[{\"AudioTrackClips\":"+audioClipArray.toJSONString()+"}]}";
//
// 生成输出文件路径:{用户目录}/mix/{yyyyMMdd}/{uuid}.mp4
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";
// 使用默认的阿里云OSS endpoint格式
String bucketEndpoint = "https://" + properties.getBucket() + ".oss-" + properties.getRegionId() + ".aliyuncs.com";
String outputMediaPath = "/ice_output/" + targetFileName + ".mp4";
String outputMediaUrl = bucketEndpoint + outputMediaPath;
// 生成签名URL有效期24小时使用公共API
String signedUrl = fileApi.presignGetUrl(outputMediaUrl, 24 * 3600);
String outputMediaUrl = bucketEndpoint + "/" + outputMediaPath;
// ICE需要将处理结果写入到该URL签名URL会导致写入失败
int width = 720;
int height = 1280;
int bitrate = 2000; // 输出码率 2000 Kbit/s符合 ICE API 文档推荐)
String outputMediaConfig = "{\"MediaURL\":\"" + signedUrl + "\",\"Width\":" + width + ",\"Height\":" + height + ",\"Bitrate\":" + bitrate + "}";
String outputMediaConfig = "{\"MediaURL\":\"" + outputMediaUrl + "\",\"Width\":" + width + ",\"Height\":" + height + ",\"Bitrate\":" + bitrate + "}";
SubmitMediaProducingJobRequest request = new SubmitMediaProducingJobRequest();
request.setTimeline(timeline);
@@ -166,6 +168,7 @@ public class BatchProduceAlignment {
String jobId = response.getBody().getJobId();
log.info("[ICE][任务提交成功][jobId={}, outputMediaUrl={}]", jobId, outputMediaUrl);
// 返回原始URL签名URL在用户下载时生成
return jobId + " : " + outputMediaUrl;
}

View File

@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.tik.media.controller;
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.tik.media.BatchProduceAlignment;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -31,8 +32,14 @@ public class BatchProduceAlignmentController {
// 生成的成片数
int produceCount) {
try {
// 获取当前登录用户ID
Long userId = SecurityFrameworkUtils.getLoginUserId();
if (userId == null) {
return CommonResult.error(new ErrorCode(401, "用户未登录"));
}
// 纯画面模式仅传入视频数组无需text和bgMusicArray
List<String> jobIds = batchProduceAlignment.batchProduceAlignment(title, videoArray, produceCount);
List<String> jobIds = batchProduceAlignment.batchProduceAlignment(title, videoArray, produceCount, userId);
return CommonResult.success(jobIds);
} catch (Exception e) {
return CommonResult.error(new ErrorCode(500, "Produce failed. Exception: " + e));

View File

@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.tik.mix.controller;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
@@ -14,6 +15,9 @@ import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.ArrayList;
import java.util.List;
/**
* 混剪任务 Controller
*
@@ -98,4 +102,38 @@ public class MixTaskController {
mixTaskService.cancelTask(id);
return CommonResult.success(true);
}
@GetMapping("/signed-url/{id}")
@Operation(summary = "获取任务输出文件的签名URL")
public CommonResult<List<String>> getSignedUrls(@PathVariable Long id) {
try {
// 获取任务信息
MixTaskRespVO mixTaskVO = mixTaskService.getMixTask(id);
if (mixTaskVO == null || mixTaskVO.getOutputUrls() == null) {
return CommonResult.success(List.of());
}
// 重新生成所有输出URL的签名URL避免使用过期的签名URL
List<String> signedUrls = new ArrayList<>();
for (String outputUrl : mixTaskVO.getOutputUrls()) {
try {
// 每次都重新生成签名URL有效期24小时
String signedUrl = mixTaskService.generateSignedUrl(outputUrl, 24 * 3600);
if (StrUtil.isNotEmpty(signedUrl)) {
signedUrls.add(signedUrl);
}
} catch (Exception e) {
log.error("[MixTask][生成签名URL失败] url={}, error={}", outputUrl, e.getMessage(), e);
// 单个URL失败不影响其他URL
}
}
log.info("[MixTask][获取签名URL成功] taskId={}, urlCount={}", id, signedUrls.size());
return CommonResult.success(signedUrls);
} catch (Exception e) {
log.error("[MixTask][获取签名URL失败] taskId={}, error={}", id, e.getMessage(), e);
// 不返回详细错误信息给前端避免XML/HTML解析问题
return CommonResult.error(500, "获取签名URL失败");
}
}
}

View File

@@ -71,4 +71,13 @@ public interface MixTaskService {
* 保存任务结果
*/
void saveTaskResult(Long taskId, List<String> outputUrls);
/**
* 生成签名URL
*
* @param ossUrl OSS文件完整URL
* @param expirationSeconds 有效期(秒)
* @return 签名URL
*/
String generateSignedUrl(String ossUrl, Integer expirationSeconds);
}

View File

@@ -2,6 +2,8 @@ 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.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.infra.service.file.FileService;
import cn.iocoder.yudao.module.tik.mix.client.IceClient;
import cn.iocoder.yudao.module.tik.mix.constants.MixTaskConstants;
import cn.iocoder.yudao.module.tik.mix.dal.dataobject.MixTaskDO;
@@ -9,6 +11,7 @@ import cn.iocoder.yudao.module.tik.mix.dal.mysql.MixTaskMapper;
import cn.iocoder.yudao.module.tik.mix.util.MixTaskUtils;
import cn.iocoder.yudao.module.tik.mix.vo.*;
import cn.iocoder.yudao.module.tik.media.BatchProduceAlignment;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -16,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@@ -36,6 +40,9 @@ public class MixTaskServiceImpl implements MixTaskService {
private final BatchProduceAlignment batchProduceAlignment;
private final IceClient iceClient;
@Resource
private FileService fileService;
@Override
@Transactional(rollbackFor = Exception.class)
public Long createMixTask(MixTaskSaveReqVO createReqVO, Long userId) {
@@ -51,9 +58,9 @@ public class MixTaskServiceImpl implements MixTaskService {
// 3. 异步提交到阿里云 ICE
CompletableFuture.runAsync(() -> {
try {
submitToICE(task.getId(), createReqVO);
submitToICE(task.getId(), createReqVO, userId);
} catch (Exception e) {
log.error("[MixTask][提交ICE失败] taskId={}, error={}", task.getId(), e.getMessage(), e);
log.error("[MixTask][提交ICE失败] taskId={}", task.getId(), e);
updateTaskError(task.getId(), "提交任务失败: " + e.getMessage());
}
});
@@ -88,7 +95,24 @@ public class MixTaskServiceImpl implements MixTaskService {
return;
}
// 2. 执行删除
// 2. 删除OSS文件
try {
List<String> outputUrls = existTask.getOutputUrlList();
if (outputUrls != null && !outputUrls.isEmpty()) {
log.info("[MixTask][删除OSS文件] taskId={}, fileCount={}", id, outputUrls.size());
for (String url : outputUrls) {
try {
fileService.deleteFileByUrl(url);
} catch (Exception e) {
log.error("[MixTask][删除单个OSS文件失败] taskId={}, url={}", id, url, e);
}
}
}
} catch (Exception e) {
log.error("[MixTask][删除OSS文件失败] taskId={}", id, e);
}
// 3. 执行删除
mixTaskMapper.deleteById(id);
}
@@ -149,9 +173,9 @@ public class MixTaskServiceImpl implements MixTaskService {
saveReqVO.setTitle(existTask.getTitle());
saveReqVO.setVideoUrls(existTask.getVideoUrlList());
saveReqVO.setProduceCount(existTask.getProduceCount());
submitToICE(id, saveReqVO);
submitToICE(id, saveReqVO, existTask.getUserId());
} catch (Exception e) {
log.error("[MixTask][重新提交失败] taskId={}, error={}", id, e.getMessage(), e);
log.error("[MixTask][重新提交失败] taskId={}", id, e);
updateTaskError(id, "重新提交失败: " + e.getMessage());
}
});
@@ -218,7 +242,7 @@ public class MixTaskServiceImpl implements MixTaskService {
syncTaskStatus(task.getId(), jobId);
}
} catch (Exception e) {
log.error("[MixTask][单个任务检查失败] taskId={}, error={}", task.getId(), e.getMessage(), e);
log.error("[MixTask][单个任务检查失败] taskId={}", task.getId(), e);
failureCount++;
// 单个任务失败不影响其他任务
}
@@ -247,8 +271,10 @@ public class MixTaskServiceImpl implements MixTaskService {
// 根据ICE状态更新任务
if ("Success".equalsIgnoreCase(status) || "success".equalsIgnoreCase(status)) {
// 任务成功完成,更新为100%
updateTaskStatus(taskId, MixTaskConstants.STATUS_SUCCESS, MixTaskConstants.PROGRESS_COMPLETED);
// 任务成功完成,需要获取实际的输出URL
log.info("[MixTask][ICE任务执行成功] taskId={}, jobId={}", taskId, jobId);
// 重新生成签名URL并更新任务
updateTaskSuccess(taskId, jobId);
} else if ("Failed".equalsIgnoreCase(status) || "failed".equalsIgnoreCase(status) || "Failure".equalsIgnoreCase(status)) {
// 任务失败 - 获取详细错误信息
String errorMsg = "ICE任务执行失败";
@@ -262,18 +288,38 @@ public class MixTaskServiceImpl implements MixTaskService {
taskId, jobId, status, errorMsg);
updateTaskError(taskId, errorMsg);
} else if ("Running".equalsIgnoreCase(status) || "running".equalsIgnoreCase(status) || "Processing".equalsIgnoreCase(status)) {
// 任务仍在运行,更新进度为70%
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, 70);
// 任务仍在运行,逐步更新进度
// 1. 获取当前进度
MixTaskDO currentTask = mixTaskMapper.selectById(taskId);
if (currentTask != null) {
int currentProgress = currentTask.getProgress() != null ? currentTask.getProgress() : 0;
// 2. 根据当前进度逐步提升但不超过95%
int newProgress;
if (currentProgress < 50) {
newProgress = 50; // 首次看到Running时更新到50%
} else if (currentProgress < 80) {
newProgress = 80; // 第二次更新到80%
} else if (currentProgress < 95) {
newProgress = 95; // 最后更新到95%
} else {
newProgress = currentProgress; // 已经很高了,保持不变
}
if (newProgress != currentProgress) {
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, newProgress);
log.info("[MixTask][进度更新] taskId={}, from={}%, to={}%", taskId, currentProgress, newProgress);
}
}
} else if ("Pending".equalsIgnoreCase(status) || "pending".equalsIgnoreCase(status)) {
// 任务等待中,更新进度为60%
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, 60);
// 任务等待中,更新进度为30%
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, 30);
} else {
// 未知状态,记录日志但不更新
log.warn("[MixTask][未知ICE状态] taskId={}, jobId={}, iceStatus={}", taskId, jobId, status);
}
} catch (Exception e) {
log.error("[MixTask][状态同步异常] taskId={}, jobId={}, error={}", taskId, jobId, e.getMessage(), e);
log.error("[MixTask][状态同步异常] taskId={}, jobId={}", taskId, jobId, e);
updateTaskError(taskId, "查询任务状态失败: " + e.getMessage());
}
}
@@ -291,75 +337,49 @@ public class MixTaskServiceImpl implements MixTaskService {
mixTaskMapper.updateById(updateTask);
}
@Override
public String generateSignedUrl(String ossUrl, Integer expirationSeconds) {
try {
if (StrUtil.isEmpty(ossUrl)) {
return null;
}
// 使用FileService生成签名URL
return fileService.presignGetUrl(ossUrl, expirationSeconds);
} catch (Exception e) {
log.error("[MixTask][生成签名URL失败] url={}", ossUrl, e);
return null;
}
}
/**
* 提交任务到阿里云 ICE
*/
private void submitToICE(Long taskId, MixTaskSaveReqVO createReqVO) {
private void submitToICE(Long taskId, MixTaskSaveReqVO createReqVO, Long userId) {
try {
// 1. 更新任务状态为运行中进度10%
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, MixTaskConstants.PROGRESS_SUBMITTED);
// 2. 转换为ICE需要的参数格式
// 1. 转换为ICE需要的参数格式
String[] videoArray = createReqVO.getVideoUrls().toArray(new String[0]);
// 3. 调用ICE批量生成接口纯画面模式无需text和bgMusic
// 2. 调用ICE批量生成接口纯画面模式无需text和bgMusic
List<String> jobIdWithUrls = batchProduceAlignment.batchProduceAlignment(
createReqVO.getTitle(),
videoArray,
createReqVO.getProduceCount()
createReqVO.getProduceCount(),
userId
);
// 4. 解析jobId和输出URL
// 3. 解析jobId和输出URL
MixTaskUtils.JobIdUrlPair jobIdUrlPair = MixTaskUtils.parseJobIdsAndUrls(jobIdWithUrls);
// 5. 更新任务信息
// 4. 更新任务信息(包含状态和进度)
updateTaskWithResults(taskId, jobIdUrlPair.getJobIds(), jobIdUrlPair.getOutputUrls(),
MixTaskConstants.STATUS_RUNNING, MixTaskConstants.PROGRESS_UPLOADED);
} catch (Exception e) {
log.error("[MixTask][ICE提交失败] taskId={}, error={}", taskId, e.getMessage(), e);
updateTaskError(taskId, "ICE处理失败: " + e.getMessage());
// 注意:异步线程中不抛出异常,避免未处理异常
}
}
/**
* 提交任务到阿里云 ICE新版本使用 IceClient
*/
private void submitToIceV2(Long taskId, MixTaskSaveReqVO createReqVO) {
try {
// 1. 更新任务状态
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, MixTaskConstants.PROGRESS_SUBMITTED);
// 2. 构建 timeline 和 outputMediaConfig
String timeline = buildTimeline(createReqVO);
String outputMediaConfig = buildOutputMediaConfig();
// 3. 提交任务
// TODO: 这里需要将 BatchProduceAlignment 的逻辑移到 IceClient 中
// String jobId = iceClient.submitMediaProducingJob(timeline, outputMediaConfig);
} catch (Exception e) {
log.error("[MixTask][V2提交失败] taskId={}, error={}", taskId, e.getMessage(), e);
log.error("[MixTask][ICE提交失败] taskId={}", taskId, e);
updateTaskError(taskId, "ICE处理失败: " + e.getMessage());
}
}
/**
* 构建 ICE 时间线
*/
private String buildTimeline(MixTaskSaveReqVO reqVO) {
// TODO: 实现 timeline 构建逻辑
return "{}";
}
/**
* 构建输出媒体配置
*/
private String buildOutputMediaConfig() {
// TODO: 实现输出配置构建逻辑
return "{}";
}
/**
* 更新任务状态
@@ -399,6 +419,62 @@ public class MixTaskServiceImpl implements MixTaskService {
mixTaskMapper.updateById(updateTask);
}
/**
* 更新任务成功状态重新生成签名URL
*/
private void updateTaskSuccess(Long taskId, String jobId) {
try {
// 查询任务信息
MixTaskDO task = mixTaskMapper.selectById(taskId);
if (task == null) {
log.warn("[MixTask][任务不存在] taskId={}", taskId);
return;
}
// 获取原始输出URL不是签名URL
List<String> outputUrls = task.getOutputUrlList();
if (outputUrls == null || outputUrls.isEmpty()) {
log.warn("[MixTask][任务成功但没有输出URL] taskId={}", taskId);
updateTaskStatus(taskId, MixTaskConstants.STATUS_SUCCESS, MixTaskConstants.PROGRESS_COMPLETED);
return;
}
// 任务成功时直接保存原始URL不是签名URL
// 签名URL在用户下载时通过 /signed-url/{id} 接口动态生成
MixTaskDO updateTask = new MixTaskDO();
updateTask.setId(taskId);
updateTask.setStatus(MixTaskConstants.STATUS_SUCCESS);
updateTask.setProgress(MixTaskConstants.PROGRESS_COMPLETED);
updateTask.setOutputUrlList(outputUrls);
updateTask.setFinishTime(java.time.LocalDateTime.now());
mixTaskMapper.updateById(updateTask);
log.info("[MixTask][任务成功更新] taskId={}, urlCount={}", taskId, outputUrls.size());
} catch (Exception e) {
log.error("[MixTask][更新任务成功状态失败] taskId={}", taskId, e);
// 即使更新失败,也标记为成功
updateTaskStatus(taskId, MixTaskConstants.STATUS_SUCCESS, MixTaskConstants.PROGRESS_COMPLETED);
}
}
/**
* 从OSS URL中提取相对路径
*/
private String extractPathFromUrl(String ossUrl) {
if (StrUtil.isEmpty(ossUrl)) {
return null;
}
// 查找 ".aliyuncs.com/" 的位置
int index = ossUrl.indexOf(".aliyuncs.com/");
if (index == -1) {
return null;
}
// 提取 "/" 后的路径
return ossUrl.substring(index + ".aliyuncs.com/".length());
}
/**
* 将DO分页结果转换为VO分页结果消除冗余代码
* 优化点: