package cn.iocoder.yudao.module.digitalcourse.service.coursemedia; import cn.hutool.core.io.FileUtil; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; import cn.iocoder.yudao.module.digitalcourse.controller.admin.coursemedia.vo.*; import cn.iocoder.yudao.module.digitalcourse.dal.dataobject.coursemedia.CourseMediaDO; import cn.iocoder.yudao.module.digitalcourse.dal.mysql.coursemedia.CourseMediaMapper; import cn.iocoder.yudao.module.digitalcourse.manager.MediaTaskManager; import cn.iocoder.yudao.module.digitalcourse.model.MediaTask; import cn.iocoder.yudao.module.infra.api.config.ConfigApi; import cn.iocoder.yudao.module.infra.api.file.FileApi; import com.alibaba.fastjson.JSON; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import java.io.*; import java.text.SimpleDateFormat; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Date; import java.util.List; import static cn.iocoder.yudao.module.digitalcourse.enums.ErrorCodeConstants.COURSE_MEDIA_NOT_EXISTS; /** * 课程媒体 Service 实现类 * * @author 芋道源码 */ @Service @Validated @Slf4j public class CourseMediaServiceImpl implements CourseMediaService { @Resource private CourseMediaMapper courseMediaMapper; @Resource private CourseMediaServiceUtil courseMediaServiceUtil; @Resource private FileApi fileApi; @Override public Long createCourseMedia(CourseMediaSaveReqVO createReqVO) { // 插入 CourseMediaDO courseMedia = BeanUtils.toBean(createReqVO, CourseMediaDO.class); courseMediaMapper.insert(courseMedia); // 返回 return courseMedia.getId(); } @Override public void updateCourseMedia(CourseMediaSaveReqVO updateReqVO) { // 校验存在 validateCourseMediaExists(updateReqVO.getId()); // 更新 CourseMediaDO updateObj = BeanUtils.toBean(updateReqVO, CourseMediaDO.class); courseMediaMapper.updateById(updateObj); } @Override public void deleteCourseMedia(Long id) { // 校验存在 validateCourseMediaExists(id); // 删除 courseMediaMapper.deleteById(id); } private void validateCourseMediaExists(Long id) { if (courseMediaMapper.selectById(id) == null) { throw exception(COURSE_MEDIA_NOT_EXISTS); } } @Override public CourseMediaDO getCourseMedia(Long id) { return courseMediaMapper.selectById(id); } @Override public PageResult getCourseMediaPage(CourseMediaPageReqVO pageReqVO) { PageResult courseMediaDOPageResult = courseMediaMapper.selectPage(pageReqVO); for (CourseMediaDO courseMediaDO : courseMediaDOPageResult.getList()) { if (courseMediaDO.getStatus() == 1 || courseMediaDO.getStatus() == 0) { //视频合成中 查询排队和合成进度 Long id = courseMediaDO.getCourseId(); int pos = mediaTaskManager.getQueuePosition(id); if (pos == -1) { //不在队列中,说明已经合成完成 courseMediaDO.setStatus(3); courseMediaMapper.updateById(courseMediaDO); } if (pos == 0) { //正在合成中 String reqJson = courseMediaDO.getReqJson(); CourseMediaMegerVO courseMediaMegerVO = JSON.parseObject(reqJson, CourseMediaMegerVO.class); int size = courseMediaMegerVO.getScenes().size(); String s = configApi.getConfigValueByKey(HEYGEM_FACE2FACE) + "/temp/"; //查询s下面的文件 File folder = new File(s); int count = 0; if (folder.exists() && folder.isDirectory()) { File[] files = folder.listFiles(); if (files != null) { for (File file : files) { if (file.isFile() && file.getName().endsWith("-r.mp4")) { count++; System.out.println("匹配文件: " + file.getName()); } } } System.out.println("总计匹配 -r.mp4 文件数量: " + count); } else { System.out.println("路径不存在或不是目录"); } if (count+1>size){ courseMediaDO.setProgressVideo((count) + "/" + size); }else{ courseMediaDO.setProgressVideo((count)+"/"+size); } } courseMediaDO.setPos(pos); } } return courseMediaDOPageResult; } private static final String HEYGEM_FACE2FACE = "heygem.face2face"; @Resource private ConfigApi configApi; @Override public CommonResult megerMedia(CourseMediaMegerVO updateReqVO) { Long id = updateReqVO.getId(); CourseMediaDO courseMediaDO = courseMediaMapper.selectOne(new QueryWrapperX().lambda().eq(CourseMediaDO::getCourseId,id).in(CourseMediaDO::getStatus,0,1)); if (courseMediaDO == null){ courseMediaDO = new CourseMediaDO(); courseMediaDO.setCourseId(id); courseMediaDO.setStatus(0); courseMediaDO.setMediaType(1); courseMediaDO.setName(updateReqVO.getName()); courseMediaDO.setCourseName(updateReqVO.getName()); // courseMediaDO.setAuditVo(updateReqVO.getAuditionVo()); //将updateReqVO 转换为json字符串 courseMediaDO.setReqJson(JSON.toJSONString(updateReqVO)); courseMediaMapper.insert(courseMediaDO); }else{ return CommonResult.error(BAD_REQUEST.getCode(),"已存在合成中视频,不允许重复合成"); } updateReqVO.setCourseMediaId(courseMediaDO.getId()); //异步调用数字人视频渲染接口,开始合并 MediaTask task = new MediaTask(id, updateReqVO, LocalDateTime.now()); int pos = 0; try { pos = mediaTaskManager.submitTask(task); } catch (IOException e) { throw new RuntimeException(e); } return CommonResult.success("合成视频提交成功,您排在第 " + (pos+1) + " 个"); } @Resource private MediaTaskManager mediaTaskManager; @Override public CommonResult reMegerMedia(CourseMediaMegerVO updateReqVO) { Long id = updateReqVO.getId(); CourseMediaDO courseMediaDO = courseMediaMapper.selectById(updateReqVO.getId()); if (courseMediaDO == null){ return CommonResult.error(BAD_REQUEST.getCode(),"未查询到合成视频记录"); } if (3!=courseMediaDO.getStatus()){ return CommonResult.error(BAD_REQUEST.getCode(),"只有失败状态视频允许重新合成视频"); } //异步调用数字人视频渲染接口,开始合并 Boolean success = courseMediaServiceUtil.reMegerMedia(courseMediaDO); if(success) { return CommonResult.success(true); } else { return CommonResult.error(BAD_REQUEST.getCode(),"视频重新合成失败"); } } @Override public CommonResult createSubtitles(CourseMediaSubtitlesReqVO courseMediaSubtitlesReqVO) { CourseMediaDO courseMediaDO1 = courseMediaMapper.selectOne(new QueryWrapperX().lambda().eq(CourseMediaDO::getId, courseMediaSubtitlesReqVO.getId())); if (courseMediaDO1.getSubtitlesStatus()!= null && courseMediaDO1.getSubtitlesStatus() == 1) { throw new RuntimeException("字幕生成中,请勿重复提交"); } CourseMediaDO courseMediaDO = new CourseMediaDO(); courseMediaDO.setId(courseMediaSubtitlesReqVO.getId()); courseMediaDO.setSubtitlesStatus(1); courseMediaMapper.updateById(courseMediaDO); courseMediaServiceUtil.createSubtitles(courseMediaSubtitlesReqVO); return CommonResult.success("视频字幕生成中,请稍后查看"); } @Override public CommonResult createSubtitlesVideo(CourseMediaSubtitlesReqVO courseMediaSubtitlesReqVO) { CourseMediaDO courseMediaDO1 = courseMediaMapper.selectOne(new QueryWrapperX().lambda().eq(CourseMediaDO::getId, courseMediaSubtitlesReqVO.getId()).eq(CourseMediaDO::getSubtitlesStatus, 2)); if (courseMediaDO1 == null) { return CommonResult.error(BAD_REQUEST.getCode(), "字幕文件不存在或未生成"); } CourseMediaDO courseMediaDO = new CourseMediaDO(); courseMediaDO.setId(courseMediaSubtitlesReqVO.getId()); courseMediaDO.setSubtitlesAddStatus(1); courseMediaMapper.updateById(courseMediaDO); courseMediaServiceUtil.createSubtitlesVideo(courseMediaDO1); return CommonResult.success("视频添加字幕中,请稍后查看"); } @Override public void updateSubtitles(CourseMediaEditSReqVO updateReqVO) { // 校验存在 validateCourseMediaExists(updateReqVO.getId()); // 更新 CourseMediaDO updateObj = BeanUtils.toBean(updateReqVO, CourseMediaDO.class); updateObj.setSubtitlesStatus(2); courseMediaMapper.updateById(updateObj); } /** * 上传片头片尾 * @param courseMediaSubtitlesReqVO * @return */ @Override public CommonResult createTrailer(CourseMediaSubtitlesReqVO courseMediaSubtitlesReqVO) { CourseMediaDO courseMediaDO = new CourseMediaDO(); courseMediaDO.setId(courseMediaSubtitlesReqVO.getId()); courseMediaDO.setTrailer(courseMediaSubtitlesReqVO.getTrailer()); courseMediaDO.setTitles(courseMediaSubtitlesReqVO.getTitles()); int i = courseMediaMapper.updateById(courseMediaDO); if (i>0){ return CommonResult.success("片头片尾上传成功"); } return CommonResult.error(BAD_REQUEST.getCode(),"片头片尾上传失败"); } /** * 合成片头片尾视频 * @param courseMediaSubtitlesReqVO * @return */ @Override public CommonResult createCompositeVideo(CourseMediaSubtitlesReqVO courseMediaSubtitlesReqVO) { // 生成时间戳 String timestamp = new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date()); // 片头、片尾和视频地址 String titles = courseMediaSubtitlesReqVO.getTitles(); titles = configApi.getConfigValueByKey("easegen.url") + titles.substring(titles.lastIndexOf("/")); String trailer = courseMediaSubtitlesReqVO.getTrailer(); trailer = configApi.getConfigValueByKey("easegen.url") + trailer.substring(trailer.lastIndexOf("/")); String videoUrl = courseMediaSubtitlesReqVO.getVideoUrl(); String previewUrl = courseMediaSubtitlesReqVO.getPreviewUrl(); List videoUrls = new ArrayList<>(); videoUrls.add(titles); // 检查是否有主视频或预览视频 if (videoUrl != null) { videoUrl = configApi.getConfigValueByKey("easegen.url") + videoUrl.substring(videoUrl.lastIndexOf("/")); videoUrls.add(videoUrl); videoUrls.add(trailer); } else if (previewUrl != null) { previewUrl = configApi.getConfigValueByKey("easegen.url") + previewUrl.substring(previewUrl.lastIndexOf("/")); videoUrls.add(previewUrl); videoUrls.add(trailer); } // 判断文件夹是否存在,如果不存在则创建 String filePath = configApi.getConfigValueByKey(HEYGEM_FACE2FACE) + "/compositeVideo/"; File file = new File(filePath); if (!file.exists()) { file.mkdirs(); } // 生成视频文件列表 String fileListPath = configApi.getConfigValueByKey(HEYGEM_FACE2FACE) + "/compositeVideo/" + timestamp + ".txt"; try (BufferedWriter writer = new BufferedWriter(new FileWriter(fileListPath))) { for (String path : videoUrls) { writer.write("file '" + path + "'\n"); } System.out.println("文件列表已生成:" + fileListPath); } catch (IOException e) { e.printStackTrace(); } // 去掉 courseMediaSubtitlesReqVO.getCourseName() 中的空格和特殊字符 String newFileName = courseMediaSubtitlesReqVO.getCourseName().replaceAll("[\\s\\p{Punct}]", ""); // 获取主视频分辨率 String videoInfo = getVideoResolution(videoUrls.get(1)); // 使用主视频 URL String[] resolution = videoInfo.split("x"); String width = resolution[0]; String height = resolution[1]; boolean hasAudio = checkAudio(titles); try { ProcessBuilder builder = null; if (hasAudio) { // 视频包含音频,执行相应的命令 builder = new ProcessBuilder( "ffmpeg", "-i", titles, "-vf", "scale="+width+":"+height+":force_original_aspect_ratio=decrease,pad="+width+":"+height+":(ow-iw)/2:(oh-ih)/2,setsar=1", "-c:v", "libx264", "-preset", "veryfast", "-c:a", "aac", "-shortest", configApi.getConfigValueByKey(HEYGEM_FACE2FACE) + "/compositeVideo/" + timestamp + "_intro.mp4" ); } else { // 视频不包含音频,生成空音频并合成 builder = new ProcessBuilder( "ffmpeg", "-i", titles, "-f", "lavfi", "-t", "10", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100", "-filter_complex", "[0:v]scale="+width+":"+height+":force_original_aspect_ratio=decrease,pad="+width+":"+height+":(ow-iw)/2:(oh-ih)/2,setsar=1[v]", "-map", "[v]", "-map", "1:a", "-c:v", "libx264", "-preset", "veryfast", "-c:a", "aac", "-shortest", configApi.getConfigValueByKey(HEYGEM_FACE2FACE) + "/compositeVideo/" + timestamp + "_intro.mp4" ); } builder.redirectErrorStream(true); Process process = null; try { process = builder.start(); // 使用 try-with-resources 确保流关闭 try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { System.out.println(line); } } // 等待 FFmpeg 进程完成 int exitCode = process.waitFor(); if (exitCode != 0) { throw new RuntimeException("FFmpeg 执行失败,退出码:" + exitCode); } System.out.println("片头和片尾已调整分辨率并添加音轨"); } catch (IOException | InterruptedException e) { throw new RuntimeException("FFmpeg 执行异常", e); } finally { // 确保 Process 的输入/错误流被关闭 if (process != null) { try { process.getInputStream().close(); process.getErrorStream().close(); process.getOutputStream().close(); } catch (IOException e) { e.printStackTrace(); } } } } catch (Exception e) { e.printStackTrace(); } boolean hasAudio1 = checkAudio(trailer); try { ProcessBuilder builder = null; if (hasAudio1) { // 视频包含音频,执行相应的命令 builder = new ProcessBuilder( "ffmpeg", "-i", trailer, "-vf", "scale="+width+":"+height+":force_original_aspect_ratio=decrease,pad="+width+":"+height+":(ow-iw)/2:(oh-ih)/2,setsar=1", "-c:v", "libx264", "-preset", "veryfast", "-c:a", "aac", "-shortest", configApi.getConfigValueByKey(HEYGEM_FACE2FACE) + "/compositeVideo/" + timestamp + "_outro.mp4" ); } else { // 视频不包含音频,生成空音频并合成 builder = new ProcessBuilder( "ffmpeg", "-i", trailer, "-f", "lavfi", "-t", "10", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100", "-filter_complex", "[0:v]scale="+width+":"+height+":force_original_aspect_ratio=decrease,pad="+width+":"+height+":(ow-iw)/2:(oh-ih)/2,setsar=1[v]", "-map", "[v]", "-map", "1:a", "-c:v", "libx264", "-preset", "veryfast", "-c:a", "aac", "-shortest", configApi.getConfigValueByKey(HEYGEM_FACE2FACE) + "/compositeVideo/" + timestamp + "_outro.mp4" ); } builder.redirectErrorStream(true); Process process = null; try { process = builder.start(); // 使用 try-with-resources 确保流关闭 try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { System.out.println(line); } } // 等待 FFmpeg 进程完成 int exitCode = process.waitFor(); if (exitCode != 0) { throw new RuntimeException("FFmpeg 执行失败,退出码:" + exitCode); } System.out.println("片头和片尾已调整分辨率并添加音轨"); } catch (IOException | InterruptedException e) { throw new RuntimeException("FFmpeg 执行异常", e); } finally { // 确保 Process 的输入/错误流被关闭 if (process != null) { try { process.getInputStream().close(); process.getErrorStream().close(); process.getOutputStream().close(); } catch (IOException e) { e.printStackTrace(); } } } } catch (Exception e) { e.printStackTrace(); } // 合并三个视频:片头、主视频、片尾 ProcessBuilder builder10 = new ProcessBuilder( "ffmpeg", "-i", filePath + timestamp + "_intro.mp4", "-i", videoUrls.get(1), "-i", filePath + timestamp + "_outro.mp4", "-filter_complex", "[0:v][0:a][1:v][1:a][2:v][2:a]concat=n=3:v=1:a=1[v][a]", "-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "veryfast", "-c:a", "aac", "-shortest", configApi.getConfigValueByKey(HEYGEM_FACE2FACE) + "/compositeVideo/" + timestamp + "_final.mp4" ); Process process10 = null; builder10.redirectErrorStream(true); try { process10 = builder10.start(); // 使用 try-with-resources 确保流关闭 try (BufferedReader reader = new BufferedReader(new InputStreamReader(process10.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { System.out.println(line); } } // 等待 FFmpeg 进程完成 int exitCode = process10.waitFor(); if (exitCode != 0) { throw new RuntimeException("FFmpeg 合成视频失败,退出码:" + exitCode); } System.out.println("视频已成功合成"); } catch (IOException | InterruptedException e) { throw new RuntimeException("FFmpeg 合成视频异常", e); } byte[] bytes = FileUtil.readBytes(FileUtil.file(configApi.getConfigValueByKey(HEYGEM_FACE2FACE) + "/compositeVideo/" + timestamp + "_final.mp4")); String compositeVideo = fileApi.createFile(bytes); CourseMediaDO courseMediaDO = new CourseMediaDO(); courseMediaDO.setId(courseMediaSubtitlesReqVO.getId()); courseMediaDO.setCompositeVideo(compositeVideo); int i = courseMediaMapper.updateById(courseMediaDO); //删除文件 FileUtil.del(configApi.getConfigValueByKey(HEYGEM_FACE2FACE) + "/compositeVideo/" + timestamp + "_intro.mp4"); FileUtil.del(configApi.getConfigValueByKey(HEYGEM_FACE2FACE) + "/compositeVideo/" + timestamp + "_outro.mp4"); FileUtil.del(configApi.getConfigValueByKey(HEYGEM_FACE2FACE) + "/compositeVideo/" + timestamp + "_final.mp4"); FileUtil.del(configApi.getConfigValueByKey(HEYGEM_FACE2FACE) + "/compositeVideo/" + timestamp + ".txt"); if (i>0){ return CommonResult.success("合成成功"); } return CommonResult.error(BAD_REQUEST.getCode(),"合成失败"); } public String getVideoResolution(String videoFilePath) { ProcessBuilder builder = new ProcessBuilder( "ffprobe", "-v", "error", // 仅显示错误 "-select_streams", "v:0", // 选择视频流 "-show_entries", "stream=width,height", // 仅显示宽度和高度 "-of", "default=noprint_wrappers=1:nokey=1", // 格式化输出 videoFilePath // 视频文件路径 ); Process process = null; String resolution = ""; try { process = builder.start(); // 读取 ffprobe 输出流 try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { resolution += line + " "; // 拼接宽度和高度 } } // 等待 ffprobe 进程完成 int exitCode = process.waitFor(); if (exitCode != 0) { throw new RuntimeException("ffprobe 执行失败,退出码:" + exitCode); } } catch (IOException | InterruptedException e) { throw new RuntimeException("ffprobe 执行异常", e); } finally { if (process != null) { try { process.getInputStream().close(); process.getErrorStream().close(); process.getOutputStream().close(); } catch (IOException e) { e.printStackTrace(); } } } // 返回分辨率,格式如 "1920x1080" return resolution.trim().replaceAll("\\s+", "x"); } private static boolean checkAudio(String inputFile) { try { ProcessBuilder builder = new ProcessBuilder( "ffprobe", "-i", inputFile, "-show_streams", "-select_streams", "a", "-loglevel", "error" ); Process process = builder.start(); BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); String line; while ((line = reader.readLine()) != null) { if (line.contains("codec_type=audio")) { return true; // 音频流存在 } } int exitCode = process.waitFor(); return exitCode == 0; } catch (IOException | InterruptedException e) { e.printStackTrace(); return false; } } }