From ac803ec03b4054d7e6db52ae2da356a0808b694a Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Sun, 30 Nov 2025 18:06:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- docs/ICE_SubmitMediaProducingJob_API.md | 365 ++++++++++++ .../web-gold/src/components/SidebarNav.vue | 4 +- .../src/views/material/MixTaskList.vue | 512 +++++++++-------- .../src/views/material/MixTaskList.vue.bak | 532 ++++++++++++++++++ .../tik/media/BatchProduceAlignment.java | 37 +- .../mix/config/MixTaskDataOptimization.java | 20 + .../tik/mix/dal/dataobject/MixTaskDO.java | 38 +- .../tik/mix/service/MixTaskServiceImpl.java | 129 ++--- 9 files changed, 1314 insertions(+), 326 deletions(-) create mode 100644 docs/ICE_SubmitMediaProducingJob_API.md create mode 100644 frontend/app/web-gold/src/views/material/MixTaskList.vue.bak create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/config/MixTaskDataOptimization.java 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 @@ + + + + + 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()); + } + }); } }