refactor(tik): 抽取 ICE SDK 调用到统一客户端层
将 `BatchProduceAlignment` 中的 ICE `Client` 初始化与 API 调用逻辑抽离到 `IceClient`, 作为项目中唯一 ICE SDK 入口,避免各业务类自行创建客户端实例。 主要变更: - `IceClient`:重构为最终的统一封装入口,支持懒加载 + 线程安全 Client 创建, 新增 `submitJob(SubmitMediaProducingJobRequest)` 和 `getJobStatus()` 方法, 保留旧方法标记为 `@Deprecated` 以兼容存量调用。 - `BatchProduceAlignment`:移除内部 `initClient()` 与直接 SDK 调用,全部委托 `IceClient`; 同时重构 Timeline 构建逻辑,提取 `buildOutputPath()` 等工具方法, 并补充 Javadoc 架构说明。 - `MixTaskServiceImpl`:同步使用 `iceClient.getJobStatus()` 替代已废弃的旧方法名。
This commit is contained in:
@@ -2,13 +2,11 @@ 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.client.IceClient;
|
||||
import cn.iocoder.yudao.module.tik.mix.config.IceProperties;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.aliyun.ice20201109.Client;
|
||||
import com.aliyun.ice20201109.models.*;
|
||||
import com.aliyun.teaopenapi.models.Config;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -16,301 +14,162 @@ import cn.iocoder.yudao.module.tik.mix.vo.MixTaskSaveReqVO;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
// 成功视频
|
||||
// http://oushu-test-shanghai.oss-cn-shanghai.aliyuncs.com/ice_output/46b29eb5775f4f758846171ab79bfca7.mp4
|
||||
|
||||
/**
|
||||
* 需要maven引入二方包依赖:
|
||||
* <dependency>
|
||||
* <groupId>com.aliyun</groupId>
|
||||
* <artifactId>ice20201109</artifactId>
|
||||
* <version>2.1.0</version>
|
||||
* </dependency>
|
||||
* <dependency>
|
||||
* <groupId>com.alibaba</groupId>
|
||||
* <artifactId>fastjson</artifactId>
|
||||
* <version>1.2.9</version>
|
||||
* </dependency>
|
||||
* Timeline 构建器 + ICE 提交编排
|
||||
* <p>
|
||||
* 职责:将素材列表构建为 ICE Timeline JSON,委托 {@link IceClient} 提交任务。
|
||||
* <p>
|
||||
* 架构原则:
|
||||
* <ul>
|
||||
* <li>不自行创建 SDK Client —— 统一通过 {@link IceClient} 调用 ICE API</li>
|
||||
* <li>专注于 Timeline 构建逻辑(随机截取、裁剪参数、音频静音等)</li>
|
||||
* <li>OSS URL 转换通过 {@link IceProperties} 完成</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class BatchProduceAlignment {
|
||||
|
||||
private final IceProperties properties;
|
||||
private final FileApi fileApi;
|
||||
private final TikOssInitService ossInitService;
|
||||
private Client iceClient;
|
||||
private final IceClient iceClient;
|
||||
|
||||
public void initClient() throws Exception {
|
||||
if (iceClient == null) {
|
||||
synchronized (this) {
|
||||
if (iceClient == null) {
|
||||
if (!properties.isEnabled()) {
|
||||
log.error("ICE 配置未启用或未配置 AccessKey");
|
||||
throw new IllegalStateException("未配置 ICE AccessKey");
|
||||
}
|
||||
log.info("初始化阿里云 ICE 客户端... regionId={}, bucket={}",
|
||||
properties.getRegionId(), properties.getBucket());
|
||||
Config config = new Config();
|
||||
config.accessKeyId = properties.getAccessKeyId();
|
||||
config.accessKeySecret = properties.getAccessKeySecret();
|
||||
config.endpoint = "ice." + properties.getRegionId() + ".aliyuncs.com";
|
||||
config.regionId = properties.getRegionId();
|
||||
iceClient = new Client(config);
|
||||
log.info("ICE 客户端初始化成功");
|
||||
}
|
||||
}
|
||||
}
|
||||
public BatchProduceAlignment(IceProperties properties, FileApi fileApi,
|
||||
TikOssInitService ossInitService, IceClient iceClient) {
|
||||
this.properties = properties;
|
||||
this.fileApi = fileApi;
|
||||
this.ossInitService = ossInitService;
|
||||
this.iceClient = iceClient;
|
||||
}
|
||||
|
||||
public List<String> batchProduceAlignment(String title, String[] videoArray, int produceCount, Long userId) throws Exception {
|
||||
// 初始化 ICE 客户端
|
||||
if (iceClient == null) {
|
||||
initClient();
|
||||
}
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
* 旧版 API(兼容保留)
|
||||
* ════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
// 批量提交任务,返回 "jobId:url" 格式
|
||||
public List<String> batchProduceAlignment(String title, String[] videoArray, int produceCount, Long userId) throws Exception {
|
||||
List<String> jobIdWithUrls = new ArrayList<>();
|
||||
|
||||
if (produceCount <= 1) {
|
||||
// 生成1个视频,包含所有片段
|
||||
String jobIdWithUrl = produceSingleVideo(title, videoArray, userId);
|
||||
jobIdWithUrls.add(jobIdWithUrl);
|
||||
} else {
|
||||
// 生成多个视频:将视频数组分成多份,每份生成一个视频
|
||||
int videoCount = videoArray.length;
|
||||
|
||||
// 计算每份的起始和结束索引
|
||||
int videosPerGroup = Math.max(1, videoCount / produceCount);
|
||||
int remainder = videoCount % produceCount;
|
||||
|
||||
int start = 0;
|
||||
for (int i = 0; i < produceCount; i++) {
|
||||
// 计算当前组的视频数量(尽可能平均分配)
|
||||
int groupSize = videosPerGroup + (i < remainder ? 1 : 0);
|
||||
|
||||
// 提取当前组的视频片段
|
||||
String[] groupVideos = Arrays.copyOfRange(videoArray, start, start + groupSize);
|
||||
|
||||
// 生成单个视频
|
||||
String jobIdWithUrl = produceSingleVideo(title, groupVideos, userId);
|
||||
jobIdWithUrls.add(jobIdWithUrl);
|
||||
|
||||
start += groupSize;
|
||||
}
|
||||
}
|
||||
|
||||
// 改为异步模式,不在这里等待
|
||||
return jobIdWithUrls;
|
||||
}
|
||||
|
||||
public String produceSingleVideo(String title, String[] videoArray, Long userId) throws Exception {
|
||||
// 初始化 ICE 客户端
|
||||
if (iceClient == null) {
|
||||
initClient();
|
||||
}
|
||||
|
||||
// 纯画面模式:仅拼接视频片段,为每个视频添加静音音频轨道
|
||||
JSONArray videoClipArray = new JSONArray();
|
||||
JSONArray audioClipArray = new JSONArray();
|
||||
|
||||
// 按顺序拼接视频片段(不随机打乱)
|
||||
for (int i = 0; i < videoArray.length; i++) {
|
||||
String videoUrl = videoArray[i];
|
||||
|
||||
// 将CDN URL转换为OSS原始URL(ICE需要原始域名)
|
||||
videoUrl = properties.convertToOssUrl(videoUrl);
|
||||
videoArray[i] = videoUrl; // 更新数组中的URL
|
||||
String videoUrl = properties.convertToOssUrl(videoArray[i]);
|
||||
videoArray[i] = videoUrl;
|
||||
|
||||
log.debug("[ICE][添加视频片段][{}: {}]", i + 1, videoUrl);
|
||||
// 使用标准的 MediaURL 参数(符合 ICE API 文档规范)
|
||||
String videoClip = "{\"MediaURL\":\"" + videoUrl + "\"}";
|
||||
videoClipArray.add(JSONObject.parseObject(videoClip));
|
||||
|
||||
// 为每个视频片段添加静音的音频轨道
|
||||
JSONObject audioClip = new JSONObject();
|
||||
audioClip.put("MediaURL", videoUrl);
|
||||
audioClip.put("Effects", new JSONArray() {{
|
||||
add(new JSONObject() {{
|
||||
put("Type", "Volume");
|
||||
put("Gain", 0); // 0 表示静音
|
||||
put("Gain", 0);
|
||||
}});
|
||||
}});
|
||||
audioClipArray.add(audioClip);
|
||||
}
|
||||
|
||||
// 构建时间线,包含视频轨道和音频轨道
|
||||
String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":"+videoClipArray.toJSONString()+"}],\"AudioTracks\":[{\"AudioTrackClips\":"+audioClipArray.toJSONString()+"}]}";
|
||||
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";
|
||||
|
||||
// ICE写入必须使用OSS原始域名(不能是CDN域名,因为ICE需要写权限)
|
||||
String outputMediaPath = buildOutputPath(userId);
|
||||
String outputMediaUrl = properties.getOssWriteUrl(outputMediaPath);
|
||||
|
||||
// ICE需要将处理结果写入到该URL,签名URL会导致写入失败
|
||||
int width = 720;
|
||||
int height = 1280;
|
||||
int bitrate = 2000; // 输出码率 2000 Kbit/s(符合 ICE API 文档推荐)
|
||||
String outputMediaConfig = "{\"MediaURL\":\"" + outputMediaUrl + "\",\"Width\":" + width + ",\"Height\":" + height + ",\"Bitrate\":" + bitrate + "}";
|
||||
int width = 720, height = 1280, 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][提交任务][视频数量={}, timeline={}]", videoArray.length, timeline);
|
||||
SubmitMediaProducingJobResponse response = iceClient.submitMediaProducingJob(request);
|
||||
log.info("[ICE][提交任务][视频数量={}]", videoArray.length);
|
||||
String jobId = iceClient.submitJob(request);
|
||||
log.info("[ICE][任务提交成功][jobId={}]", jobId);
|
||||
|
||||
String jobId = response.getBody().getJobId();
|
||||
log.info("[ICE][任务提交成功][jobId={}, outputMediaUrl={}]", jobId, outputMediaUrl);
|
||||
// 返回CDN URL用于存储到数据库(ICE写入用OSS原始URL,存储用CDN URL)
|
||||
String cdnUrl = properties.getCdnStorageUrl(outputMediaPath);
|
||||
return jobId + " : " + cdnUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查单个任务状态
|
||||
*
|
||||
* @param jobId 任务ID
|
||||
* @return 任务状态:Pending/Running/Success/Failed
|
||||
*/
|
||||
public String checkJobStatus(String jobId) throws Exception {
|
||||
if (iceClient == null) {
|
||||
initClient();
|
||||
}
|
||||
|
||||
GetMediaProducingJobRequest req = new GetMediaProducingJobRequest();
|
||||
req.setJobId(jobId);
|
||||
GetMediaProducingJobResponse response = iceClient.getMediaProducingJob(req);
|
||||
GetMediaProducingJobResponseBody.GetMediaProducingJobResponseBodyMediaProducingJob mediaProducingJob = response.getBody().getMediaProducingJob();
|
||||
String status = mediaProducingJob.getStatus();
|
||||
log.debug("jobId: {}, status: {}", mediaProducingJob.getJobId(), status);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从任务ID中获取输出URL
|
||||
*
|
||||
* @param jobIdWithUrl jobId : url 格式的字符串
|
||||
* @return 输出URL
|
||||
*/
|
||||
public String extractOutputUrl(String jobIdWithUrl) {
|
||||
if (jobIdWithUrl == null || !jobIdWithUrl.contains(" : ")) {
|
||||
return null;
|
||||
}
|
||||
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<>();
|
||||
|
||||
// 填充模式:不裁剪,保持原尺寸
|
||||
if ("fill".equals(cropMode)) {
|
||||
cropParams.put("X", 0);
|
||||
cropParams.put("Y", 0);
|
||||
cropParams.put("Width", sourceWidth);
|
||||
cropParams.put("Height", sourceHeight);
|
||||
log.debug("[裁剪计算] 源尺寸={}x{}, 模式=fill, 裁剪参数={}", sourceWidth, sourceHeight, cropParams);
|
||||
return cropParams;
|
||||
}
|
||||
|
||||
// center/smart模式:居中裁剪(smart暂未开放,降级为center)
|
||||
if ("smart".equals(cropMode)) {
|
||||
log.info("[裁剪模式] smart模式暂未开放,自动降级为center模式");
|
||||
}
|
||||
|
||||
double targetRatio = 9.0 / 16.0; // 9:16竖屏比例
|
||||
double cropHeight = sourceHeight;
|
||||
double cropWidth = cropHeight * targetRatio;
|
||||
int cropX = (int) Math.round((sourceWidth - cropWidth) / 2);
|
||||
|
||||
cropParams.put("X", cropX);
|
||||
cropParams.put("Y", 0);
|
||||
cropParams.put("Width", (int) Math.round(cropWidth));
|
||||
cropParams.put("Height", (int) Math.round(cropHeight));
|
||||
|
||||
log.debug("[裁剪计算] 源尺寸={}x{}, 模式={}, 裁剪参数={}", sourceWidth, sourceHeight, cropMode, cropParams);
|
||||
return cropParams;
|
||||
}
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
* 新版 API(核心方法)
|
||||
* ════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* 生成单个视频(支持随机截取起始点)
|
||||
*
|
||||
* 多视频差异化原理:
|
||||
* - 每个视频使用随机截取起点,确保内容完全不同
|
||||
* - 支持不同长度的素材,ICE自动容错处理
|
||||
* - 容错机制:如果起点超出素材长度,从0开始截取
|
||||
*
|
||||
* @param materials 素材列表(包含fileUrl和duration)
|
||||
* @param videoIndex 视频序号(0开始),用于生成随机种子
|
||||
* @param userId 用户ID
|
||||
* @param cropMode 裁剪模式:center(居中裁剪)、smart(智能裁剪)、fill(填充模式)
|
||||
* @return jobId : outputUrl 格式字符串
|
||||
* @param materials 素材列表
|
||||
* @param videoIndex 视频序号(用于随机种子)
|
||||
* @param userId 用户 ID
|
||||
* @param cropMode 裁剪模式
|
||||
* @return "jobId : cdnUrl" 格式字符串
|
||||
*/
|
||||
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();
|
||||
String videoUrl = properties.convertToOssUrl(material.getFileUrl());
|
||||
material.setFileUrl(videoUrl);
|
||||
int duration = material.getDuration();
|
||||
|
||||
// 将CDN URL转换为OSS原始URL(ICE需要原始域名)
|
||||
videoUrl = properties.convertToOssUrl(videoUrl);
|
||||
material.setFileUrl(videoUrl); // 更新material中的URL
|
||||
|
||||
// 计算随机截取起点
|
||||
// 优先使用前端传入的素材实际时长,无则从0开始截取(兜底)
|
||||
// 计算随机截取区间
|
||||
Integer fileDuration = material.getFileDuration();
|
||||
int startOffset = 0;
|
||||
int endOffset = duration;
|
||||
int actualDuration = duration; // 实际截取时长
|
||||
int startOffset = 0, endOffset = duration, actualDuration = duration;
|
||||
|
||||
if (fileDuration != null && fileDuration >= duration) {
|
||||
// 有实际时长且足够:随机起点范围 0 到 (实际时长 - 截取时长)
|
||||
long randomSeed = ((material.getFileId() != null ? material.getFileId() : i) * 1000000L) +
|
||||
(videoIndex * 10000L) + (material.getFileUrl().hashCode() % 1000);
|
||||
long randomSeed = ((material.getFileId() != null ? material.getFileId() : i) * 1000000L)
|
||||
+ (videoIndex * 10000L) + (videoUrl.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 if (fileDuration != null && fileDuration < duration) {
|
||||
// 素材时长不足:使用素材全部时长,避免超出范围
|
||||
material.getFileId(), fileDuration, startOffset, endOffset);
|
||||
} else if (fileDuration != null) {
|
||||
actualDuration = fileDuration;
|
||||
endOffset = fileDuration;
|
||||
log.warn("[ICE][素材时长不足] fileId={}, 请求{}s, 实际{}s, 将使用全部时长",
|
||||
material.getFileId(), duration, fileDuration);
|
||||
log.warn("[ICE][素材时长不足] fileId={}, 请求{}s, 实际{}s",
|
||||
material.getFileId(), duration, fileDuration);
|
||||
} else {
|
||||
// 无时长信息:从0开始截取(兜底,ICE可能会失败)
|
||||
log.warn("[ICE][无时长信息] fileId={}, In=0, Out={}, ICE可能会失败",
|
||||
material.getFileId(), duration);
|
||||
log.warn("[ICE][无时长信息] fileId={}, In=0, Out={}",
|
||||
material.getFileId(), duration);
|
||||
}
|
||||
|
||||
log.debug("[ICE][添加视频片段][视频{}: {}, In={}, Out={}, TimelineIn={}, TimelineOut={}]",
|
||||
videoIndex + 1, videoUrl, startOffset, endOffset, timelinePos, timelinePos + actualDuration);
|
||||
videoIndex + 1, videoUrl, startOffset, endOffset, timelinePos, timelinePos + actualDuration);
|
||||
|
||||
// 构建视频片段(带 In/Out 参数)
|
||||
// 构建视频片段
|
||||
JSONObject videoClip = new JSONObject();
|
||||
videoClip.put("MediaURL", videoUrl);
|
||||
videoClip.put("In", startOffset);
|
||||
@@ -318,15 +177,9 @@ public class BatchProduceAlignment {
|
||||
videoClip.put("TimelineIn", timelinePos);
|
||||
videoClip.put("TimelineOut", timelinePos + actualDuration);
|
||||
|
||||
// 添加裁剪效果(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);
|
||||
|
||||
Map<String, Integer> cropParams = calculateCropParams(1920, 1080, cropMode);
|
||||
JSONArray effects = new JSONArray();
|
||||
JSONObject cropEffect = new JSONObject();
|
||||
cropEffect.put("Type", "Crop");
|
||||
@@ -335,14 +188,12 @@ public class BatchProduceAlignment {
|
||||
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);
|
||||
@@ -352,7 +203,7 @@ public class BatchProduceAlignment {
|
||||
audioClip.put("Effects", new JSONArray() {{
|
||||
add(new JSONObject() {{
|
||||
put("Type", "Volume");
|
||||
put("Gain", 0); // 静音
|
||||
put("Gain", 0);
|
||||
}});
|
||||
}});
|
||||
audioClipArray.add(audioClip);
|
||||
@@ -360,38 +211,80 @@ public class BatchProduceAlignment {
|
||||
timelinePos += actualDuration;
|
||||
}
|
||||
|
||||
// 构建时间线
|
||||
String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":" + videoClipArray.toJSONString() +
|
||||
"}],\"AudioTracks\":[{\"AudioTrackClips\":" + audioClipArray.toJSONString() + "}]}";
|
||||
// 构建 Timeline
|
||||
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";
|
||||
|
||||
// ICE写入必须使用OSS原始域名(不能是CDN域名,因为ICE需要写权限)
|
||||
// 输出配置
|
||||
String outputMediaPath = buildOutputPath(userId);
|
||||
String outputMediaUrl = properties.getOssWriteUrl(outputMediaPath);
|
||||
|
||||
int width = 720;
|
||||
int height = 1280;
|
||||
int bitrate = 2000;
|
||||
String outputMediaConfig = "{\"MediaURL\":\"" + outputMediaUrl + "\",\"Width\":" + width +
|
||||
",\"Height\":" + height + ",\"Bitrate\":" + bitrate + "}";
|
||||
int width = 720, height = 1280, 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);
|
||||
videoIndex, materials.size(), (int) timelinePos);
|
||||
String jobId = iceClient.submitJob(request);
|
||||
log.info("[ICE][任务提交成功][videoIndex={}, jobId={}]", videoIndex, jobId);
|
||||
|
||||
String jobId = response.getBody().getJobId();
|
||||
log.info("[ICE][任务提交成功][videoIndex={}, jobId={}, outputUrl={}]", videoIndex, jobId, outputMediaUrl);
|
||||
// 返回CDN URL用于存储到数据库(ICE写入用OSS原始URL,存储用CDN URL)
|
||||
String cdnUrl = properties.getCdnStorageUrl(outputMediaPath);
|
||||
return jobId + " : " + cdnUrl;
|
||||
}
|
||||
|
||||
}
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
* 工具方法
|
||||
* ════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
public String checkJobStatus(String jobId) throws Exception {
|
||||
return iceClient.getJobStatus(jobId);
|
||||
}
|
||||
|
||||
public String extractOutputUrl(String jobIdWithUrl) {
|
||||
if (jobIdWithUrl == null || !jobIdWithUrl.contains(" : ")) {
|
||||
return null;
|
||||
}
|
||||
return jobIdWithUrl.split(" : ")[1];
|
||||
}
|
||||
|
||||
private String buildOutputPath(Long userId) {
|
||||
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"));
|
||||
return mixDirectory + "/" + dateDir + "/" + targetFileName + ".mp4";
|
||||
}
|
||||
|
||||
private Map<String, Integer> calculateCropParams(int sourceWidth, int sourceHeight, String cropMode) {
|
||||
Map<String, Integer> cropParams = new HashMap<>();
|
||||
|
||||
if ("fill".equals(cropMode)) {
|
||||
cropParams.put("X", 0);
|
||||
cropParams.put("Y", 0);
|
||||
cropParams.put("Width", sourceWidth);
|
||||
cropParams.put("Height", sourceHeight);
|
||||
return cropParams;
|
||||
}
|
||||
|
||||
if ("smart".equals(cropMode)) {
|
||||
log.info("[裁剪模式] smart 模式暂未开放,降级为 center");
|
||||
}
|
||||
|
||||
double targetRatio = 9.0 / 16.0;
|
||||
double cropHeight = sourceHeight;
|
||||
double cropWidth = cropHeight * targetRatio;
|
||||
int cropX = (int) Math.round((sourceWidth - cropWidth) / 2);
|
||||
|
||||
cropParams.put("X", cropX);
|
||||
cropParams.put("Y", 0);
|
||||
cropParams.put("Width", (int) Math.round(cropWidth));
|
||||
cropParams.put("Height", (int) Math.round(cropHeight));
|
||||
|
||||
log.debug("[裁剪计算] 源尺寸={}x{}, 模式={}, 参数={}", sourceWidth, sourceHeight, cropMode, cropParams);
|
||||
return cropParams;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +1,161 @@
|
||||
package cn.iocoder.yudao.module.tik.mix.client;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.module.tik.mix.config.IceProperties;
|
||||
import com.aliyun.ice20201109.Client;
|
||||
import com.aliyun.ice20201109.models.*;
|
||||
import com.aliyun.teaopenapi.models.Config;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception0;
|
||||
import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.VOICE_TTS_FAILED;
|
||||
|
||||
/**
|
||||
* 阿里云 ICE 客户端
|
||||
* 阿里云 ICE 客户端 —— 项目中 ICE SDK 的唯一封装入口
|
||||
* <p>
|
||||
* 设计原则:
|
||||
* <ul>
|
||||
* <li>单一入口:所有 ICE API 调用必须经过此类,禁止各业务类自行创建 {@link Client}</li>
|
||||
* <li>统一超时:通过 {@link IceProperties} 集中管理连接/读取超时</li>
|
||||
* <li>懒加载 + 线程安全:DCL 保证全局唯一 Client 实例</li>
|
||||
* <li>统一异常转换:ICE SDK 异常统一转为 {@link cn.iocoder.yudao.framework.common.exception.ServiceException}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class IceClient {
|
||||
|
||||
private final IceProperties properties;
|
||||
|
||||
private volatile Client iceClient;
|
||||
private volatile Client client;
|
||||
|
||||
/**
|
||||
* 获取 ICE 客户端(懒加载,线程安全)
|
||||
*/
|
||||
public Client getClient() {
|
||||
if (iceClient == null) {
|
||||
public IceClient(IceProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
* Client 生命周期管理
|
||||
* ════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
private Client getClient() {
|
||||
if (client == null) {
|
||||
synchronized (this) {
|
||||
if (iceClient == null) {
|
||||
if (client == null) {
|
||||
if (!properties.isEnabled()) {
|
||||
throw exception0(VOICE_TTS_FAILED.getCode(), "未配置 ICE AccessKey");
|
||||
}
|
||||
iceClient = createClient();
|
||||
client = createClient();
|
||||
}
|
||||
}
|
||||
}
|
||||
return iceClient;
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 ICE 客户端
|
||||
*/
|
||||
private Client createClient() {
|
||||
try {
|
||||
log.info("[ICE][开始初始化客户端][regionId={}, bucket={}]",
|
||||
properties.getRegionId(), properties.getBucket());
|
||||
log.info("[ICE][初始化] regionId={}, bucket={}, connectTimeout={}, readTimeout={}",
|
||||
properties.getRegionId(), properties.getBucket(),
|
||||
properties.getConnectTimeout(), properties.getReadTimeout());
|
||||
|
||||
Config config = new Config();
|
||||
config.accessKeyId = properties.getAccessKeyId();
|
||||
config.accessKeySecret = properties.getAccessKeySecret();
|
||||
config.endpoint = "ice." + properties.getRegionId() + ".aliyuncs.com";
|
||||
config.regionId = properties.getRegionId();
|
||||
config.connectTimeout = (int) properties.getConnectTimeout().toMillis();
|
||||
config.readTimeout = (int) properties.getReadTimeout().toMillis();
|
||||
|
||||
Client client = new Client(config);
|
||||
|
||||
log.info("[ICE][客户端初始化成功]");
|
||||
return client;
|
||||
Client c = new Client(config);
|
||||
log.info("[ICE][初始化成功]");
|
||||
return c;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[ICE][客户端初始化失败]", e);
|
||||
log.error("[ICE][初始化失败]", e);
|
||||
throw exception0(VOICE_TTS_FAILED.getCode(), "ICE 客户端初始化失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
* 业务 API
|
||||
* ════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* 提交媒体制作任务
|
||||
* 提交媒体制作任务(底层方法,接受已构建好的 Request)
|
||||
* <p>
|
||||
* 供 {@link cn.iocoder.yudao.module.tik.media.BatchProduceAlignment} 等
|
||||
* 需要自定义 Timeline 构建逻辑的上层组件调用。
|
||||
*
|
||||
* @param request 已填充 timeline 和 outputMediaConfig 的请求对象
|
||||
* @return ICE 任务 ID(jobId)
|
||||
*/
|
||||
public String submitMediaProducingJob(String timeline, String outputMediaConfig) {
|
||||
public String submitJob(SubmitMediaProducingJobRequest request) {
|
||||
try {
|
||||
Client client = getClient();
|
||||
SubmitMediaProducingJobRequest request = new SubmitMediaProducingJobRequest();
|
||||
request.setTimeline(timeline);
|
||||
request.setOutputMediaConfig(outputMediaConfig);
|
||||
|
||||
SubmitMediaProducingJobResponse response = client.submitMediaProducingJob(request);
|
||||
SubmitMediaProducingJobResponse response = getClient().submitMediaProducingJob(request);
|
||||
String jobId = response.getBody().getJobId();
|
||||
|
||||
log.info("[ICE][提交任务成功][jobId={}]", jobId);
|
||||
log.info("[ICE][提交成功] jobId={}", jobId);
|
||||
return jobId;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[ICE][提交任务失败]", e);
|
||||
log.error("[ICE][提交失败]", e);
|
||||
throw exception0(VOICE_TTS_FAILED.getCode(), "ICE 提交任务失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询媒体制作任务状态
|
||||
* 提交媒体制作任务(便捷方法,接受字符串参数)
|
||||
*
|
||||
* @param timeline 时间线 JSON
|
||||
* @param outputMediaConfig 输出媒体配置 JSON
|
||||
* @return ICE 任务 ID(jobId)
|
||||
*/
|
||||
public String getMediaProducingJobStatus(String jobId) {
|
||||
public String submitJob(String timeline, String outputMediaConfig) {
|
||||
SubmitMediaProducingJobRequest request = new SubmitMediaProducingJobRequest();
|
||||
request.setTimeline(timeline);
|
||||
request.setOutputMediaConfig(outputMediaConfig);
|
||||
return submitJob(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询媒体制作任务状态
|
||||
*
|
||||
* @param jobId ICE 任务 ID
|
||||
* @return 任务状态:Success / Failed / Running / Pending 等
|
||||
*/
|
||||
public String getJobStatus(String jobId) {
|
||||
try {
|
||||
Client client = getClient();
|
||||
GetMediaProducingJobRequest request = new GetMediaProducingJobRequest();
|
||||
request.setJobId(jobId);
|
||||
|
||||
GetMediaProducingJobResponse response = client.getMediaProducingJob(request);
|
||||
GetMediaProducingJobResponse response = getClient().getMediaProducingJob(request);
|
||||
String status = response.getBody().getMediaProducingJob().getStatus();
|
||||
|
||||
log.debug("[ICE][查询任务状态][jobId={}, status={}]", jobId, status);
|
||||
log.debug("[ICE][状态查询] jobId={}, status={}", jobId, status);
|
||||
return status;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[ICE][查询任务状态失败][jobId={}]", jobId, e);
|
||||
log.error("[ICE][状态查询失败] jobId={}", jobId, e);
|
||||
throw exception0(VOICE_TTS_FAILED.getCode(), "ICE 查询任务状态失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
* 历史兼容方法(逐步废弃)
|
||||
* ════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* @deprecated 请使用 {@link #submitJob(String, String)}
|
||||
*/
|
||||
@Deprecated
|
||||
public String submitMediaProducingJob(String timeline, String outputMediaConfig) {
|
||||
return submitJob(timeline, outputMediaConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 请使用 {@link #getJobStatus(String)}
|
||||
*/
|
||||
@Deprecated
|
||||
public String getMediaProducingJobStatus(String jobId) {
|
||||
return getJobStatus(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,7 +384,7 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
|
||||
for (String jobId : jobIds) {
|
||||
try {
|
||||
String status = iceClient.getMediaProducingJobStatus(jobId);
|
||||
String status = iceClient.getJobStatus(jobId);
|
||||
log.debug("[MixTask][jobId状态] taskId={}, jobId={}, status={}", taskId, jobId, status);
|
||||
if ("Success".equalsIgnoreCase(status)) {
|
||||
successJobIds.add(jobId);
|
||||
|
||||
Reference in New Issue
Block a user