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:
2026-06-03 22:19:03 +08:00
parent 248deeea0d
commit 3a3638295b
3 changed files with 217 additions and 277 deletions

View File

@@ -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;
}
/* ════════════════════════════════════════════════════════════════════════════
* 旧版 API兼容保留
* ════════════════════════════════════════════════════════════════════════════ */
public List<String> batchProduceAlignment(String title, String[] videoArray, int produceCount, Long userId) throws Exception {
// 初始化 ICE 客户端
if (iceClient == null) {
initClient();
}
// 批量提交任务,返回 "jobId:url" 格式
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原始URLICE需要原始域名
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原始URLICE需要原始域名
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) {
// 素材时长不足:使用素材全部时长,避免超出范围
} else if (fileDuration != null) {
actualDuration = fileDuration;
endOffset = fileDuration;
log.warn("[ICE][素材时长不足] fileId={}, 请求{}s, 实际{}s, 将使用全部时长",
log.warn("[ICE][素材时长不足] fileId={}, 请求{}s, 实际{}s",
material.getFileId(), duration, fileDuration);
} else {
// 无时长信息从0开始截取兜底ICE可能会失败
log.warn("[ICE][无时长信息] fileId={}, In=0, Out={}, ICE可能会失败",
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);
// 构建视频片段(带 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竖屏输出
// 假设源素材为1920x108016: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;
}
}

View File

@@ -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 任务 IDjobId
*/
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 任务 IDjobId
*/
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);
}
}

View File

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