package cn.iocoder.yudao.module.digitalcourse.service.coursemedia; import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.http.HttpRequest; import cn.hutool.http.HttpResponse; import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; import cn.iocoder.yudao.module.digitalcourse.controller.admin.coursemedia.vo.CourseMediaMegerVO; import cn.iocoder.yudao.module.digitalcourse.controller.admin.coursescenecomponents.vo.AppCourseSceneComponentsMegerReqVO; import cn.iocoder.yudao.module.digitalcourse.controller.admin.coursescenes.vo.AppCourseScenesMegerReqVO; import cn.iocoder.yudao.module.digitalcourse.dal.dataobject.coursemedia.CourseMediaDO; import cn.iocoder.yudao.module.digitalcourse.dal.dataobject.digitalhumans.DigitalHumansDO; import cn.iocoder.yudao.module.digitalcourse.dal.dataobject.voices.AuditionVO; import cn.iocoder.yudao.module.digitalcourse.dal.mysql.coursemedia.CourseMediaMapper; import cn.iocoder.yudao.module.digitalcourse.dal.mysql.digitalhumans.DigitalHumansMapper; import cn.iocoder.yudao.module.digitalcourse.service.voices.VoicesServiceImpl; import cn.iocoder.yudao.module.digitalcourse.util.SrtToVttUtil; import cn.iocoder.yudao.module.infra.api.config.ConfigApi; import cn.iocoder.yudao.module.infra.api.file.FileApi; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; import java.io.*; import java.math.BigDecimal; import java.math.BigInteger; import java.math.RoundingMode; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.text.SimpleDateFormat; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @Service @Validated @Slf4j public class CourseMediaServiceUtil { private static final String EASEGEN_CORE_URL = "easegen.core.url"; static final String EASEGEN_CORE_KEY = "easegen.core.key"; @Resource private CourseMediaMapper courseMediaMapper; @Resource private ConfigApi configApi; @Resource private SrtToVttUtil srtToVttUtil; @Resource private DigitalHumansMapper digitalcourseDigitalHumansMapper; @Resource private VoicesServiceImpl serviceImpl; @Resource private FileApi fileApi; /** * 远程合并视频 * * @param updateReqVO * @return */ @Async public void remoteMegerMedia(CourseMediaMegerVO updateReqVO) { CourseMediaDO courseMediaDO = courseMediaMapper.selectById(updateReqVO.getCourseMediaId()); List scenes = updateReqVO.getScenes(); //获取数字人素材(声音、视频) String entityId = null; if (scenes != null) { //获取scenes中的第一个元素的entityId List components = scenes.get(0).getComponents(); if (components != null) { entityId = components.get(0).getEntityId(); } } DigitalHumansDO digitalHumansDO = digitalcourseDigitalHumansMapper.selectByCode(entityId); //获取数字人素材(声音、视频) if (digitalHumansDO == null) { // 如果找不到对应的课程媒体记录,直接返回或记录错误日志 return; } if (courseMediaDO == null) { // 如果找不到对应的课程媒体记录,直接返回或记录错误日志 return; } boolean success; List videoUrls = new ArrayList<>(); AuditionVO auditionVO = new AuditionVO(); auditionVO.setHumanId(String.valueOf(digitalHumansDO.getId())); for (AppCourseScenesMegerReqVO scene : scenes) { //TODO 先判断是否有备注内容 auditionVO.setText(scene.getBackground().getPptRemark()); String audition = serviceImpl.audition(auditionVO); // 提取音频文件名(路径的最后一部分) String substring = configApi.getConfigValueByKey("easegen.url") + audition.substring(audition.lastIndexOf("/")); String fileName = audition.substring(audition.lastIndexOf('/') + 1); String newFileName = "D:/heygem_data/face2face/temp/" + fileName; // 提取视频文件名(路径的最后一部分) String substring1 = configApi.getConfigValueByKey("easegen.url") + digitalHumansDO.getFixVideoUrl().substring(digitalHumansDO.getFixVideoUrl().lastIndexOf("/")); String fileName1 = digitalHumansDO.getFixVideoUrl().substring(digitalHumansDO.getFixVideoUrl().lastIndexOf('/') + 1); String newFileName1 = "D:/heygem_data/face2face/temp/"+fileName1; //获取时间戳 Date date = new Date(); long timestamp = date.getTime(); String newFileName2 = "D:/heygem_data/face2face/temp/"+timestamp+".mp4"; //获取PPT内容 String cover = scene.getComponents().get(1).getSrc(); cover = configApi.getConfigValueByKey("easegen.url") + scene.getComponents().get(1).getSrc().substring(scene.getComponents().get(1).getSrc().lastIndexOf("/")); //获取背景 String cover1 = scene.getBackground().getCover(); cover1 = configApi.getConfigValueByKey("easegen.url") + scene.getBackground().getCover().substring(scene.getBackground().getCover().lastIndexOf("/")); // 去掉扩展名 int dotIndex = fileName1.lastIndexOf('.'); String substring2 = fileName1.substring(0, dotIndex); // 合成ppt背景,视频,模板 //ffmpeg -i 2.png -i 1.mp4 -filter_complex "[0:v]scale=w=ceil(iw/2)*2:h=ceil(ih/2)*2[bg];[1:v]scale=iw/2:ih/2[v1];[bg][v1]overlay=x=0:y=H-h" output.mp4 //判断是否有人像 ProcessBuilder builder = null; if("1".equals(scene.getHasPerson())){ builder = new ProcessBuilder( "ffmpeg", "-i", cover1, "-i", cover, "-i", substring1, "-filter_complex", "[0:v]scale=" + Math.round(scene.getBackground().getWidth()) + ":" + Math.round(scene.getBackground().getHeight()) + "[bg];" + "[1:v]scale=" + Math.round(scene.getComponents().get(1).getWidth()) + ":" + Math.round(scene.getComponents().get(1).getHeight()) + "[v1];" + "[bg][v1]overlay=x=" + Math.round(scene.getComponents().get(1).getMarginLeft()) + ":y=" + Math.round(scene.getComponents().get(1).getTop()) + "[img];" + "[2:v]scale=" + Math.round(scene.getComponents().get(0).getWidth()) + ":" + Math.round(scene.getComponents().get(0).getHeight()) + "[v2];" + "[img][v2]overlay=x=" + Math.round(scene.getComponents().get(0).getMarginLeft()) + ":y=" + Math.round(scene.getComponents().get(0).getTop()), newFileName2 ); } else if ("2".equals(scene.getHasPerson())) { // 当没有人像时,视频放在 cover 的下层 builder = new ProcessBuilder( "ffmpeg", "-i", cover1, // 背景图 "-i", substring1, // 视频 "-filter_complex", "[0:v]scale=" + Math.round(scene.getBackground().getWidth()) + ":" + Math.round(scene.getBackground().getHeight()) + "[bg];" + "[1:v]scale=" + Math.round(scene.getComponents().get(0).getWidth()) + ":" + Math.round(scene.getComponents().get(0).getHeight()) + "[v1];" + "[bg][v1]overlay=x=" + Math.round(scene.getComponents().get(0).getMarginLeft()) + ":y=" + Math.round(scene.getComponents().get(0).getTop()), newFileName2 // 输出文件名 ); } System.out.println(newFileName2); builder.redirectErrorStream(true); Process process = null; try { process = builder.start(); // 读取 FFmpeg 输出(可选) BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); System.out.println(builder.command()); String line; while ((line = reader.readLine()) != null) { System.out.println(line); } } catch (IOException e) { throw new RuntimeException(e); } try { Files.copy(Path.of(substring), Path.of(newFileName), StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { throw new RuntimeException(e); } //最大重试次数 int maxRetries = 3; // 当前重试次数 int retryCount = 0; success = false; while (retryCount < maxRetries && !success) { try { // 发送POST请求 HashMap objectObjectHashMap = new HashMap<>(); //音频路径 objectObjectHashMap.put("audio_url", fileName); //视频路径 objectObjectHashMap.put("video_url",timestamp+".mp4"); //唯一key(用于查询) String code = RandomUtil.randomString(32); //固定值 objectObjectHashMap.put("code", code); objectObjectHashMap.put("chaofen", 0); objectObjectHashMap.put("watermark_switch", 0); objectObjectHashMap.put("pn", 1); HttpResponse execute = HttpRequest.post("http://192.168.3.161:8383/easy/submit") .body(JSON.toJSONString(objectObjectHashMap)) .execute(); String body = execute.body(); // 检查响应状态码是否成功 if (execute.getStatus() != 200) { retryCount++; if (retryCount >= maxRetries) { // 超过重试次数,更新状态和错误信息 courseMediaDO.setStatus(3); // 3 表示合成失败 courseMediaDO.setErrorReason(truncateErrorMsg("HTTP 请求报错: " + execute.getStatus())); courseMediaMapper.updateById(courseMediaDO); return; } continue; // 重新尝试 } // 解析响应,检查是否有错误信息 JSONObject responseJson = JSON.parseObject(body); if (!responseJson.getBoolean("success")) { // 处理业务逻辑错误,更新状态和错误信息 String errorDetail = responseJson.getString("detail"); retryCount++; if (retryCount >= maxRetries) { courseMediaDO.setStatus(3); // 3 表示合成失败 courseMediaDO.setErrorReason(truncateErrorMsg("API 接口异常: " + errorDetail)); courseMediaMapper.updateById(courseMediaDO); return; } continue; // 重新尝试 } //调用查询视频结果 String result = getResult(code); result = "D:/heygem_data/face2face/temp" + result; videoUrls.add(result); System.out.println("驱动视频名"+result); } catch (Exception e) { retryCount++; if (retryCount >= maxRetries) { // 捕获异常,记录错误原因并更新状态 courseMediaDO.setStatus(3); // 3 表示合成失败 courseMediaDO.setErrorReason(truncateErrorMsg("视频合成任务失败,请联系管理员,错误信息: " + e.getMessage())); courseMediaMapper.updateById(courseMediaDO); return; } try { // 重试前等待一段时间,避免频繁请求 TimeUnit.SECONDS.sleep(2); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); // 处理中断异常 break; } } success = true; } } String fileListPath = "D:/heygem_data/face2face/temp/filelist.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(); } //去掉updateReqVO.getName()中的空格和特殊字符 String newFileName = updateReqVO.getName().replaceAll("[\\s\\p{Punct}]", ""); ProcessBuilder builder = new ProcessBuilder( "ffmpeg", "-f", "concat", "-safe","0", "-i",fileListPath , "-c", "copy", "D:/heygem_data/face2face/temp/"+"111111.mp4" // q ); builder.redirectErrorStream(true); Process process = null; try { process = builder.start(); // 读取 FFmpeg 输出(可选) BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); System.out.println(builder.command()); System.out.println("最终视频已生成"); String line; while ((line = reader.readLine()) != null) { System.out.println(line); } } catch (IOException e) { throw new RuntimeException(e); } // "D:/heygem_data/face2face/temp/"+"111111.mp4"将这个文件转成byte[] byte[] bytes = FileUtil.readBytes(FileUtil.file("D:/heygem_data/face2face/temp/"+"111111.mp4")); String file = fileApi.createFile(bytes); // 如果成功,更新状态为1(成功) courseMediaDO.setStatus(1); courseMediaDO.setPreviewUrl(file); SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); Date date = new Date(); courseMediaDO.setFinishTime(sdf.format(date)); courseMediaDO.setProgress(100.0f); courseMediaMapper.updateById(courseMediaDO); } // 四舍五入方法 private static int round(double value) { return BigDecimal.valueOf(value) .setScale(0, RoundingMode.HALF_UP) .intValue(); } public String getResult(String taskCode) { // 使用 do-while 循环轮询任务状态 // 定义变量存储返回结果 String result = null; // 初始化状态为未完成 int status = -1; do { try { // 调用接口获取任务状态(假设使用 Hutool 的 HttpRequest) String body = HttpRequest.get("http://192.168.3.161:8383/easy/query?code=" + taskCode) .execute() .body(); System.out.println("接口返回数据: " + body); // 使用 fastjson 解析 JSON 数据 JSONObject jsonObject = JSON.parseObject(body); JSONObject data = jsonObject.getJSONObject("data"); // 提取任务状态和结果 status = data.getIntValue("status"); // 如果任务完成 if (status == 2) { result = data.getString("result"); System.out.println("任务已完成,结果文件路径: " + result); } else if (status == 3){ throw new RuntimeException("任务失败,请联系管理员"); }else { System.out.println("任务尚未完成,当前进度: " + data.getIntValue("progress") + "%"); } // 等待一段时间再进行下一次轮询(避免频繁请求) // 每隔 5 秒轮询一次 Thread.sleep(5000); } catch (Exception e) { e.printStackTrace(); System.out.println("调用接口失败,稍后重试..."); try { // 出现异常时也等待 5 秒 Thread.sleep(5000); } catch (InterruptedException interruptedException) { interruptedException.printStackTrace(); } } // 当 status 等于 时继续循环 } while (status == 1 ); return result; } public Boolean reMegerMedia(CourseMediaDO courseMediaDO) { if (courseMediaDO == null) { // 如果找不到对应的课程媒体记录,直接返回或记录错误日志 return false; } JSONObject reqJson = new JSONObject(); reqJson.put("courseMediaId", courseMediaDO.getId()); int maxRetries = 3; // 最大重试次数 int retryCount = 0; // 当前重试次数 boolean success = false; while (retryCount < maxRetries && !success) { try { // 发送POST请求 HttpResponse execute = HttpRequest.post(configApi.getConfigValueByKey(EASEGEN_CORE_URL) + "/api/reMergemedia") .header("X-API-Key", configApi.getConfigValueByKey(EASEGEN_CORE_KEY)) .body(JSON.toJSONString(reqJson)) .execute(); String body = execute.body(); // 检查响应状态码是否成功 if (execute.getStatus() != 200) { retryCount++; if (retryCount >= maxRetries) { // 超过重试次数,更新状态和错误信息 courseMediaDO.setStatus(3); // 3 表示合成失败 courseMediaDO.setErrorReason(truncateErrorMsg("HTTP 请求报错: " + execute.getStatus() + ", 报错内容: " + body)); courseMediaMapper.updateById(courseMediaDO); return false; } continue; // 重新尝试 } // 解析响应,检查是否有错误信息 JSONObject responseJson = JSON.parseObject(body); if (!responseJson.getBoolean("success")) { // 处理业务逻辑错误,更新状态和错误信息 String errorDetail = responseJson.getString("detail"); retryCount++; if (retryCount >= maxRetries) { courseMediaDO.setStatus(3); // 3 表示合成失败 courseMediaDO.setErrorReason(truncateErrorMsg("API 接口异常: " + errorDetail)); courseMediaMapper.updateById(courseMediaDO); return false; } continue; // 重新尝试 } // 如果成功,更新状态为1(成功) courseMediaDO.setErrorReason(""); courseMediaDO.setStatus(1); // 1 表示请求成功,状态变为合成中 courseMediaMapper.updateById(courseMediaDO); success = true; } catch (Exception e) { retryCount++; if (retryCount >= maxRetries) { // 捕获异常,记录错误原因并更新状态 courseMediaDO.setStatus(3); // 3 表示合成失败 courseMediaDO.setErrorReason(truncateErrorMsg("视频合成任务失败,请联系管理员,错误信息: " + e.getMessage())); courseMediaMapper.updateById(courseMediaDO); return false; } try { // 重试前等待一段时间,避免频繁请求 TimeUnit.SECONDS.sleep(2); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); // 处理中断异常 break; } } } return success; } /** * 截取错误信息,使其不超过指定的最大长度 */ private String truncateErrorMsg(String errorMsg) { int maxLength = 500; return errorMsg.length() > maxLength ? errorMsg.substring(0, maxLength) : errorMsg; } /** * 远程查询合并结果(定时任务) */ @Async public void queryRemoteMegerResult() { try { List courseMediaDOS = courseMediaMapper.selectList(new QueryWrapperX().lambda().eq(CourseMediaDO::getStatus, 1)); // 检查 courseMediaDOS 是否为空 if (courseMediaDOS == null || courseMediaDOS.isEmpty()) { return; // 如果为空,直接返回 } // 收集所有的ID String courseMediaIds = courseMediaDOS.stream() .map(e -> String.valueOf(e.getId())) .collect(Collectors.joining(",")); // 批量调用远程接口 String result = HttpRequest.get(configApi.getConfigValueByKey(EASEGEN_CORE_URL) + "/api/mergemedia/result") .header("X-API-Key", configApi.getConfigValueByKey(EASEGEN_CORE_KEY)) .form("courseMediaIds", courseMediaIds) .timeout(5000) // 设置超时时间 .execute() .body(); // 检查远程接口返回的结果是否有效 if (result == null || result.isEmpty()) { System.err.println("Remote API returned empty or null response."); return; } //打印结果 log.info("Remote API returned: " + result); if (JSON.isValidArray(result)) { JSONArray jsonArray = JSON.parseArray(result); Map resultMap = jsonArray.stream() .filter(obj -> JSON.isValidObject(JSON.toJSONString(obj))) .map(obj -> JSON.parseObject(JSON.toJSONString(obj))) .collect(Collectors.toMap(jsonObject -> jsonObject.getString("courseMediaId"), jsonObject -> jsonObject)); courseMediaDOS.forEach(e -> { JSONObject jsonObject = resultMap.get(String.valueOf(e.getId())); if (jsonObject != null) { BigInteger status = jsonObject.getBigInteger("status"); if (status != null) { // 合并状态,0:草稿,1:合成中,2:合成成功,3:合成失败 // 状态为合成中,更新进度 if (status.intValue() == 1) { // 合成中 Float completionPercentage = jsonObject.getFloat("completion_percentage"); e.setProgress(completionPercentage); // 更新进度 e.setErrorReason(""); // 清空错误信息 courseMediaMapper.updateById(e); log.info("合成中,已更新进度:" + completionPercentage + "%,课程ID: " + e.getId()); } else if (status.intValue() == 2) { e.setStatus(status.intValue()); e.setFinishTime(jsonObject.getString("finish_time")); // 远程返回的完成时间 e.setPreviewUrl(jsonObject.getString("merge_video")); e.setDuration(jsonObject.getLong("duration")); e.setProgress(jsonObject.getFloat("completion_percentage")); // 合成进度 e.setSubtitlesUrl(jsonObject.getString("subtitles_url")); e.setThumbnail(jsonObject.getString("thumbnail")); try { String vtturl = srtToVttUtil.convertAndUploadSrtToVtt(jsonObject.getString("subtitles_url")); e.setSubtitlesVttUrl(vtturl); } catch (IOException ex) { log.info("Failed to convert and upload SRT to VTT: " + ex.getMessage()); throw new RuntimeException(ex); } e.setErrorReason(""); // 清空错误信息 courseMediaMapper.updateById(e); } else if (status.intValue() == 3) { e.setStatus(status.intValue()); String failureReasons = jsonObject.getString("failure_reasons"); if (StrUtil.isNotEmpty(failureReasons) && failureReasons.length() > 1000) { failureReasons = failureReasons.substring(0, 1000); // 截取字符串,确保不超过1000字符 } e.setErrorReason(failureReasons); // 获取失败原因 e.setProgress(jsonObject.getFloat("completion_percentage")); // 合成进度 courseMediaMapper.updateById(e); } } else { log.error("Status is null for courseMediaId: " + e.getId()); } } else { //如果没有匹配的记录,也修改为生成失败 e.setStatus(3); e.setErrorReason("服务端没有查询到视频合成记录,请重新合成"); courseMediaMapper.updateById(e); log.error("No matching result found for courseMediaId: " + e.getId()); } }); } else { log.error("Invalid JSON array received from the remote API."); } } catch (Exception ex) { // 捕获所有异常,防止程序崩溃 log.error("An error occurred while querying remote merge result: " + ex.getMessage()); ex.printStackTrace(); } } }