feat: 优化

This commit is contained in:
2025-11-30 18:06:54 +08:00
parent 853bedcb23
commit ac803ec03b
9 changed files with 1314 additions and 326 deletions

View File

@@ -9,7 +9,8 @@
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git log:*)",
"Bash(xargs:*)"
"Bash(xargs:*)",
"Bash(test:*)"
],
"deny": [],
"ask": []

View File

@@ -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 | 输出文件URLoss-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*

View File

@@ -16,7 +16,8 @@ const icons = {
wave: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M2 12s2-4 5-4 3 8 6 8 3-8 6-8 3 4 3 4"/></svg>',
user: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><circle cx="12" cy="7" r="4"/><path d="M5.5 21a8.38 8.38 0 0 1 13 0"/></svg>',
video: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="m22 8-6 4 6 4V8Z"/><rect x="2" y="6" width="14" height="12" rx="2" ry="2"/></svg>',
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>'
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>',
scissors: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M8.59 13.51 15.42 17.49"/><path d="M15.41 6.51 8.59 10.49"/></svg>'
}
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' },
]
},

View File

@@ -58,126 +58,152 @@
<!-- 任务列表 -->
<div class="mix-task-list__content">
<a-spin :spinning="loading" tip="加载中...">
<template v-if="taskList.length > 0">
<div class="task-list">
<div
v-for="task in taskList"
:key="task.id"
class="task-item"
>
<div class="task-item__header">
<div class="task-item__title">
<h3>{{ task.title }}</h3>
<a-tag :color="getStatusColor(task.status)">
{{ getStatusText(task.status) }}
</a-tag>
</div>
<div class="task-item__actions">
<a-button
v-if="task.status === 'failed'"
type="link"
size="small"
@click="handleRetry(task.id)"
<a-table
:data-source="taskList"
:columns="columns"
:row-key="record => record.id"
:pagination="paginationConfig"
@change="handleTableChange"
:expanded-row-keys="expandedRowKeys"
@expandedRowsChange="handleExpandedRowsChange"
:scroll="{ x: 1000 }"
>
<!-- 标题列 -->
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<div class="title-cell">
<strong>{{ record.title }}</strong>
<a-tag v-if="record.text" size="small" style="margin-left: 8px">有文案</a-tag>
</div>
</template>
<!-- 状态列 -->
<template v-else-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<!-- 进度列 -->
<template v-else-if="column.key === 'progress'">
<div style="min-width: 100px">
<div style="font-size: 12px; margin-bottom: 4px">{{ record.progress }}%</div>
<a-progress
:percent="record.progress"
:status="getProgressStatus(record.status)"
size="small"
:show-info="false"
/>
</div>
</template>
<!-- 创建时间列 -->
<template v-else-if="column.key === 'createTime'">
{{ formatDate(record.createTime) }}
</template>
<!-- 完成时间列 -->
<template v-else-if="column.key === 'finishTime'">
{{ record.finishTime ? formatDate(record.finishTime) : '-' }}
</template>
<!-- 生成结果列 -->
<template v-else-if="column.key === 'outputUrls'">
<div v-if="record.outputUrls && record.outputUrls.length > 0">
<a-tag color="success">{{ record.outputUrls.length }} 个视频</a-tag>
</div>
<span v-else>-</span>
</template>
<!-- 操作列 -->
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button
v-if="record.outputUrls && record.outputUrls.length > 0"
type="primary"
size="small"
@click="handleDownloadAll(record.outputUrls)"
>
<template #icon>
<DownloadOutlined />
</template>
<span>下载</span>
</a-button>
<a-button
v-if="record.status === 'failed'"
size="small"
@click="handleRetry(record.id)"
>
重新生成
</a-button>
<a-button
v-if="record.status === 'running'"
size="small"
@click="handleCancel(record.id)"
>
取消
</a-button>
<a-popconfirm
title="确定删除这个任务吗删除后无法恢复"
@confirm="() => handleDelete(record.id)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
<!-- 展开行内容 -->
<template #expandedRowRender="{ record }">
<div class="expanded-content">
<!-- 任务详情 -->
<div v-if="record.text" class="task-text">
<strong>文案内容:</strong>
<p>{{ record.text }}</p>
</div>
<!-- 生成结果 -->
<div v-if="record.outputUrls && record.outputUrls.length > 0" class="task-results">
<strong>生成结果:</strong>
<div class="result-list">
<div
v-for="(url, index) in record.outputUrls"
:key="index"
class="result-item"
>
重新生成
</a-button>
<a-button
v-if="task.status === 'running'"
type="link"
size="small"
@click="handleCancel(task.id)"
>
取消
</a-button>
<a-button
type="link"
size="small"
@click="handleDelete(task.id)"
>
删除
</a-button>
<a :href="url" target="_blank">
<PlayCircleOutlined />
视频 {{ index + 1 }}
</a>
<a-button
type="link"
size="small"
@click="handleDownload(url)"
>
<DownloadOutlined />
</a-button>
</div>
</div>
</div>
<div class="task-item__content">
<div class="task-item__progress">
<div class="progress-info">
<span>进度{{ task.progress }}%</span>
</div>
<a-progress
:percent="task.progress"
:status="getProgressStatus(task.status)"
:show-info="false"
/>
</div>
<div class="task-item__meta">
<span>创建时间{{ formatDate(task.createTime) }}</span>
<span v-if="task.finishTime">
完成时间{{ formatDate(task.finishTime) }}
</span>
</div>
<div class="task-item__text" v-if="task.text">
<p>{{ task.text }}</p>
</div>
<div class="task-item__results" v-if="task.outputUrls && task.outputUrls.length > 0">
<h4>生成结果 ({{ task.outputUrls.length }})</h4>
<div class="result-list">
<div
v-for="(url, index) in task.outputUrls"
:key="index"
class="result-item"
>
<a :href="url" target="_blank">
<PlayCircleOutlined />
视频 {{ index + 1 }}
</a>
<a-button
type="link"
size="small"
@click="handleDownload(url)"
>
<DownloadOutlined />
</a-button>
</div>
</div>
</div>
<div class="task-item__error" v-if="task.errorMsg">
<a-alert
type="error"
:message="task.errorMsg"
show-icon
/>
</div>
<!-- 错误信息 -->
<div v-if="record.errorMsg" class="task-error">
<a-alert
type="error"
:message="record.errorMsg"
show-icon
/>
</div>
</div>
</div>
</template>
<template v-else>
<a-empty description="暂无混剪任务" />
</template>
</template>
</a-table>
</a-spin>
</div>
<!-- 分页 -->
<div class="mix-task-list__pagination">
<a-pagination
v-model:current="pagination.pageNo"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:show-total="(total) => `${total}`"
:show-size-changer="true"
@change="handlePageChange"
/>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
ReloadOutlined,
@@ -191,7 +217,61 @@ import { formatDate } from '@/utils/file'
// 数据
const loading = ref(false)
const taskList = ref([])
const timer = ref(null) // 定时器
const expandedRowKeys = ref([])
// 表格列定义
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
fixed: 'left'
},
{
title: '标题',
dataIndex: 'title',
key: 'title',
width: 250,
ellipsis: true
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100
},
{
title: '进度',
dataIndex: 'progress',
key: 'progress',
width: 150
},
{
title: '生成结果',
dataIndex: 'outputUrls',
key: 'outputUrls',
width: 120
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 180
},
{
title: '完成时间',
dataIndex: 'finishTime',
key: 'finishTime',
width: 180
},
{
title: '操作',
key: 'actions',
width: 300,
fixed: 'right'
}
]
// 筛选条件
const filters = reactive({
@@ -200,18 +280,32 @@ const filters = reactive({
createTime: undefined
})
// 分页
const pagination = reactive({
pageNo: 1,
// 分页配置
const paginationConfig = reactive({
current: 1,
pageSize: 10,
total: 0
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
pageSizeOptions: ['10', '20', '50', '100'],
onChange: (page, pageSize) => {
paginationConfig.current = page
paginationConfig.pageSize = pageSize
handlePageChange(page, pageSize)
},
onShowSizeChange: (current, size) => {
paginationConfig.current = 1
paginationConfig.pageSize = size
handlePageChange(1, size)
}
})
// 构建查询参数
const buildQueryParams = () => {
const params = {
pageNo: pagination.pageNo,
pageSize: pagination.pageSize,
pageNo: paginationConfig.current,
pageSize: paginationConfig.pageSize,
status: filters.status || undefined,
title: filters.title || undefined
}
@@ -232,7 +326,7 @@ const loadTaskList = async () => {
const res = await MixTaskService.getTaskPage(buildQueryParams())
if (res.code === 0) {
taskList.value = res.data.list || []
pagination.total = res.data.total || 0
paginationConfig.total = res.data.total || 0
} else {
message.error(res.msg || '加载失败')
}
@@ -246,7 +340,7 @@ const loadTaskList = async () => {
// 筛选
const handleFilterChange = () => {
pagination.pageNo = 1
paginationConfig.current = 1
loadTaskList()
}
@@ -254,20 +348,25 @@ const handleResetFilters = () => {
filters.status = ''
filters.title = ''
filters.createTime = undefined
pagination.pageNo = 1
paginationConfig.current = 1
loadTaskList()
}
// 分页
const handlePageChange = (page, pageSize) => {
pagination.pageNo = page
if (pageSize && pageSize !== pagination.pageSize) {
pagination.pageSize = pageSize
pagination.pageNo = 1
}
loadTaskList()
}
// 表格变化
const handleTableChange = (pag, filters, sorter) => {
console.log('表格变化:', pag, filters, sorter)
}
// 展开行变化
const handleExpandedRowsChange = (expandedRows) => {
expandedRowKeys.value = expandedRows
}
// 刷新
const handleRefresh = () => {
loadTaskList()
@@ -309,23 +408,17 @@ const handleCancel = (id) => {
// 删除任务
const handleDelete = (id) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个任务吗?删除后无法恢复。',
okType: 'danger',
onOk: async () => {
try {
await MixTaskService.deleteTask(id)
message.success('删除成功')
loadTaskList()
} catch (error) {
message.error('删除失败')
}
}
})
MixTaskService.deleteTask(id)
.then(() => {
message.success('删除成功')
loadTaskList()
})
.catch(() => {
message.error('删除失败')
})
}
// 下载
// 下载单个视频
const handleDownload = (url) => {
const link = document.createElement('a')
link.href = url
@@ -336,6 +429,27 @@ const handleDownload = (url) => {
document.body.removeChild(link)
}
// 批量下载所有视频
const handleDownloadAll = (urls) => {
if (!urls || urls.length === 0) {
message.warning('没有可下载的视频')
return
}
message.loading('正在准备下载...', 0)
// 逐个触发下载,避免浏览器阻止多个弹窗
urls.forEach((url, index) => {
setTimeout(() => {
console.log('下载视频:', url)
handleDownload(url)
}, index * 500) // 每个下载间隔500ms
})
message.destroy()
message.success(`已触发 ${urls.length} 个视频的下载`)
}
// 获取状态文本
const getStatusText = (status) => {
const statusMap = {
@@ -369,29 +483,9 @@ const getProgressStatus = (status) => {
return statusMap[status] || 'normal'
}
// 定时刷新
const startAutoRefresh = () => {
// 每5秒刷新一次
timer.value = setInterval(() => {
loadTaskList()
}, 5000)
}
const stopAutoRefresh = () => {
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
}
// 初始化
onMounted(() => {
loadTaskList()
startAutoRefresh()
})
onUnmounted(() => {
stopAutoRefresh()
})
</script>
@@ -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;
}
</style>

View File

@@ -0,0 +1,532 @@
<template>
<div class="mix-task-list">
<div class="mix-task-list__header">
<h1 class="mix-task-list__title">混剪任务</h1>
<div class="mix-task-list__actions">
<a-button @click="handleRefresh">
<template #icon>
<ReloadOutlined />
</template>
刷新
</a-button>
</div>
</div>
<!-- 筛选条件 -->
<div class="mix-task-list__filters">
<a-space>
<a-select
v-model:value="filters.status"
style="width: 120px"
placeholder="任务状态"
@change="handleFilterChange"
allow-clear
>
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="pending">待处理</a-select-option>
<a-select-option value="running">处理中</a-select-option>
<a-select-option value="success">已完成</a-select-option>
<a-select-option value="failed">失败</a-select-option>
</a-select>
<a-input
v-model="filters.title"
placeholder="搜索标题"
style="width: 200px"
allow-clear
@press-enter="handleFilterChange"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<a-range-picker
v-model:value="filters.createTime"
style="width: 300px"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:placeholder="['开始日期', '结束日期']"
@change="handleFilterChange"
/>
<a-button type="primary" @click="handleFilterChange">查询</a-button>
<a-button @click="handleResetFilters">重置</a-button>
</a-space>
</div>
<!-- 任务列表 -->
<div class="mix-task-list__content">
<a-spin :spinning="loading" tip="加载中...">
<template v-if="taskList.length > 0">
<div class="task-list">
<div
v-for="task in taskList"
:key="task.id"
class="task-item"
>
<div class="task-item__header">
<div class="task-item__title">
<h3>{{ task.title }}</h3>
<a-tag :color="getStatusColor(task.status)">
{{ getStatusText(task.status) }}
</a-tag>
</div>
</div>
<div class="task-item__content">
<div class="task-item__progress">
<div class="progress-info">
<span>进度{{ task.progress }}%</span>
</div>
<a-progress
:percent="task.progress"
:status="getProgressStatus(task.status)"
:show-info="false"
/>
</div>
<div class="task-item__meta">
<span>创建时间{{ formatDate(task.createTime) }}</span>
<span v-if="task.finishTime">
完成时间{{ formatDate(task.finishTime) }}
</span>
</div>
<div class="task-item__text" v-if="task.text">
<p>{{ task.text }}</p>
</div>
<div class="task-item__results" v-if="task.outputUrls && task.outputUrls.length > 0">
<h4>生成结果 ({{ task.outputUrls.length }})</h4>
<div class="result-list">
<div
v-for="(url, index) in task.outputUrls"
:key="index"
class="result-item"
>
<a :href="url" target="_blank">
<PlayCircleOutlined />
视频 {{ index + 1 }}
</a>
<a-button
type="link"
size="small"
@click="handleDownload(url)"
>
<DownloadOutlined />
</a-button>
</div>
</div>
</div>
<div class="task-item__error" v-if="task.errorMsg">
<a-alert
type="error"
:message="task.errorMsg"
show-icon
/>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<a-empty description="暂无混剪任务" />
</template>
</a-spin>
</div>
<!-- 分页 -->
<div class="mix-task-list__pagination">
<a-pagination
v-model:current="pagination.pageNo"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:show-total="(total) => `${total}`"
:show-size-changer="true"
@change="handlePageChange"
/>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
ReloadOutlined,
SearchOutlined,
PlayCircleOutlined,
DownloadOutlined
} from '@ant-design/icons-vue'
import { MixTaskService } from '@/api/mixTask'
import { formatDate } from '@/utils/file'
// 数据
const loading = ref(false)
const taskList = ref([])
const timer = ref(null) // 定时器
// 筛选条件
const filters = reactive({
status: '',
title: '',
createTime: undefined
})
// 分页
const pagination = reactive({
pageNo: 1,
pageSize: 10,
total: 0
})
// 构建查询参数
const buildQueryParams = () => {
const params = {
pageNo: pagination.pageNo,
pageSize: pagination.pageSize,
status: filters.status || undefined,
title: filters.title || undefined
}
// 处理日期范围
if (filters.createTime && Array.isArray(filters.createTime) && filters.createTime.length === 2) {
params.createTimeStart = `${filters.createTime[0]} 00:00:00`
params.createTimeEnd = `${filters.createTime[1]} 23:59:59`
}
return params
}
// 加载任务列表
const loadTaskList = async () => {
loading.value = true
try {
const res = await MixTaskService.getTaskPage(buildQueryParams())
if (res.code === 0) {
taskList.value = res.data.list || []
pagination.total = res.data.total || 0
} else {
message.error(res.msg || '加载失败')
}
} catch (error) {
console.error('加载任务列表失败:', error)
message.error('加载失败,请重试')
} finally {
loading.value = false
}
}
// 筛选
const handleFilterChange = () => {
pagination.pageNo = 1
loadTaskList()
}
const handleResetFilters = () => {
filters.status = ''
filters.title = ''
filters.createTime = undefined
pagination.pageNo = 1
loadTaskList()
}
// 分页
const handlePageChange = (page, pageSize) => {
pagination.pageNo = page
if (pageSize && pageSize !== pagination.pageSize) {
pagination.pageSize = pageSize
pagination.pageNo = 1
}
loadTaskList()
}
// 刷新
const handleRefresh = () => {
loadTaskList()
}
// 重新生成
const handleRetry = (id) => {
Modal.confirm({
title: '确认重新生成',
content: '确定要重新生成这个任务吗?',
onOk: async () => {
try {
await MixTaskService.retryTask(id)
message.success('已重新提交任务')
loadTaskList()
} catch (error) {
message.error('操作失败')
}
}
})
}
// 取消任务
const handleCancel = (id) => {
Modal.confirm({
title: '确认取消',
content: '确定要取消这个任务吗?',
onOk: async () => {
try {
await MixTaskService.cancelTask(id)
message.success('已取消任务')
loadTaskList()
} catch (error) {
message.error('操作失败')
}
}
})
}
// 删除任务
const handleDelete = (id) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个任务吗?删除后无法恢复。',
okType: 'danger',
onOk: async () => {
try {
await MixTaskService.deleteTask(id)
message.success('删除成功')
loadTaskList()
} catch (error) {
message.error('删除失败')
}
}
})
}
// 下载单个视频
const handleDownload = (url) => {
const link = document.createElement('a')
link.href = url
link.download = 'video'
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
// 批量下载所有视频
const handleDownloadAll = (urls) => {
if (!urls || urls.length === 0) {
message.warning('没有可下载的视频')
return
}
message.loading('正在准备下载...', 0)
// 逐个触发下载,避免浏览器阻止多个弹窗
urls.forEach((url, index) => {
setTimeout(() => {
handleDownload(url)
}, index * 500) // 每个下载间隔500ms
})
message.destroy()
message.success(`已触发 ${urls.length} 个视频的下载`)
}
// 获取状态文本
const getStatusText = (status) => {
const statusMap = {
pending: '待处理',
running: '处理中',
success: '已完成',
failed: '失败'
}
return statusMap[status] || status
}
// 获取状态颜色
const getStatusColor = (status) => {
const colorMap = {
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error'
}
return colorMap[status] || 'default'
}
// 获取进度条状态
const getProgressStatus = (status) => {
const statusMap = {
pending: 'normal',
running: 'active',
success: 'success',
failed: 'exception'
}
return statusMap[status] || 'normal'
}
// 定时刷新
const startAutoRefresh = () => {
// 每5秒刷新一次
timer.value = setInterval(() => {
loadTaskList()
}, 5000)
}
const stopAutoRefresh = () => {
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
}
// 初始化
onMounted(() => {
loadTaskList()
startAutoRefresh()
})
onUnmounted(() => {
stopAutoRefresh()
})
</script>
<style scoped>
.mix-task-list {
padding: 24px;
height: 100%;
display: flex;
flex-direction: column;
}
.mix-task-list__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.mix-task-list__title {
font-size: 24px;
font-weight: 600;
margin: 0;
}
.mix-task-list__actions {
display: flex;
gap: 12px;
}
.mix-task-list__filters {
margin-bottom: 24px;
padding: 16px;
background: var(--color-surface);
border-radius: var(--radius-card);
border: 1px solid var(--color-border);
}
.mix-task-list__content {
flex: 1;
overflow-y: auto;
margin-bottom: 24px;
}
.task-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.task-item {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
padding: 16px;
}
.task-item__header {
display: flex;
justify-content: space-between;
align-items: center;
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);
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;
}
.result-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.result-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--color-bg-2);
border-radius: var(--radius-card);
font-size: 13px;
}
.mix-task-list__pagination {
display: flex;
justify-content: center;
}
</style>

View File

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

View File

@@ -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 字段
// 设计原则:封装优于修复
}
}

View File

@@ -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<String> getVideoUrlList() {
return JsonUtils.parseObject(videoUrls, new TypeReference<List<String>>() {});
if (videoUrls == null || videoUrls.isEmpty()) {
return null;
}
return List.of(videoUrls.split(","));
}
/**
* 设置视频URL列表
*/
public void setVideoUrlList(List<String> videoUrls) {
this.videoUrls = JsonUtils.toJsonString(videoUrls);
this.videoUrls = videoUrls == null || videoUrls.isEmpty() ? null : String.join(",", videoUrls);
}
/**
* 获取背景音乐URL列表
*/
public List<String> getBgMusicUrlList() {
return JsonUtils.parseObject(bgMusicUrls, new TypeReference<List<String>>() {});
if (bgMusicUrls == null || bgMusicUrls.isEmpty()) {
return null;
}
return List.of(bgMusicUrls.split(","));
}
/**
* 设置背景音乐URL列表
*/
public void setBgMusicUrlList(List<String> bgMusicUrls) {
this.bgMusicUrls = JsonUtils.toJsonString(bgMusicUrls);
this.bgMusicUrls = bgMusicUrls == null || bgMusicUrls.isEmpty() ? null : String.join(",", bgMusicUrls);
}
/**
* 获取任务ID列表
*/
public List<String> getJobIdList() {
return JsonUtils.parseObject(jobIds, new TypeReference<List<String>>() {});
if (jobIds == null || jobIds.isEmpty()) {
return null;
}
return List.of(jobIds.split(","));
}
/**
* 设置任务ID列表
*/
public void setJobIdList(List<String> jobIds) {
this.jobIds = JsonUtils.toJsonString(jobIds);
this.jobIds = jobIds == null || jobIds.isEmpty() ? null : String.join(",", jobIds);
}
/**
* 获取输出URL列表
*/
public List<String> getOutputUrlList() {
return JsonUtils.parseObject(outputUrls, new TypeReference<List<String>>() {});
if (outputUrls == null || outputUrls.isEmpty()) {
return null;
}
return List.of(outputUrls.split(","));
}
/**
* 设置输出URL列表
*/
public void setOutputUrlList(List<String> outputUrls) {
this.outputUrls = JsonUtils.toJsonString(outputUrls);
this.outputUrls = outputUrls == null || outputUrls.isEmpty() ? null : String.join(",", outputUrls);
}
}

View File

@@ -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<MixTaskRespVO> getMixTaskPage(MixTaskPageReqVO pageReqVO, Long userId) {
log.debug("分页查询混剪任务用户ID: {}, 页码: {}, 页大小: {}",
userId, pageReqVO.getPageNo(), pageReqVO.getPageSize());
PageResult<MixTaskDO> pageResult = mixTaskMapper.selectPage(pageReqVO);
return BeanUtils.toBean(pageResult, MixTaskRespVO.class);
return convertToRespVO(pageResult);
}
@Override
public PageResult<MixTaskRespVO> getUserMixTaskPage(MixTaskPageReqVO pageReqVO, Long userId) {
log.debug("分页查询用户混剪任务用户ID: {}, 页码: {}, 页大小: {}",
userId, pageReqVO.getPageNo(), pageReqVO.getPageSize());
// 使用用户ID过滤查询
PageResult<MixTaskDO> 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<String> 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<String> 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<MixTaskRespVO> convertToRespVO(PageResult<MixTaskDO> pageResult) {
if (pageResult == null || pageResult.getList().isEmpty()) {
return PageResult.empty(pageResult.getTotal());
}
// 创建Map用于高效查找O(1)复杂度替代流操作的O(n)复杂度
Map<Long, MixTaskDO> 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());
}
});
}
}