混剪优化

This commit is contained in:
2025-12-07 00:10:22 +08:00
parent 0fffd787bb
commit 7f7551f74f
14 changed files with 479 additions and 113 deletions

78
.promptx/pouch.json Normal file
View File

@@ -0,0 +1,78 @@
{
"currentState": "initial",
"stateHistory": [
{
"from": "initial",
"command": "project",
"timestamp": "2025-12-06T14:49:15.887Z",
"args": [
{
"workingDirectory": "d:\\projects\\sionrui",
"ideType": "vscode"
}
]
},
{
"from": "initial",
"command": "action",
"timestamp": "2025-12-06T14:49:17.391Z",
"args": [
"nuwa"
]
},
{
"from": "initial",
"command": "toolx",
"timestamp": "2025-12-06T14:50:24.336Z",
"args": [
"@tool://role-creator",
"manual"
]
},
{
"from": "initial",
"command": "toolx",
"timestamp": "2025-12-06T14:51:32.727Z",
"args": [
"@tool://role-creator",
"execute",
"{\"role\":\"video-editing-pm\",\"action\":\"write\",\"file\":\"video-editing-pm.role.md\",\"content\":\"<role>\\n<personality>\\n我是一名专业的视频剪辑产品经理专注于AI驱动的智能剪辑工具和技术架构设计。\\n\\n核心特征\\n- 技术嗅觉敏锐快速理解新兴AI视频技术评估其商业价值和可行性\\n- 架构思维清晰:擅长设计工具组合方案,优化自动化流程\\n- 决策果断务实:基于数据和市场洞察做出清晰的产品决策\\n- 前瞻性强:关注行业趋势,预判技术发展方向\\n\\n思维模式\\n- 以技术可行性为前提,以用户体验为核心\\n- 平衡创新与稳定,评估风险与收益\\n- 从系统工程角度思考工具集成和流程优化\\n</personality>\\n\\n<principle>\\n1. 技术评估三原则:先进性、成熟度、兼容性\\n2. 决策透明化:清晰阐述决策依据和权衡过程\\n3. 用户价值导向:所有技术选择最终服务于用户需求\\n4. 快速迭代验证用MVP方式测试技术方案可行性\\n5. 架构可持续性:设计可扩展、可维护的技术架构\\n</principle>\\n\\n<knowledge>\\n视频剪辑技术栈\\n- AI视觉目标检测、场景识别、自动剪辑点检测\\n- AI听觉语音识别、音频分离、自动配乐\\n- 云渲染分布式渲染、GPU集群、实时预览\\n- 自动化工作流:任务编排、批量处理、监控告警\\n\\n主流AI剪辑工具\\n- Runway MLAI视频生成和编辑\\n- Descript基于文本的视频编辑\\n- Pictory自动化短视频生成\\n- SynthesiaAI虚拟人视频制作\\n- Lumen5AI驱动的内容转视频\\n\\n评估维度\\n- 技术能力AI模型精度、处理速度、稳定性\\n- 集成难度API友好度、文档完整性、SDK支持\\n- 成本结构:授权费用、计算资源、人力成本\\n- 市场接受度:用户规模、客户案例、行业口碑\\n</knowledge>\\n</role>\\n\"}"
]
},
{
"from": "initial",
"command": "toolx",
"timestamp": "2025-12-06T14:52:03.041Z",
"args": [
"@tool://role-creator",
"execute",
"{\"role\":\"video-editing-pm\",\"action\":\"write\",\"file\":\"thought/technical-analysis.thought.md\",\"content\":\"<thought>\\n<reasoning>\\n## 技术评估推理框架\\n\\n### 维度分析\\n**技术成熟度评估**\\n- 技术发展阶段:实验室/原型/商用/成熟\\n- 性能基准:准确率、速度、稳定性指标\\n- 资源需求:计算资源、存储需求、网络带宽\\n\\n**商业价值分析**\\n- 市场需求强度:痛点解决程度\\n- 竞争态势:市场空白、差异化优势\\n- 成本效益比投入产出比、ROI预测\\n\\n**集成复杂度评估**\\n- API可用性文档质量、调用限制\\n- 架构适配:与现有系统的兼容性\\n- 迁移成本:替换现有方案的成本\\n\\n### 决策矩阵构建\\n对每个技术方案从以下维度打分1-5分\\n1. 技术先进性(创新程度、技术领先性)\\n2. 成熟度(稳定性、案例验证)\\n3. 集成难度(实施复杂度、时间成本)\\n4. 成本可控性(授权费、运维成本)\\n5. 市场接受度(用户需求、行业趋势)\\n\\n权重建议技术40%、成熟度30%、集成20%、成本10%\\n</reasoning>\\n</thought>\\n\"}"
]
},
{
"from": "initial",
"command": "toolx",
"timestamp": "2025-12-06T14:52:04.464Z",
"args": [
"@tool://role-creator",
"execute",
"{\"role\":\"video-editing-pm\",\"action\":\"write\",\"file\":\"execution/architecture-design.execution.md\",\"content\":\"<execution>\\n<process>\\n## 视频剪辑技术架构设计流程\\n\\n### Step 1: 需求分析\\n1. 明确业务场景和用户画像\\n2. 识别核心痛点和功能需求\\n3. 定义性能指标和SLA要求\\n4. 确定技术约束和边界条件\\n\\n### Step 2: 技术调研\\n1. 评估AI技术栈能力边界\\n2. 分析主流工具和平台特性\\n3. 研究竞品技术架构方案\\n4. 识别技术趋势和未来方向\\n\\n### Step 3: 方案设计\\n1. 构建工具组合方案\\n2. 设计自动化工作流程\\n3. 规划系统集成策略\\n4. 制定容错和降级机制\\n\\n### Step 4: 评估决策\\n1. 多维度打分和权重计算\\n2. 风险识别和缓解策略\\n3. 成本效益分析和ROI预测\\n4. 选择最优方案和备选方案\\n\\n### Step 5: 实施规划\\n1. 制定技术选型决策文档\\n2. 规划分阶段实施路线图\\n3. 定义成功指标和验证标准\\n4. 制定监控和优化机制\\n</process>\\n</execution>\\n\"}"
]
},
{
"from": "initial",
"command": "discover",
"timestamp": "2025-12-06T14:52:39.427Z",
"args": []
},
{
"from": "initial",
"command": "action",
"timestamp": "2025-12-06T15:01:23.095Z",
"args": [
"video-editing-pm"
]
}
],
"lastUpdated": "2025-12-06T15:01:23.101Z"
}

View File

@@ -0,0 +1,17 @@
{
"version": "2.0.0",
"source": "project",
"metadata": {
"version": "2.0.0",
"description": "project 级资源注册表",
"createdAt": "2025-12-06T14:52:39.437Z",
"updatedAt": "2025-12-06T14:52:39.438Z",
"resourceCount": 0
},
"resources": [],
"stats": {
"totalResources": 0,
"byProtocol": {},
"bySource": {}
}
}

View File

@@ -65,6 +65,13 @@ export const MixTaskService = {
*/
cancelTask(id) {
return http.post(`${BASE_URL}/cancel/${id}`)
},
/**
* 获取任务输出文件的签名URL
*/
getSignedUrls(id) {
return http.get(`${BASE_URL}/signed-url/${id}`)
}
}

View File

@@ -533,7 +533,6 @@ const loadLastTask = async () => {
if (task.status === 'SUCCESS' && task.resultVideoUrl) {
previewVideoUrl.value = task.resultVideoUrl
currentTaskStatus.value = 'SUCCESS'
message.success('已自动加载最近一次任务结果')
} else if (task.status === 'PROCESSING') {
// 如果任务还在处理中,继续轮询
currentTaskStatus.value = 'PROCESSING'

View File

@@ -877,7 +877,6 @@ const loadLastTask = async () => {
previewVideoUrl.value = task.resultVideoUrl
currentTaskStatus.value = 'SUCCESS'
currentTaskError.value = ''
message.success('已自动加载最近一次任务结果')
} else if (task.status === 'PROCESSING') {
// 如果任务还在处理中,继续轮询
currentTaskStatus.value = 'PROCESSING'

View File

@@ -119,23 +119,16 @@
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button
v-if="record.outputUrls && record.outputUrls.length > 0"
v-if="record.status === 'success' && record.outputUrls && record.outputUrls.length > 0"
type="primary"
size="small"
@click="handleDownloadAll(record.outputUrls)"
@click="handleDownloadAll(record.id)"
>
<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"
@@ -171,17 +164,25 @@
:key="index"
class="result-item"
>
<a :href="url" target="_blank">
<a-button
v-if="record.status === 'success'"
type="link"
@click="handlePreviewSignedUrl(record.id, index)"
>
<PlayCircleOutlined />
视频 {{ index + 1 }}
</a>
</a-button>
<a-button
v-if="record.status === 'success'"
type="link"
size="small"
@click="handleDownload(url)"
@click="handleDownloadSignedUrl(record.id, index)"
>
<DownloadOutlined />
</a-button>
<span v-else class="processing-tip">
视频 {{ index + 1 }} (处理中...)
</span>
</div>
</div>
</div>
@@ -203,7 +204,7 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
ReloadOutlined,
@@ -218,6 +219,7 @@ import { formatDate } from '@/utils/file'
const loading = ref(false)
const taskList = ref([])
const expandedRowKeys = ref([])
const refreshInterval = ref(null) // 定时刷新定时器
// 表格列定义
const columns = [
@@ -429,25 +431,73 @@ const handleDownload = (url) => {
document.body.removeChild(link)
}
// 批量下载所有视频
const handleDownloadAll = (urls) => {
if (!urls || urls.length === 0) {
message.warning('没有可下载的视频')
return
// 下载单个视频使用签名URL
const handlePreviewSignedUrl = async (taskId, index) => {
try {
message.loading('正在获取预览链接...', 0)
const res = await MixTaskService.getSignedUrls(taskId)
message.destroy()
if (res.code === 0 && res.data && res.data[index]) {
window.open(res.data[index], '_blank')
} else {
message.warning('获取预览链接失败')
}
} catch (error) {
console.error('获取预览链接失败:', error)
message.destroy()
message.error('获取预览链接失败')
}
}
message.loading('正在准备下载...', 0)
const handleDownloadSignedUrl = async (taskId, index) => {
try {
message.loading('正在获取下载链接...', 0)
const res = await MixTaskService.getSignedUrls(taskId)
message.destroy()
// 逐个触发下载,避免浏览器阻止多个弹窗
urls.forEach((url, index) => {
setTimeout(() => {
console.log('下载视频:', url)
handleDownload(url)
}, index * 500) // 每个下载间隔500ms
})
if (res.code === 0 && res.data && res.data[index]) {
handleDownload(res.data[index])
} else {
message.warning('获取下载链接失败')
}
} catch (error) {
console.error('获取下载链接失败:', error)
message.destroy()
message.error('获取下载链接失败')
}
}
message.destroy()
message.success(`已触发 ${urls.length} 个视频的下载`)
// 批量下载所有视频
const handleDownloadAll = async (taskId) => {
try {
message.loading('正在获取下载链接...', 0)
const res = await MixTaskService.getSignedUrls(taskId)
message.destroy()
if (res.code === 0 && res.data && res.data.length > 0) {
const urls = res.data
message.loading('正在准备下载...', 0)
// 逐个触发下载,避免浏览器阻止多个弹窗
urls.forEach((url, index) => {
setTimeout(() => {
console.log('下载视频:', url)
handleDownload(url)
}, index * 500) // 每个下载间隔500ms
})
message.destroy()
message.success(`已触发 ${urls.length} 个视频的下载`)
} else {
message.warning('没有可下载的视频')
}
} catch (error) {
console.error('获取下载链接失败:', error)
message.destroy()
message.error('获取下载链接失败')
}
}
// 获取状态文本
@@ -486,7 +536,35 @@ const getProgressStatus = (status) => {
// 初始化
onMounted(() => {
loadTaskList()
// 开启定时刷新每30秒检查一次running状态的任务
startAutoRefresh()
})
// 组件卸载时清理定时器
onUnmounted(() => {
stopAutoRefresh()
})
// 开启自动刷新
const startAutoRefresh = () => {
// 清除可能存在的旧定时器
stopAutoRefresh()
refreshInterval.value = setInterval(() => {
// 只在页面可见时刷新,避免后台浪费资源
if (document.visibilityState === 'visible') {
loadTaskList()
}
}, 30000) // 30秒刷新一次
}
// 停止自动刷新
const stopAutoRefresh = () => {
if (refreshInterval.value) {
clearInterval(refreshInterval.value)
refreshInterval.value = null
}
}
</script>
<style scoped>
@@ -578,6 +656,11 @@ onMounted(() => {
font-size: 13px;
}
.processing-tip {
color: var(--color-text-secondary);
font-size: 12px;
}
.task-error {
margin-bottom: 8px;
}

View File

@@ -85,4 +85,12 @@ public interface FileService {
*/
byte[] getFileContent(Long configId, String path) throws Exception;
/**
* 根据OSS URL删除文件
*
* @param ossUrl OSS文件完整URL
* @throws Exception 删除失败时抛出异常
*/
void deleteFileByUrl(String ossUrl) throws Exception;
}

View File

@@ -18,6 +18,7 @@ import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils;
import com.google.common.annotations.VisibleForTesting;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
@@ -31,6 +32,7 @@ import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_NOT_EX
*
* @author 芋道源码
*/
@Slf4j
@Service
public class FileServiceImpl implements FileService {
@@ -198,4 +200,44 @@ public class FileServiceImpl implements FileService {
return client.getContent(path);
}
@Override
public void deleteFileByUrl(String ossUrl) throws Exception {
if (StrUtil.isEmpty(ossUrl)) {
return;
}
// 从URL中提取相对路径
// 例如: https://bucket.oss-region.aliyuncs.com/mix/20241206/video.mp4
// 需要提取出: mix/20241206/video.mp4
String path = extractPathFromUrl(ossUrl);
if (StrUtil.isEmpty(path)) {
log.warn("[FileService][删除文件][URL解析路径失败] ossUrl={}", ossUrl);
return;
}
// 使用默认的master客户端删除文件
FileClient client = fileConfigService.getMasterFileClient();
Assert.notNull(client, "客户端(master) 不能为空");
client.delete(path);
log.info("[FileService][删除文件成功] path={}, ossUrl={}", path, ossUrl);
}
/**
* 从OSS URL中提取相对路径
*/
private String extractPathFromUrl(String ossUrl) {
if (StrUtil.isEmpty(ossUrl)) {
return null;
}
// 查找 ".aliyuncs.com/" 的位置
int index = ossUrl.indexOf(".aliyuncs.com/");
if (index == -1) {
return null;
}
// 提取 "/" 后的路径
return ossUrl.substring(index + ".aliyuncs.com/".length());
}
}

View File

@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.tik.media;
import cn.iocoder.yudao.module.infra.api.file.FileApi;
import cn.iocoder.yudao.module.tik.file.service.TikOssInitService;
import cn.iocoder.yudao.module.tik.mix.config.IceProperties;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
@@ -36,6 +37,7 @@ public class BatchProduceAlignment {
private final IceProperties properties;
private final FileApi fileApi;
private final TikOssInitService ossInitService;
private Client iceClient;
public void initClient() throws Exception {
@@ -60,7 +62,7 @@ public class BatchProduceAlignment {
}
}
public List<String> batchProduceAlignment(String title, String[] videoArray, int produceCount) throws Exception {
public List<String> batchProduceAlignment(String title, String[] videoArray, int produceCount, Long userId) throws Exception {
// 初始化 ICE 客户端
if (iceClient == null) {
initClient();
@@ -71,7 +73,7 @@ public class BatchProduceAlignment {
if (produceCount <= 1) {
// 生成1个视频包含所有片段
String jobIdWithUrl = produceSingleVideo(title, videoArray);
String jobIdWithUrl = produceSingleVideo(title, videoArray, userId);
jobIdWithUrls.add(jobIdWithUrl);
} else {
// 生成多个视频:将视频数组分成多份,每份生成一个视频
@@ -90,7 +92,7 @@ public class BatchProduceAlignment {
String[] groupVideos = Arrays.copyOfRange(videoArray, start, start + groupSize);
// 生成单个视频
String jobIdWithUrl = produceSingleVideo(title, groupVideos);
String jobIdWithUrl = produceSingleVideo(title, groupVideos, userId);
jobIdWithUrls.add(jobIdWithUrl);
start += groupSize;
@@ -101,7 +103,7 @@ public class BatchProduceAlignment {
return jobIdWithUrls;
}
public String produceSingleVideo(String title, String[] videoArray) throws Exception {
public String produceSingleVideo(String title, String[] videoArray, Long userId) throws Exception {
// 初始化 ICE 客户端
if (iceClient == null) {
initClient();
@@ -113,7 +115,6 @@ public class BatchProduceAlignment {
// 按顺序拼接视频片段(不随机打乱)
for (int i = 0; i < videoArray.length; i++) {
String clipId = "clip" + i;
String videoUrl = videoArray[i];
// 验证视频URL必须是阿里云OSS地址
@@ -130,32 +131,33 @@ public class BatchProduceAlignment {
// 为每个视频片段添加静音的音频轨道
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);
audioClip.put("Effects", new JSONArray() {{
add(new JSONObject() {{
put("Type", "Volume");
put("Gain", 0); // 0 表示静音
}});
}});
audioClipArray.add(audioClip);
}
// 构建时间线,包含视频轨道和音频轨道
String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":"+videoClipArray.toJSONString()+"}],\"AudioTracks\":[{\"AudioTrackClips\":"+audioClipArray.toJSONString()+"}]}";
//
// 生成输出文件路径:{用户目录}/mix/{yyyyMMdd}/{uuid}.mp4
String targetFileName = UUID.randomUUID().toString().replace("-", "");
String mixDirectory = ossInitService.getOssDirectoryByCategory(userId, "mix");
String dateDir = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"));
String outputMediaPath = mixDirectory + "/" + dateDir + "/" + targetFileName + ".mp4";
// 使用默认的阿里云OSS endpoint格式
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);
String outputMediaUrl = bucketEndpoint + "/" + outputMediaPath;
// ICE需要将处理结果写入到该URL签名URL会导致写入失败
int width = 720;
int height = 1280;
int bitrate = 2000; // 输出码率 2000 Kbit/s符合 ICE API 文档推荐)
String outputMediaConfig = "{\"MediaURL\":\"" + signedUrl + "\",\"Width\":" + width + ",\"Height\":" + height + ",\"Bitrate\":" + bitrate + "}";
String outputMediaConfig = "{\"MediaURL\":\"" + outputMediaUrl + "\",\"Width\":" + width + ",\"Height\":" + height + ",\"Bitrate\":" + bitrate + "}";
SubmitMediaProducingJobRequest request = new SubmitMediaProducingJobRequest();
request.setTimeline(timeline);
@@ -166,6 +168,7 @@ public class BatchProduceAlignment {
String jobId = response.getBody().getJobId();
log.info("[ICE][任务提交成功][jobId={}, outputMediaUrl={}]", jobId, outputMediaUrl);
// 返回原始URL签名URL在用户下载时生成
return jobId + " : " + outputMediaUrl;
}

View File

@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.tik.media.controller;
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.tik.media.BatchProduceAlignment;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -31,8 +32,14 @@ public class BatchProduceAlignmentController {
// 生成的成片数
int produceCount) {
try {
// 获取当前登录用户ID
Long userId = SecurityFrameworkUtils.getLoginUserId();
if (userId == null) {
return CommonResult.error(new ErrorCode(401, "用户未登录"));
}
// 纯画面模式仅传入视频数组无需text和bgMusicArray
List<String> jobIds = batchProduceAlignment.batchProduceAlignment(title, videoArray, produceCount);
List<String> jobIds = batchProduceAlignment.batchProduceAlignment(title, videoArray, produceCount, userId);
return CommonResult.success(jobIds);
} catch (Exception e) {
return CommonResult.error(new ErrorCode(500, "Produce failed. Exception: " + e));

View File

@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.tik.mix.controller;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
@@ -14,6 +15,9 @@ import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.ArrayList;
import java.util.List;
/**
* 混剪任务 Controller
*
@@ -98,4 +102,38 @@ public class MixTaskController {
mixTaskService.cancelTask(id);
return CommonResult.success(true);
}
@GetMapping("/signed-url/{id}")
@Operation(summary = "获取任务输出文件的签名URL")
public CommonResult<List<String>> getSignedUrls(@PathVariable Long id) {
try {
// 获取任务信息
MixTaskRespVO mixTaskVO = mixTaskService.getMixTask(id);
if (mixTaskVO == null || mixTaskVO.getOutputUrls() == null) {
return CommonResult.success(List.of());
}
// 重新生成所有输出URL的签名URL避免使用过期的签名URL
List<String> signedUrls = new ArrayList<>();
for (String outputUrl : mixTaskVO.getOutputUrls()) {
try {
// 每次都重新生成签名URL有效期24小时
String signedUrl = mixTaskService.generateSignedUrl(outputUrl, 24 * 3600);
if (StrUtil.isNotEmpty(signedUrl)) {
signedUrls.add(signedUrl);
}
} catch (Exception e) {
log.error("[MixTask][生成签名URL失败] url={}, error={}", outputUrl, e.getMessage(), e);
// 单个URL失败不影响其他URL
}
}
log.info("[MixTask][获取签名URL成功] taskId={}, urlCount={}", id, signedUrls.size());
return CommonResult.success(signedUrls);
} catch (Exception e) {
log.error("[MixTask][获取签名URL失败] taskId={}, error={}", id, e.getMessage(), e);
// 不返回详细错误信息给前端避免XML/HTML解析问题
return CommonResult.error(500, "获取签名URL失败");
}
}
}

View File

@@ -71,4 +71,13 @@ public interface MixTaskService {
* 保存任务结果
*/
void saveTaskResult(Long taskId, List<String> outputUrls);
/**
* 生成签名URL
*
* @param ossUrl OSS文件完整URL
* @param expirationSeconds 有效期(秒)
* @return 签名URL
*/
String generateSignedUrl(String ossUrl, Integer expirationSeconds);
}

View File

@@ -2,6 +2,8 @@ package cn.iocoder.yudao.module.tik.mix.service;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.infra.service.file.FileService;
import cn.iocoder.yudao.module.tik.mix.client.IceClient;
import cn.iocoder.yudao.module.tik.mix.constants.MixTaskConstants;
import cn.iocoder.yudao.module.tik.mix.dal.dataobject.MixTaskDO;
@@ -9,6 +11,7 @@ import cn.iocoder.yudao.module.tik.mix.dal.mysql.MixTaskMapper;
import cn.iocoder.yudao.module.tik.mix.util.MixTaskUtils;
import cn.iocoder.yudao.module.tik.mix.vo.*;
import cn.iocoder.yudao.module.tik.media.BatchProduceAlignment;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -16,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@@ -36,6 +40,9 @@ public class MixTaskServiceImpl implements MixTaskService {
private final BatchProduceAlignment batchProduceAlignment;
private final IceClient iceClient;
@Resource
private FileService fileService;
@Override
@Transactional(rollbackFor = Exception.class)
public Long createMixTask(MixTaskSaveReqVO createReqVO, Long userId) {
@@ -51,9 +58,9 @@ public class MixTaskServiceImpl implements MixTaskService {
// 3. 异步提交到阿里云 ICE
CompletableFuture.runAsync(() -> {
try {
submitToICE(task.getId(), createReqVO);
submitToICE(task.getId(), createReqVO, userId);
} catch (Exception e) {
log.error("[MixTask][提交ICE失败] taskId={}, error={}", task.getId(), e.getMessage(), e);
log.error("[MixTask][提交ICE失败] taskId={}", task.getId(), e);
updateTaskError(task.getId(), "提交任务失败: " + e.getMessage());
}
});
@@ -88,7 +95,24 @@ public class MixTaskServiceImpl implements MixTaskService {
return;
}
// 2. 执行删除
// 2. 删除OSS文件
try {
List<String> outputUrls = existTask.getOutputUrlList();
if (outputUrls != null && !outputUrls.isEmpty()) {
log.info("[MixTask][删除OSS文件] taskId={}, fileCount={}", id, outputUrls.size());
for (String url : outputUrls) {
try {
fileService.deleteFileByUrl(url);
} catch (Exception e) {
log.error("[MixTask][删除单个OSS文件失败] taskId={}, url={}", id, url, e);
}
}
}
} catch (Exception e) {
log.error("[MixTask][删除OSS文件失败] taskId={}", id, e);
}
// 3. 执行删除
mixTaskMapper.deleteById(id);
}
@@ -149,9 +173,9 @@ public class MixTaskServiceImpl implements MixTaskService {
saveReqVO.setTitle(existTask.getTitle());
saveReqVO.setVideoUrls(existTask.getVideoUrlList());
saveReqVO.setProduceCount(existTask.getProduceCount());
submitToICE(id, saveReqVO);
submitToICE(id, saveReqVO, existTask.getUserId());
} catch (Exception e) {
log.error("[MixTask][重新提交失败] taskId={}, error={}", id, e.getMessage(), e);
log.error("[MixTask][重新提交失败] taskId={}", id, e);
updateTaskError(id, "重新提交失败: " + e.getMessage());
}
});
@@ -218,7 +242,7 @@ public class MixTaskServiceImpl implements MixTaskService {
syncTaskStatus(task.getId(), jobId);
}
} catch (Exception e) {
log.error("[MixTask][单个任务检查失败] taskId={}, error={}", task.getId(), e.getMessage(), e);
log.error("[MixTask][单个任务检查失败] taskId={}", task.getId(), e);
failureCount++;
// 单个任务失败不影响其他任务
}
@@ -247,8 +271,10 @@ public class MixTaskServiceImpl implements MixTaskService {
// 根据ICE状态更新任务
if ("Success".equalsIgnoreCase(status) || "success".equalsIgnoreCase(status)) {
// 任务成功完成,更新为100%
updateTaskStatus(taskId, MixTaskConstants.STATUS_SUCCESS, MixTaskConstants.PROGRESS_COMPLETED);
// 任务成功完成,需要获取实际的输出URL
log.info("[MixTask][ICE任务执行成功] taskId={}, jobId={}", taskId, jobId);
// 重新生成签名URL并更新任务
updateTaskSuccess(taskId, jobId);
} else if ("Failed".equalsIgnoreCase(status) || "failed".equalsIgnoreCase(status) || "Failure".equalsIgnoreCase(status)) {
// 任务失败 - 获取详细错误信息
String errorMsg = "ICE任务执行失败";
@@ -262,18 +288,38 @@ public class MixTaskServiceImpl implements MixTaskService {
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);
// 任务仍在运行,逐步更新进度
// 1. 获取当前进度
MixTaskDO currentTask = mixTaskMapper.selectById(taskId);
if (currentTask != null) {
int currentProgress = currentTask.getProgress() != null ? currentTask.getProgress() : 0;
// 2. 根据当前进度逐步提升但不超过95%
int newProgress;
if (currentProgress < 50) {
newProgress = 50; // 首次看到Running时更新到50%
} else if (currentProgress < 80) {
newProgress = 80; // 第二次更新到80%
} else if (currentProgress < 95) {
newProgress = 95; // 最后更新到95%
} else {
newProgress = currentProgress; // 已经很高了,保持不变
}
if (newProgress != currentProgress) {
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, newProgress);
log.info("[MixTask][进度更新] taskId={}, from={}%, to={}%", taskId, currentProgress, newProgress);
}
}
} else if ("Pending".equalsIgnoreCase(status) || "pending".equalsIgnoreCase(status)) {
// 任务等待中,更新进度为60%
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, 60);
// 任务等待中,更新进度为30%
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, 30);
} else {
// 未知状态,记录日志但不更新
log.warn("[MixTask][未知ICE状态] taskId={}, jobId={}, iceStatus={}", taskId, jobId, status);
}
} catch (Exception e) {
log.error("[MixTask][状态同步异常] taskId={}, jobId={}, error={}", taskId, jobId, e.getMessage(), e);
log.error("[MixTask][状态同步异常] taskId={}, jobId={}", taskId, jobId, e);
updateTaskError(taskId, "查询任务状态失败: " + e.getMessage());
}
}
@@ -291,75 +337,49 @@ public class MixTaskServiceImpl implements MixTaskService {
mixTaskMapper.updateById(updateTask);
}
@Override
public String generateSignedUrl(String ossUrl, Integer expirationSeconds) {
try {
if (StrUtil.isEmpty(ossUrl)) {
return null;
}
// 使用FileService生成签名URL
return fileService.presignGetUrl(ossUrl, expirationSeconds);
} catch (Exception e) {
log.error("[MixTask][生成签名URL失败] url={}", ossUrl, e);
return null;
}
}
/**
* 提交任务到阿里云 ICE
*/
private void submitToICE(Long taskId, MixTaskSaveReqVO createReqVO) {
private void submitToICE(Long taskId, MixTaskSaveReqVO createReqVO, Long userId) {
try {
// 1. 更新任务状态为运行中进度10%
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, MixTaskConstants.PROGRESS_SUBMITTED);
// 2. 转换为ICE需要的参数格式
// 1. 转换为ICE需要的参数格式
String[] videoArray = createReqVO.getVideoUrls().toArray(new String[0]);
// 3. 调用ICE批量生成接口纯画面模式无需text和bgMusic
// 2. 调用ICE批量生成接口纯画面模式无需text和bgMusic
List<String> jobIdWithUrls = batchProduceAlignment.batchProduceAlignment(
createReqVO.getTitle(),
videoArray,
createReqVO.getProduceCount()
createReqVO.getProduceCount(),
userId
);
// 4. 解析jobId和输出URL
// 3. 解析jobId和输出URL
MixTaskUtils.JobIdUrlPair jobIdUrlPair = MixTaskUtils.parseJobIdsAndUrls(jobIdWithUrls);
// 5. 更新任务信息
// 4. 更新任务信息(包含状态和进度)
updateTaskWithResults(taskId, jobIdUrlPair.getJobIds(), jobIdUrlPair.getOutputUrls(),
MixTaskConstants.STATUS_RUNNING, MixTaskConstants.PROGRESS_UPLOADED);
} catch (Exception e) {
log.error("[MixTask][ICE提交失败] taskId={}, error={}", taskId, e.getMessage(), e);
updateTaskError(taskId, "ICE处理失败: " + e.getMessage());
// 注意:异步线程中不抛出异常,避免未处理异常
}
}
/**
* 提交任务到阿里云 ICE新版本使用 IceClient
*/
private void submitToIceV2(Long taskId, MixTaskSaveReqVO createReqVO) {
try {
// 1. 更新任务状态
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, MixTaskConstants.PROGRESS_SUBMITTED);
// 2. 构建 timeline 和 outputMediaConfig
String timeline = buildTimeline(createReqVO);
String outputMediaConfig = buildOutputMediaConfig();
// 3. 提交任务
// TODO: 这里需要将 BatchProduceAlignment 的逻辑移到 IceClient 中
// String jobId = iceClient.submitMediaProducingJob(timeline, outputMediaConfig);
} catch (Exception e) {
log.error("[MixTask][V2提交失败] taskId={}, error={}", taskId, e.getMessage(), e);
log.error("[MixTask][ICE提交失败] taskId={}", taskId, e);
updateTaskError(taskId, "ICE处理失败: " + e.getMessage());
}
}
/**
* 构建 ICE 时间线
*/
private String buildTimeline(MixTaskSaveReqVO reqVO) {
// TODO: 实现 timeline 构建逻辑
return "{}";
}
/**
* 构建输出媒体配置
*/
private String buildOutputMediaConfig() {
// TODO: 实现输出配置构建逻辑
return "{}";
}
/**
* 更新任务状态
@@ -399,6 +419,62 @@ public class MixTaskServiceImpl implements MixTaskService {
mixTaskMapper.updateById(updateTask);
}
/**
* 更新任务成功状态重新生成签名URL
*/
private void updateTaskSuccess(Long taskId, String jobId) {
try {
// 查询任务信息
MixTaskDO task = mixTaskMapper.selectById(taskId);
if (task == null) {
log.warn("[MixTask][任务不存在] taskId={}", taskId);
return;
}
// 获取原始输出URL不是签名URL
List<String> outputUrls = task.getOutputUrlList();
if (outputUrls == null || outputUrls.isEmpty()) {
log.warn("[MixTask][任务成功但没有输出URL] taskId={}", taskId);
updateTaskStatus(taskId, MixTaskConstants.STATUS_SUCCESS, MixTaskConstants.PROGRESS_COMPLETED);
return;
}
// 任务成功时直接保存原始URL不是签名URL
// 签名URL在用户下载时通过 /signed-url/{id} 接口动态生成
MixTaskDO updateTask = new MixTaskDO();
updateTask.setId(taskId);
updateTask.setStatus(MixTaskConstants.STATUS_SUCCESS);
updateTask.setProgress(MixTaskConstants.PROGRESS_COMPLETED);
updateTask.setOutputUrlList(outputUrls);
updateTask.setFinishTime(java.time.LocalDateTime.now());
mixTaskMapper.updateById(updateTask);
log.info("[MixTask][任务成功更新] taskId={}, urlCount={}", taskId, outputUrls.size());
} catch (Exception e) {
log.error("[MixTask][更新任务成功状态失败] taskId={}", taskId, e);
// 即使更新失败,也标记为成功
updateTaskStatus(taskId, MixTaskConstants.STATUS_SUCCESS, MixTaskConstants.PROGRESS_COMPLETED);
}
}
/**
* 从OSS URL中提取相对路径
*/
private String extractPathFromUrl(String ossUrl) {
if (StrUtil.isEmpty(ossUrl)) {
return null;
}
// 查找 ".aliyuncs.com/" 的位置
int index = ossUrl.indexOf(".aliyuncs.com/");
if (index == -1) {
return null;
}
// 提取 "/" 后的路径
return ossUrl.substring(index + ".aliyuncs.com/".length());
}
/**
* 将DO分页结果转换为VO分页结果消除冗余代码
* 优化点:

View File

@@ -12,8 +12,8 @@ spring:
servlet:
# 文件上传相关配置项
multipart:
max-file-size: 100MB # 单个文件大小配音文件建议50MB以内
max-request-size: 200MB # 设置总上传的文件大小(支持多文件上传)
max-file-size: 300MB # 单个文件大小
max-request-size: 500MB # 设置总上传的文件大小(支持多文件上传)
# Jackson 配置项
jackson: