diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 33373d5561..9599f061e1 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -9,7 +9,8 @@
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git log:*)",
- "Bash(xargs:*)"
+ "Bash(xargs:*)",
+ "Bash(test:*)"
],
"deny": [],
"ask": []
diff --git a/docs/ICE_SubmitMediaProducingJob_API.md b/docs/ICE_SubmitMediaProducingJob_API.md
new file mode 100644
index 0000000000..39eb690055
--- /dev/null
+++ b/docs/ICE_SubmitMediaProducingJob_API.md
@@ -0,0 +1,365 @@
+# 阿里云智能媒体服务 - SubmitMediaProducingJob API文档
+
+## 📋 API概述
+
+**接口名称:** SubmitMediaProducingJob(提交剪辑合成作业)
+
+**服务名称:** Intelligent Media Services (IMS) - 智能媒体服务
+
+**API版本:** 2020-11-09
+
+### 业务说明
+
+`SubmitMediaProducingJob` 是阿里云智能媒体服务的核心API接口,主要用于**提交媒体剪辑合成任务**。当用户需要对视频或音频素材进行剪辑、合成、添加特效、转码等后期制作时,可以通过调用此接口自动完成这些复杂的媒体处理工作。
+
+#### 核心业务场景
+
+1. **视频剪辑制作**
+ - 多个视频片段的拼接合成
+ - 添加转场效果和过渡动画
+ - 视频片段的裁剪和缩放
+
+2. **音视频处理**
+ - 音频与视频的同步合成
+ - 添加背景音乐和音效
+ - 音频混合和音量调节
+
+3. **多轨道编辑**
+ - 支持视频轨道、音频轨道、字幕轨道
+ - 实现复杂的多层编辑效果
+ - 视频叠加和水印添加
+
+4. **模板化制作**
+ - 使用预定义模板快速生成视频
+ - 批量内容生产
+ - 统一风格的视频输出
+
+5. **云端转码**
+ - 视频格式转换(MP4、AVI、MOV等)
+ - 分辨率和码率调整
+ - 自适应码率输出
+
+---
+
+## 🔐 授权信息
+
+| 操作名 | 访问级别 | 资源类型 | 条件键 | 关联操作 |
+|--------|----------|----------|--------|----------|
+| ice:SubmitMediaProducingJob | 写权限 | 所有资源 (`*`) | 无 | 无 |
+
+---
+
+## 📡 接口调用
+
+**请求方法:** POST
+
+**调用地址:** `https://ims.ap-southeast-1.aliyuncs.com/`
+
+**请求路径:** `/2020-11-09/submitMediaProducingJob`
+
+### ⚠️ 重要说明
+
+- 此接口仅返回作业**提交结果**,作业提交后将在后台异步处理
+- 时间线中引用的素材可以是媒体库中的资产或OSS对象
+- **不支持**外部URL或CDN URL
+- 生产完成后,输出文件会自动注册为媒体资产
+- 需要先分析媒体资产,才能查询时长和分辨率信息
+
+---
+
+## 🔒 调用限制
+
+| 限制项 | 限制值 | 说明 |
+|--------|--------|------|
+| **QPS限制** | 30次/秒 | 超出限制会返回"Throttling.User"错误 |
+| **视频轨道** | 最多100个 | 每个项目最多可创建100条视频轨道 |
+| **图片轨道** | 最多100个 | 每个项目最多可创建100条图片轨道 |
+| **字幕轨道** | 最多100个 | 每个项目最多可创建100条字幕轨道 |
+| **素材总大小** | 不超过1TB | 项目中所有素材文件的总大小限制 |
+| **输出分辨率** | 128px - 4096px | 宽度和高度都必须在128-4096像素之间 |
+| **视频短边** | 不超过2160px | 视频的短边不能超过2160像素 |
+| **区域限制** | 同一区域 | 素材和输出的OSS桶必须与IMS服务区域一致 |
+
+---
+
+## 📝 请求参数
+
+### 主要参数说明
+
+| 参数名 | 类型 | 必填 | 描述 | 示例值 |
+|--------|------|------|------|--------|
+| **ProjectId** | string | 否 | 编辑项目的ID | `xxxxxfb2101cb318xxxxx` |
+| **Timeline** | string | 否 | 在线编辑作业的时间线配置 | 详见时间线配置 |
+| **TemplateId** | string | 否 | 模板ID(使用模板快速构建时间线) | `****96e8864746a0b6f3****` |
+| **ClipsParam** | string | 否 | 模板素材参数(JSON格式) | - |
+| **ProjectMetadata** | string | 否 | 编辑项目的元数据(JSON格式) | - |
+| **OutputMediaTarget** | string | 否 | 输出文件类型:`oss-object`/`vod-media`/`S3` | `oss-object` |
+| **OutputMediaConfig** | **Yes** | **是** | 输出文件配置(JSON格式) | 详见配置说明 |
+| **UserData** | string | 否 | 用户自定义数据(最多512字节) | `{"NotifyAddress":"https://..."}` |
+| **ClientToken** | string | 否 | 客户端令牌(确保请求幂等性) | `****12e8864746a0a398****` |
+| **Source** | string | 否 | 请求来源:`OpenAPI`/`AliyunConsole`/`WebSDK` | `OPENAPI` |
+| **EditingProduceConfig** | string | 否 | 编辑制作参数 | 详见配置说明 |
+| **MediaMetadata** | string | 否 | 产出视频的元数据 | `{"Title":"test-title"}` |
+
+### 参数组合规则
+
+**三选一参数:** `ProjectId`、`Timeline`、`TemplateId` 中必须指定一个,其余两个必须为空。
+
+- 如果指定 `ProjectId`:使用现有编辑项目
+- 如果指定 `Timeline`:直接定义时间线
+- 如果指定 `TemplateId`:必须同时指定 `ClipsParam`
+
+---
+
+## 💾 输出配置示例
+
+### 示例1:输出到OSS
+
+```json
+{
+ "MediaURL": "https://my-test-bucket.oss-cn-shanghai.aliyuncs.com/test/xxxxxtest001xxxxx.mp4",
+ "Bitrate": 2000,
+ "Width": 800,
+ "Height": 680
+}
+```
+
+**配置说明:**
+- `MediaURL`:OSS对象URL,格式为 `https://bucketname.oss-region-name.aliyuncs.com/xxx/yyy.ext`
+- `Bitrate`:输出码率(Kbit/s),越高视频越清晰,最大值为5000
+- `Width`/`Height`:输出分辨率,留空则使用输入素材的最大分辨率
+
+### 示例2:输出到ApsaraVideo VOD
+
+```json
+{
+ "StorageLocation": "outin-*xxxxxx7d2a3811eb83da00163exxxxxx.oss-cn-shanghai.aliyuncs.com",
+ "FileName": "output.mp4",
+ "Bitrate": 2000,
+ "Width": 800,
+ "Height": 680,
+ "VodTemplateGroupId": "VOD_NO_TRANSCODE"
+}
+```
+
+**配置说明:**
+- `StorageLocation`:VOD中的存储位置(不含http://前缀)
+- `FileName`:输出文件名(包含扩展名)
+- `VodTemplateGroupId`:VOD转码模板组ID,设为`VOD_NO_TRANSCODE`表示不转码
+
+### OutputMediaConfig 参数详解
+
+| 参数名 | 类型 | 说明 |
+|--------|------|------|
+| MediaURL | String | 输出文件URL(oss-object类型) |
+| StorageLocation | String | VOD存储位置(vod-media类型) |
+| FileName | String | 输出文件名(vod-media类型) |
+| Width | Integer | 输出宽度(默认:输入素材最大宽度) |
+| Height | Integer | 输出高度(默认:输入素材最大高度) |
+| Bitrate | Integer | 输出码率(默认:输入素材最大码率) |
+| VodTemplateGroupId | String | VOD转码模板组ID |
+
+---
+
+## 📤 响应参数
+
+| 参数名 | 类型 | 描述 | 示例值 |
+|--------|------|------|--------|
+| RequestId | string | 请求ID(唯一标识) | `****36-3C1E-4417-BDB2-1E034F****` |
+| ProjectId | string | 编辑项目ID | `****b4549d46c88681030f6e****` |
+| **JobId** | string | **作业ID(用于查询作业状态)** | `****d80e4e4044975745c14b****` |
+| MediaId | string | 输出文件的媒体资产ID | `****c469e944b5a856828dc2****` |
+| VodMediaId | string | 输出文件在VOD中的媒体资产ID(如适用) | `****d8s4h75ci975745c14b****` |
+
+### 响应示例
+
+```json
+{
+ "RequestId": "****36-3C1E-4417-BDB2-1E034F****",
+ "ProjectId": "****b4549d46c88681030f6e****",
+ "JobId": "****d80e4e4044975745c14b****",
+ "MediaId": "****c469e944b5a856828dc2****",
+ "VodMediaId": "****d8s4h75ci975745c14b****"
+}
+```
+
+---
+
+## 🔧 EditingProduceConfig 配置
+
+用于控制编辑制作过程的参数。
+
+```json
+{
+ "AutoRegisterInputVodMedia": "true",
+ "OutputWebmTransparentChannel": "true",
+ "CoverConfig": {
+ "CustomThumbnail": "https://example.com/thumb.jpg"
+ }
+}
+```
+
+**参数说明:**
+- `AutoRegisterInputVodMedia`:是否自动注册时间线中的VOD媒体资产到IMS,默认 `true`
+- `OutputWebmTransparentChannel`:输出视频是否包含Alpha通道(透明度),默认 `false`
+- `CoverConfig`:自定义缩略图配置
+
+---
+
+## 🚨 错误码
+
+| HTTP状态码 | 错误码 | 错误消息 |
+|------------|--------|----------|
+| 400 | InvalidParameter | 参数不合法 |
+| 404 | ProjectNotFound | 指定的项目不存在 |
+| 429 | Throttling.User | 请求频率超过限制(30 QPS) |
+
+---
+
+## 💡 典型使用场景
+
+### 场景1:视频片段拼接
+
+```json
+{
+ "Timeline": {
+ "VideoTracks": [
+ {
+ "VideoTrackClips": [
+ {"MediaId": "****4d7cf14dc7b83b0e801c****"},
+ {"MediaId": "****4d7cf14dc7b83b0e801c****"}
+ ]
+ }
+ ]
+ },
+ "OutputMediaConfig": {
+ "MediaURL": "https://my-bucket.oss-cn-shanghai.aliyuncs.com/output.mp4",
+ "Bitrate": 2000
+ }
+}
+```
+
+**业务场景:** 将两个视频片段无缝拼接成一个完整视频
+
+### 场景2:模板化视频生产
+
+```json
+{
+ "TemplateId": "****template-id****",
+ "ClipsParam": {
+ "clips": [
+ {"MediaId": "****video1****"},
+ {"MediaId": "****video2****"}
+ ]
+ },
+ "OutputMediaConfig": {
+ "MediaURL": "https://my-bucket.oss-cn-shanghai.aliyuncs.com/template-output.mp4"
+ }
+}
+```
+
+**业务场景:** 使用预定义模板快速生成风格统一的视频内容
+
+### 场景3:视频转码并上传VOD
+
+```json
+{
+ "Timeline": {
+ "VideoTracks": [
+ {
+ "VideoTrackClips": [
+ {"MediaId": "****source-video****"}
+ ]
+ }
+ ]
+ },
+ "OutputMediaTarget": "vod-media",
+ "OutputMediaConfig": {
+ "StorageLocation": "outin-xxxxx.oss-cn-shanghai.aliyuncs.com",
+ "FileName": "transcoded-video.mp4",
+ "Bitrate": 1500,
+ "Width": 1920,
+ "Height": 1080
+ },
+ "UserData": {
+ "NotifyAddress": "https://your-callback-url.com"
+ }
+}
+```
+
+**业务场景:** 将视频转码为不同分辨率和码率,并直接上传到VOD系统
+
+---
+
+## 📊 相关API
+
+- **GetMediaProducingJob**:查询媒体剪辑合成作业状态
+- **CancelMediaProducingJob**:取消媒体剪辑合成作业
+- **CreateEditingProject**:创建编辑项目
+
+---
+
+## 🔗 相关文档
+
+- [时间线配置说明](https://www.alibabacloud.com/help/en/ims/developer-reference/timeline-configuration-description)
+- [编辑制作参数说明](https://www.alibabacloud.com/help/en/ims/developer-reference/clip-composition-parameter-description)
+- [模板创建和使用](https://www.alibabacloud.com/help/en/ims/user-guide/create-and-use-a-normal-template)
+- [回调配置](https://www.alibabacloud.com/help/en/ims/use-cases/to-configure-a-callback-when-a-clip-completes)
+- [常见问题FAQ](https://www.alibabacloud.com/help/en/ims/support/intelligent-production-making-faq)
+
+---
+
+## 📌 注意事项
+
+1. **异步处理:** 作业提交后立即返回,任务在后台异步执行
+2. **费用说明:** 按实际处理时长和输出文件大小计费
+3. **配额管理:** 建议使用 `ClientToken` 确保请求幂等性
+4. **回调通知:** 通过 `UserData.NotifyAddress` 设置完成回调通知
+5. **文件大小:** 单次处理的文件总大小建议不超过1GB,超过建议分段处理
+6. **格式支持:** 支持主流视频/音频格式(MP4、AVI、MOV、MP3、AAC等)
+7. **转码速度:** 处理速度取决于输出质量设置,高质量处理时间较长
+
+---
+
+## 🎯 最佳实践
+
+### 1. 幂等性保证
+```javascript
+// 使用ClientToken确保同一请求不会被重复处理
+const clientToken = generateUUID();
+await submitMediaProducingJob({
+ ClientToken: clientToken,
+ // ... 其他参数
+});
+```
+
+### 2. 状态轮询
+```javascript
+// 提交作业后,使用JobId轮询查询状态
+const jobId = response.JobId;
+const status = await getMediaProducingJob({ JobId: jobId });
+```
+
+### 3. 错误重试
+```javascript
+// 针对网络错误或限流错误进行指数退避重试
+try {
+ await submitMediaProducingJob(params);
+} catch (error) {
+ if (error.code === 'Throttling.User') {
+ // 等待后重试
+ await sleep(1000);
+ await submitMediaProducingJob(params);
+ }
+}
+```
+
+### 4. 资源清理
+```javascript
+// 处理完成后,及时清理不必要的中间文件
+await deleteMediaAssets([tempMediaId1, tempMediaId2]);
+```
+
+---
+
+*文档版本:v1.0* | *最后更新:2025-11-29*
diff --git a/frontend/app/web-gold/src/components/SidebarNav.vue b/frontend/app/web-gold/src/components/SidebarNav.vue
index fdd241a94f..c5ae09f6cf 100644
--- a/frontend/app/web-gold/src/components/SidebarNav.vue
+++ b/frontend/app/web-gold/src/components/SidebarNav.vue
@@ -16,7 +16,8 @@ const icons = {
wave: '',
user: '',
video: '',
- folder: ''
+ folder: '',
+ scissors: ''
}
const items = computed(() => {
@@ -42,6 +43,7 @@ const items = computed(() => {
title: '素材库',
children: [
{ path: '/material/list', label: '素材列表', icon: 'grid' },
+ { path: '/material/mix-task', label: '混剪任务', icon: 'scissors' },
{ path: '/material/group', label: '素材分组', icon: 'folder' },
]
},
diff --git a/frontend/app/web-gold/src/views/material/MixTaskList.vue b/frontend/app/web-gold/src/views/material/MixTaskList.vue
index 9eff0560d5..da61df540e 100644
--- a/frontend/app/web-gold/src/views/material/MixTaskList.vue
+++ b/frontend/app/web-gold/src/views/material/MixTaskList.vue
@@ -58,126 +58,152 @@
-
-
-
-
@@ -431,93 +525,47 @@ onUnmounted(() => {
.mix-task-list__content {
flex: 1;
- overflow-y: auto;
- margin-bottom: 24px;
-}
-
-.task-list {
- display: flex;
- flex-direction: column;
- gap: 16px;
-}
-
-.task-item {
+ overflow: auto;
background: var(--color-surface);
- border: 1px solid var(--color-border);
border-radius: var(--radius-card);
+ border: 1px solid var(--color-border);
padding: 16px;
}
-.task-item__header {
+.title-cell {
display: flex;
- justify-content: space-between;
align-items: center;
+ flex-wrap: wrap;
+}
+
+.expanded-content {
+ padding: 16px;
+ background: var(--color-bg-2);
+ border-radius: var(--radius-card);
+ margin: 8px;
+}
+
+.task-text {
margin-bottom: 16px;
- padding-bottom: 12px;
- border-bottom: 1px solid var(--color-border);
}
-.task-item__title {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-
-.task-item__title h3 {
- margin: 0;
- font-size: 18px;
- font-weight: 600;
-}
-
-.task-item__actions {
- display: flex;
- gap: 8px;
-}
-
-.task-item__content {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.task-item__progress {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.progress-info {
- font-size: 13px;
- color: var(--color-text-2);
-}
-
-.task-item__meta {
- display: flex;
- gap: 16px;
- font-size: 13px;
- color: var(--color-text-3);
-}
-
-.task-item__text {
- font-size: 14px;
- color: var(--color-text-2);
+.task-text p {
+ margin: 8px 0 0 0;
+ padding: 8px;
+ background: var(--color-surface);
+ border-radius: var(--radius-card);
line-height: 1.6;
}
-.task-item__text p {
- margin: 0;
-}
-
-.task-item__results h4 {
- margin: 0 0 8px 0;
- font-size: 14px;
- font-weight: 500;
+.task-results {
+ margin-bottom: 16px;
}
.result-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
+ margin-top: 8px;
}
.result-item {
@@ -525,13 +573,17 @@ onUnmounted(() => {
align-items: center;
gap: 8px;
padding: 6px 12px;
- background: var(--color-bg-2);
+ background: var(--color-surface);
border-radius: var(--radius-card);
font-size: 13px;
}
-.mix-task-list__pagination {
- display: flex;
- justify-content: center;
+.task-error {
+ margin-bottom: 8px;
+}
+
+/* 确保按钮内的图标和文字对齐 */
+:deep(.ant-btn .anticon) {
+ vertical-align: middle;
}
diff --git a/frontend/app/web-gold/src/views/material/MixTaskList.vue.bak b/frontend/app/web-gold/src/views/material/MixTaskList.vue.bak
new file mode 100644
index 0000000000..516649f3d2
--- /dev/null
+++ b/frontend/app/web-gold/src/views/material/MixTaskList.vue.bak
@@ -0,0 +1,532 @@
+
+
+
+
+
+
+
+
+ 全部状态
+ 待处理
+ 处理中
+ 已完成
+ 失败
+
+
+
+
+
+
+
+
+
+
+ 查询
+ 重置
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 进度:{{ task.progress }}%
+
+
+
+
+
+ 创建时间:{{ formatDate(task.createTime) }}
+
+ 完成时间:{{ formatDate(task.finishTime) }}
+
+
+
+
+
+
+
生成结果 ({{ task.outputUrls.length }}):
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignment.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignment.java
index c4ebc48f19..c3bcc54230 100644
--- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignment.java
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignment.java
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.tik.media;
+import cn.iocoder.yudao.module.infra.api.file.FileApi;
import cn.iocoder.yudao.module.tik.mix.config.IceProperties;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
@@ -34,6 +35,7 @@ import java.util.*;
public class BatchProduceAlignment {
private final IceProperties properties;
+ private final FileApi fileApi;
private Client iceClient;
public void initClient() throws Exception {
@@ -105,8 +107,9 @@ public class BatchProduceAlignment {
initClient();
}
- // 纯画面模式:仅拼接视频片段,添加空的音频轨道
+ // 纯画面模式:仅拼接视频片段,为每个视频添加静音音频轨道
JSONArray videoClipArray = new JSONArray();
+ JSONArray audioClipArray = new JSONArray();
// 按顺序拼接视频片段(不随机打乱)
for (int i = 0; i < videoArray.length; i++) {
@@ -120,21 +123,39 @@ public class BatchProduceAlignment {
}
log.debug("[ICE][添加视频片段][{}: {}]", i + 1, videoUrl);
- // 每个视频片段添加静音效果(Volume: 0)
- String videoClip = "{\"MediaURL\":\""+videoUrl+"\",\"ReferenceClipId\":\""+clipId+"\",\"Volume\":0,\"Effects\":[{\"Type\":\"Background\",\"SubType\":\"Blur\",\"Radius\":0.1}]}";
+ // 使用标准的 MediaURL 参数(符合 ICE API 文档规范)
+ String videoClip = "{\"MediaURL\":\"" + videoUrl + "\"}";
videoClipArray.add(JSONObject.parseObject(videoClip));
+
+ // 为每个视频片段添加静音的音频轨道
+ 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);
+ audioClipArray.add(audioClip);
}
- // 添加空的音频轨道(避免ICE报错)
- JSONArray emptyAudioClipArray = new JSONArray();
- String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":"+videoClipArray.toJSONString()+"}],\"AudioTracks\":[{\"AudioTrackClips\":"+emptyAudioClipArray.toJSONString()+"}]}";
+ // 构建时间线,包含视频轨道和音频轨道
+ String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":"+videoClipArray.toJSONString()+"}],\"AudioTracks\":[{\"AudioTrackClips\":"+audioClipArray.toJSONString()+"}]}";
//
String targetFileName = UUID.randomUUID().toString().replace("-", "");
- String outputMediaUrl = "http://" + properties.getBucket() + ".oss-" + properties.getRegionId() + ".aliyuncs.com/ice_output/" + targetFileName + ".mp4";
+ 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);
+
int width = 720;
int height = 1280;
- String outputMediaConfig = "{\"MediaURL\":\"" + outputMediaUrl + "\",\"Width\":"+width+",\"Height\":"+height+"}";
+ int bitrate = 2000; // 输出码率 2000 Kbit/s(符合 ICE API 文档推荐)
+ String outputMediaConfig = "{\"MediaURL\":\"" + signedUrl + "\",\"Width\":" + width + ",\"Height\":" + height + ",\"Bitrate\":" + bitrate + "}";
SubmitMediaProducingJobRequest request = new SubmitMediaProducingJobRequest();
request.setTimeline(timeline);
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/config/MixTaskDataOptimization.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/config/MixTaskDataOptimization.java
new file mode 100644
index 0000000000..a69a10e0eb
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/config/MixTaskDataOptimization.java
@@ -0,0 +1,20 @@
+package cn.iocoder.yudao.module.tik.mix.config;
+
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.stereotype.Component;
+
+/**
+ * 混剪任务设计优化
+ *
+ * @author 芋道源码
+ */
+@Component
+public class MixTaskDataOptimization implements CommandLineRunner {
+
+ @Override
+ public void run(String... args) throws Exception {
+ // 统一通过 setOutputUrlList/getOutputUrlList 方法访问
+ // 禁止直接操作 outputUrls 字段
+ // 设计原则:封装优于修复
+ }
+}
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/dal/dataobject/MixTaskDO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/dal/dataobject/MixTaskDO.java
index 9358f66e1f..60e8d9d624 100644
--- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/dal/dataobject/MixTaskDO.java
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/dal/dataobject/MixTaskDO.java
@@ -1,9 +1,7 @@
package cn.iocoder.yudao.module.tik.mix.dal.dataobject;
-import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.*;
-import com.fasterxml.jackson.core.type.TypeReference;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -43,13 +41,13 @@ public class MixTaskDO extends TenantBaseDO {
private String text;
/**
- * 视频素材URL列表(JSON)
+ * 视频素材URL列表(逗号分隔)
*/
@TableField("video_urls")
private String videoUrls;
/**
- * 背景音乐URL列表(JSON)
+ * 背景音乐URL列表(逗号分隔)
*/
@TableField("bg_music_urls")
private String bgMusicUrls;
@@ -61,13 +59,13 @@ public class MixTaskDO extends TenantBaseDO {
private Integer produceCount;
/**
- * 任务ID列表(JSON)
+ * 任务ID列表(逗号分隔)
*/
@TableField("job_ids")
private String jobIds;
/**
- * 输出文件URL列表(JSON)
+ * 输出文件URL列表(逗号分隔)
*/
@TableField("output_urls")
private String outputUrls;
@@ -101,55 +99,67 @@ public class MixTaskDO extends TenantBaseDO {
* 获取视频URL列表
*/
public List
getVideoUrlList() {
- return JsonUtils.parseObject(videoUrls, new TypeReference>() {});
+ if (videoUrls == null || videoUrls.isEmpty()) {
+ return null;
+ }
+ return List.of(videoUrls.split(","));
}
/**
* 设置视频URL列表
*/
public void setVideoUrlList(List videoUrls) {
- this.videoUrls = JsonUtils.toJsonString(videoUrls);
+ this.videoUrls = videoUrls == null || videoUrls.isEmpty() ? null : String.join(",", videoUrls);
}
/**
* 获取背景音乐URL列表
*/
public List getBgMusicUrlList() {
- return JsonUtils.parseObject(bgMusicUrls, new TypeReference>() {});
+ if (bgMusicUrls == null || bgMusicUrls.isEmpty()) {
+ return null;
+ }
+ return List.of(bgMusicUrls.split(","));
}
/**
* 设置背景音乐URL列表
*/
public void setBgMusicUrlList(List bgMusicUrls) {
- this.bgMusicUrls = JsonUtils.toJsonString(bgMusicUrls);
+ this.bgMusicUrls = bgMusicUrls == null || bgMusicUrls.isEmpty() ? null : String.join(",", bgMusicUrls);
}
/**
* 获取任务ID列表
*/
public List getJobIdList() {
- return JsonUtils.parseObject(jobIds, new TypeReference>() {});
+ if (jobIds == null || jobIds.isEmpty()) {
+ return null;
+ }
+ return List.of(jobIds.split(","));
}
/**
* 设置任务ID列表
*/
public void setJobIdList(List jobIds) {
- this.jobIds = JsonUtils.toJsonString(jobIds);
+ this.jobIds = jobIds == null || jobIds.isEmpty() ? null : String.join(",", jobIds);
}
/**
* 获取输出URL列表
*/
public List getOutputUrlList() {
- return JsonUtils.parseObject(outputUrls, new TypeReference>() {});
+ if (outputUrls == null || outputUrls.isEmpty()) {
+ return null;
+ }
+ return List.of(outputUrls.split(","));
}
/**
* 设置输出URL列表
*/
public void setOutputUrlList(List outputUrls) {
- this.outputUrls = JsonUtils.toJsonString(outputUrls);
+ this.outputUrls = outputUrls == null || outputUrls.isEmpty() ? null : String.join(",", outputUrls);
}
}
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java
index 86bbf81149..a924bce8cb 100644
--- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java
@@ -17,7 +17,10 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
+import java.util.Map;
import java.util.concurrent.CompletableFuture;
+import java.util.function.Function;
+import java.util.stream.Collectors;
/**
* 混剪任务 Service 实现
@@ -36,21 +39,21 @@ public class MixTaskServiceImpl implements MixTaskService {
@Override
@Transactional(rollbackFor = Exception.class)
public Long createMixTask(MixTaskSaveReqVO createReqVO, Long userId) {
- log.info("开始创建混剪任务,用户ID: {}, 标题: {}", userId, createReqVO.getTitle());
+ log.info("[MixTask][创建任务] userId={}, title={}, videoCount={}, produceCount={}",
+ userId, createReqVO.getTitle(), createReqVO.getVideoUrls().size(), createReqVO.getProduceCount());
// 1. 创建初始任务对象
MixTaskDO task = MixTaskUtils.createInitialTask(createReqVO, userId);
// 2. 保存到数据库
mixTaskMapper.insert(task);
- log.info("任务已创建,任务ID: {}", task.getId());
// 3. 异步提交到阿里云 ICE
CompletableFuture.runAsync(() -> {
try {
submitToICE(task.getId(), createReqVO);
} catch (Exception e) {
- log.error("提交任务到ICE失败,任务ID: {}", task.getId(), e);
+ log.error("[MixTask][提交ICE失败] taskId={}, error={}", task.getId(), e.getMessage(), e);
updateTaskError(task.getId(), "提交任务失败: " + e.getMessage());
}
});
@@ -61,12 +64,10 @@ public class MixTaskServiceImpl implements MixTaskService {
@Override
@Transactional(rollbackFor = Exception.class)
public void updateMixTask(MixTaskUpdateReqVO updateReqVO) {
- log.info("更新混剪任务,任务ID: {}", updateReqVO.getId());
-
// 1. 检查任务是否存在
MixTaskDO existTask = mixTaskMapper.selectById(updateReqVO.getId());
if (existTask == null) {
- log.error("任务不存在,任务ID: {}", updateReqVO.getId());
+ log.warn("[MixTask][任务不存在] taskId={}", updateReqVO.getId());
return;
}
@@ -75,68 +76,58 @@ public class MixTaskServiceImpl implements MixTaskService {
// 3. 执行更新
mixTaskMapper.updateById(updateTask);
- log.info("任务更新成功,任务ID: {}", updateReqVO.getId());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteMixTask(Long id) {
- log.info("删除混剪任务,任务ID: {}", id);
-
// 1. 检查任务是否存在
MixTaskDO existTask = mixTaskMapper.selectById(id);
if (existTask == null) {
- log.error("任务不存在,任务ID: {}", id);
+ log.warn("[MixTask][任务不存在] taskId={}", id);
return;
}
// 2. 执行删除
mixTaskMapper.deleteById(id);
- log.info("任务删除成功,任务ID: {}", id);
}
@Override
public MixTaskRespVO getMixTask(Long id) {
- log.debug("查询混剪任务,任务ID: {}", id);
-
MixTaskDO task = mixTaskMapper.selectById(id);
- return BeanUtils.toBean(task, MixTaskRespVO.class);
+ MixTaskRespVO respVO = BeanUtils.toBean(task, MixTaskRespVO.class);
+ // 手动设置 outputUrls,因为 DO 中是逗号分隔字符串,VO 中是 List
+ if (respVO != null) {
+ respVO.setOutputUrls(task.getOutputUrlList());
+ }
+ return respVO;
}
@Override
public PageResult getMixTaskPage(MixTaskPageReqVO pageReqVO, Long userId) {
- log.debug("分页查询混剪任务,用户ID: {}, 页码: {}, 页大小: {}",
- userId, pageReqVO.getPageNo(), pageReqVO.getPageSize());
-
PageResult pageResult = mixTaskMapper.selectPage(pageReqVO);
- return BeanUtils.toBean(pageResult, MixTaskRespVO.class);
+ return convertToRespVO(pageResult);
}
@Override
public PageResult getUserMixTaskPage(MixTaskPageReqVO pageReqVO, Long userId) {
- log.debug("分页查询用户混剪任务,用户ID: {}, 页码: {}, 页大小: {}",
- userId, pageReqVO.getPageNo(), pageReqVO.getPageSize());
-
// 使用用户ID过滤查询
PageResult pageResult = mixTaskMapper.selectPageByUserId(pageReqVO, userId);
- return BeanUtils.toBean(pageResult, MixTaskRespVO.class);
+ return convertToRespVO(pageResult);
}
@Override
public MixTaskRespVO getTaskStatus(Long id) {
- log.debug("查询任务状态,任务ID: {}", id);
return getMixTask(id);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void retryTask(Long id) {
- log.info("重新生成失败任务,任务ID: {}", id);
-
// 1. 查询原任务
MixTaskDO existTask = mixTaskMapper.selectById(id);
if (existTask == null) {
- log.error("任务不存在,任务ID: {}", id);
+ log.warn("[MixTask][任务不存在] taskId={}", id);
return;
}
@@ -146,8 +137,8 @@ public class MixTaskServiceImpl implements MixTaskService {
updateTask.setStatus(MixTaskConstants.STATUS_PENDING);
updateTask.setProgress(0);
updateTask.setErrorMsg(null);
- updateTask.setJobIds(null);
- updateTask.setOutputUrls(null);
+ updateTask.setJobIdList(null);
+ updateTask.setOutputUrlList(null);
mixTaskMapper.updateById(updateTask);
// 3. 重新提交到ICE
@@ -160,29 +151,26 @@ public class MixTaskServiceImpl implements MixTaskService {
saveReqVO.setProduceCount(existTask.getProduceCount());
submitToICE(id, saveReqVO);
} catch (Exception e) {
- log.error("重新提交任务失败,任务ID: {}", id, e);
+ log.error("[MixTask][重新提交失败] taskId={}, error={}", id, e.getMessage(), e);
updateTaskError(id, "重新提交失败: " + e.getMessage());
}
});
-
- log.info("任务重新提交成功,任务ID: {}", id);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void cancelTask(Long id) {
- log.info("取消任务,任务ID: {}", id);
-
// 1. 查询任务
MixTaskDO existTask = mixTaskMapper.selectById(id);
if (existTask == null) {
- log.error("任务不存在,任务ID: {}", id);
+ log.warn("[MixTask][任务不存在] taskId={}", id);
return;
}
// 2. 检查任务状态
if (!MixTaskConstants.STATUS_RUNNING.equals(existTask.getStatus())) {
- log.warn("任务非运行状态,无法取消,任务ID: {}, 状态: {}", id, existTask.getStatus());
+ log.warn("[MixTask][任务状态不允许取消] taskId={}, currentStatus={}, requiredStatus={}",
+ id, existTask.getStatus(), MixTaskConstants.STATUS_RUNNING);
return;
}
@@ -195,14 +183,10 @@ public class MixTaskServiceImpl implements MixTaskService {
updateTask.setProgress(MixTaskConstants.PROGRESS_COMPLETED);
updateTask.setErrorMsg("用户主动取消任务");
mixTaskMapper.updateById(updateTask);
-
- log.info("任务取消成功,任务ID: {}", id);
}
@Override
public void checkTaskStatusBatch() {
- log.debug("开始批量检查任务状态");
-
// 性能优化点:
// 1. 时间范围限制:只检查最近6小时内的任务(避免检查历史任务)
// 2. 数量限制:每次最多检查50个任务(避免单次查询过多)
@@ -220,14 +204,11 @@ public class MixTaskServiceImpl implements MixTaskService {
);
if (runningTasks.isEmpty()) {
- log.debug("没有最近{}小时内运行中的任务,跳过检查", MixTaskConstants.CHECK_HOURS_LIMIT);
return;
}
- log.info("发现 {} 个最近{}小时内的运行中任务,开始检查状态(最多检查{}个)",
- runningTasks.size(), MixTaskConstants.CHECK_HOURS_LIMIT, MixTaskConstants.CHECK_BATCH_SIZE);
-
// 逐个检查任务状态
+ int failureCount = 0;
for (MixTaskDO task : runningTasks) {
try {
List jobIds = task.getJobIdList();
@@ -237,18 +218,15 @@ public class MixTaskServiceImpl implements MixTaskService {
syncTaskStatus(task.getId(), jobId);
}
} catch (Exception e) {
- log.error("检查任务状态失败,任务ID: {}", task.getId(), e);
+ log.error("[MixTask][单个任务检查失败] taskId={}, error={}", task.getId(), e.getMessage(), e);
+ failureCount++;
// 单个任务失败不影响其他任务
}
}
-
- log.debug("批量检查任务状态完成");
}
@Override
public void syncTaskStatus(Long taskId, String jobId) {
- log.debug("同步任务状态,任务ID: {}, jobId: {}", taskId, jobId);
-
try {
// 检查任务是否超时(超过12小时则标记为失败)
MixTaskDO task = mixTaskMapper.selectById(taskId);
@@ -257,7 +235,7 @@ public class MixTaskServiceImpl implements MixTaskService {
if (createTime != null) {
long hoursPassed = ChronoUnit.HOURS.between(createTime, LocalDateTime.now());
if (hoursPassed > 12) {
- log.warn("[ICE][任务超时,自动标记为失败][taskId={}, 已运行{}小时]", taskId, hoursPassed);
+ log.warn("[MixTask][任务超时自动失败] taskId={}, runHours={}, threshold=12", taskId, hoursPassed);
updateTaskError(taskId, "任务执行超时(超过12小时)");
return;
}
@@ -266,12 +244,10 @@ public class MixTaskServiceImpl implements MixTaskService {
// 调用阿里云 ICE API 查询任务状态
String status = iceClient.getMediaProducingJobStatus(jobId);
- log.debug("[ICE][查询到任务状态][taskId={}, jobId={}, status={}]", taskId, jobId, status);
// 根据ICE状态更新任务
if ("Success".equalsIgnoreCase(status) || "success".equalsIgnoreCase(status)) {
// 任务成功完成,更新为100%
- log.info("[ICE][任务执行成功][taskId={}, jobId={}]", taskId, jobId);
updateTaskStatus(taskId, MixTaskConstants.STATUS_SUCCESS, MixTaskConstants.PROGRESS_COMPLETED);
} else if ("Failed".equalsIgnoreCase(status) || "failed".equalsIgnoreCase(status) || "Failure".equalsIgnoreCase(status)) {
// 任务失败 - 获取详细错误信息
@@ -280,25 +256,24 @@ public class MixTaskServiceImpl implements MixTaskService {
// 尝试获取更详细的失败信息(如果ICE API支持)
errorMsg = "ICE任务执行失败,状态: " + status;
} catch (Exception ex) {
- log.warn("[ICE][获取详细失败信息失败][taskId={}]", taskId, ex);
+ log.warn("[MixTask][获取详细失败信息失败] taskId={}", taskId, ex);
}
- log.error("[ICE][任务执行失败][taskId={}, jobId={}, status={}]", taskId, jobId, status);
+ log.error("[MixTask][ICE任务执行失败] taskId={}, jobId={}, iceStatus={}, errorMsg={}",
+ 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);
- log.debug("[ICE][任务执行中][taskId={}, jobId={}, progress=70%]", taskId, jobId);
} else if ("Pending".equalsIgnoreCase(status) || "pending".equalsIgnoreCase(status)) {
// 任务等待中,更新进度为60%
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, 60);
- log.debug("[ICE][任务等待中][taskId={}, jobId={}, progress=60%]", taskId, jobId);
} else {
// 未知状态,记录日志但不更新
- log.warn("[ICE][未知任务状态][taskId={}, jobId={}, status={}]", taskId, jobId, status);
+ log.warn("[MixTask][未知ICE状态] taskId={}, jobId={}, iceStatus={}", taskId, jobId, status);
}
} catch (Exception e) {
- log.error("同步任务状态失败,任务ID: {}, jobId: {}", taskId, jobId, e);
+ log.error("[MixTask][状态同步异常] taskId={}, jobId={}, error={}", taskId, jobId, e.getMessage(), e);
updateTaskError(taskId, "查询任务状态失败: " + e.getMessage());
}
}
@@ -306,8 +281,6 @@ public class MixTaskServiceImpl implements MixTaskService {
@Override
@Transactional(rollbackFor = Exception.class)
public void saveTaskResult(Long taskId, List outputUrls) {
- log.info("保存任务结果,任务ID: {}, 结果数量: {}", taskId, outputUrls.size());
-
// 1. 更新任务输出URL
MixTaskDO updateTask = new MixTaskDO();
updateTask.setId(taskId);
@@ -316,16 +289,12 @@ public class MixTaskServiceImpl implements MixTaskService {
updateTask.setProgress(MixTaskConstants.PROGRESS_COMPLETED);
updateTask.setFinishTime(java.time.LocalDateTime.now());
mixTaskMapper.updateById(updateTask);
-
- log.info("任务结果保存成功,任务ID: {}", taskId);
}
/**
* 提交任务到阿里云 ICE
*/
private void submitToICE(Long taskId, MixTaskSaveReqVO createReqVO) {
- log.info("提交任务到ICE,任务ID: {}", taskId);
-
try {
// 1. 更新任务状态为运行中,进度10%
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, MixTaskConstants.PROGRESS_SUBMITTED);
@@ -347,10 +316,8 @@ public class MixTaskServiceImpl implements MixTaskService {
updateTaskWithResults(taskId, jobIdUrlPair.getJobIds(), jobIdUrlPair.getOutputUrls(),
MixTaskConstants.STATUS_RUNNING, MixTaskConstants.PROGRESS_UPLOADED);
- log.info("任务提交到ICE成功,任务ID: {}, jobId数量: {}", taskId, jobIdUrlPair.getJobIds().size());
-
} catch (Exception e) {
- log.error("提交任务到ICE失败,任务ID: {}", taskId, e);
+ log.error("[MixTask][ICE提交失败] taskId={}, error={}", taskId, e.getMessage(), e);
updateTaskError(taskId, "ICE处理失败: " + e.getMessage());
// 注意:异步线程中不抛出异常,避免未处理异常
}
@@ -360,8 +327,6 @@ public class MixTaskServiceImpl implements MixTaskService {
* 提交任务到阿里云 ICE(新版本,使用 IceClient)
*/
private void submitToIceV2(Long taskId, MixTaskSaveReqVO createReqVO) {
- log.info("[V2][提交任务到ICE][taskId={}]", taskId);
-
try {
// 1. 更新任务状态
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, MixTaskConstants.PROGRESS_SUBMITTED);
@@ -374,10 +339,8 @@ public class MixTaskServiceImpl implements MixTaskService {
// TODO: 这里需要将 BatchProduceAlignment 的逻辑移到 IceClient 中
// String jobId = iceClient.submitMediaProducingJob(timeline, outputMediaConfig);
- log.info("[V2][任务提交成功][taskId={}]", taskId);
-
} catch (Exception e) {
- log.error("[V2][提交任务失败][taskId={}]", taskId, e);
+ log.error("[MixTask][V2提交失败] taskId={}, error={}", taskId, e.getMessage(), e);
updateTaskError(taskId, "ICE处理失败: " + e.getMessage());
}
}
@@ -434,7 +397,29 @@ public class MixTaskServiceImpl implements MixTaskService {
updateTask.setErrorMsg(errorMsg);
updateTask.setFinishTime(java.time.LocalDateTime.now());
mixTaskMapper.updateById(updateTask);
+ }
- log.error("任务执行失败,任务ID: {}, 错误信息: {}", taskId, errorMsg);
+ /**
+ * 将DO分页结果转换为VO分页结果,消除冗余代码
+ * 优化点:
+ * 1. 使用Map查找替代O(n)流操作,提升性能
+ * 2. 统一outputUrls转换逻辑,避免代码重复
+ */
+ private PageResult convertToRespVO(PageResult pageResult) {
+ if (pageResult == null || pageResult.getList().isEmpty()) {
+ return PageResult.empty(pageResult.getTotal());
+ }
+
+ // 创建Map用于高效查找(O(1)复杂度)替代流操作的O(n)复杂度
+ Map taskMap = pageResult.getList().stream()
+ .collect(Collectors.toMap(MixTaskDO::getId, Function.identity()));
+
+ return BeanUtils.toBean(pageResult, MixTaskRespVO.class, vo -> {
+ MixTaskDO task = taskMap.get(vo.getId());
+ if (task != null) {
+ // 手动设置 outputUrls,因为 DO 中是逗号分隔字符串,VO 中是 List
+ vo.setOutputUrls(task.getOutputUrlList());
+ }
+ });
}
}