package com.ycwl.basic.task; import com.ycwl.basic.device.DeviceFactory; import com.ycwl.basic.device.entity.common.FileObject; import com.ycwl.basic.device.operator.IDeviceStorageOperator; import com.ycwl.basic.mapper.DeviceMapper; import com.ycwl.basic.mapper.FaceSampleMapper; import com.ycwl.basic.mapper.SourceMapper; import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity; import com.ycwl.basic.model.pc.device.entity.DeviceEntity; import com.ycwl.basic.model.pc.faceSample.resp.FaceSampleRespVO; import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity; import com.ycwl.basic.model.pc.source.entity.SourceEntity; import com.ycwl.basic.storage.StorageFactory; import com.ycwl.basic.storage.adapters.IStorageAdapter; import com.ycwl.basic.storage.enums.StorageType; import com.ycwl.basic.utils.SnowFlakeUtil; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import java.util.stream.IntStream; @Component @EnableScheduling @Slf4j public class VideoPieceGetter { @Autowired private FaceSampleMapper faceSampleMapper; @Autowired private DeviceMapper deviceMapper; @Autowired private SourceMapper sourceMapper; @Data public static class Task { public String type = "normal"; public Long faceSampleId; public Callback callback; public Long memberId; public Long faceId; public static interface Callback { void onInvoke(); } } @Data public static class FfmpegTask { List fileList; BigDecimal duration; BigDecimal offsetStart; String outputFile; } public static LinkedBlockingQueue queue = new LinkedBlockingQueue<>(); public static void addTask(Task task) { queue.add(task); } @Scheduled(fixedDelay = 1000L) public void doTask() { Task task = queue.poll(); if (task == null) { return; } log.info("poll task: {}", task); if (!task.getType().equalsIgnoreCase("normal")) { task.getCallback().onInvoke(); return; } FaceSampleRespVO faceSample = faceSampleMapper.getById(task.getFaceSampleId()); DeviceEntity device = deviceMapper.getByDeviceId(faceSample.getDeviceId()); DeviceConfigEntity config = deviceMapper.getConfigByDeviceId(faceSample.getDeviceId()); SourceEntity source = sourceMapper.querySameVideo(faceSample.getId(), device.getId()); if (source != null) { // 有原视频 int count = sourceMapper.hasRelationTo(task.getMemberId(), source.getId(), 1); if (count > 0) { return; } MemberSourceEntity videoSource = new MemberSourceEntity(); videoSource.setId(SnowFlakeUtil.getLongId()); videoSource.setScenicId(faceSample.getScenicId()); videoSource.setFaceId(task.getFaceId()); videoSource.setMemberId(task.getMemberId()); videoSource.setType(1); videoSource.setIsBuy(0); videoSource.setSourceId(source.getId()); sourceMapper.addRelation(videoSource); return; } BigDecimal cutPre = BigDecimal.valueOf(5L); BigDecimal cutPost = BigDecimal.valueOf(4L); if (config == null) { return; } // 有配置 if (config.getCutPre() != null) { cutPre = config.getCutPre(); } if (config.getCutPost() != null) { cutPost = config.getCutPost(); } IDeviceStorageOperator pieceGetter = DeviceFactory.getDeviceStorageOperator(device, config); if (pieceGetter == null) { return; } BigDecimal duration = cutPre.add(cutPost); List listByDtRange = pieceGetter.getFileListByDtRange( new Date(faceSample.getCreateAt().getTime() - cutPre.multiply(BigDecimal.valueOf(1000)).longValue()), new Date(faceSample.getCreateAt().getTime() + cutPost.multiply(BigDecimal.valueOf(1000)).longValue()) ); if (listByDtRange.isEmpty()) { log.warn("没有可用的文件"); return; } long offset = faceSample.getCreateAt().getTime() - cutPre.multiply(BigDecimal.valueOf(1000)).longValue() - listByDtRange.get(0).getCreateTime().getTime(); FfmpegTask ffmpegTask = new FfmpegTask(); ffmpegTask.setFileList(listByDtRange); ffmpegTask.setDuration(duration); ffmpegTask.setOffsetStart(BigDecimal.valueOf(offset, 3)); File outFile = new File(faceSample.getDeviceId().toString() + "_" + faceSample.getId() + ".mp4"); ffmpegTask.setOutputFile(outFile.getAbsolutePath()); boolean result = startFfmpegTask(ffmpegTask); if (!result) { log.warn("视频裁切失败"); return; } log.info("视频裁切成功"); IStorageAdapter adapter = StorageFactory.use("assets"); String url = adapter.uploadFile(outFile, "video-source", outFile.getName()); // 上传成功后删除文件 outFile.delete(); SourceEntity imgSource = sourceMapper.findBySampleId(faceSample.getId()); SourceEntity sourceEntity = new SourceEntity(); sourceEntity.setId(SnowFlakeUtil.getLongId()); sourceEntity.setCreateTime(faceSample.getCreateAt()); MemberSourceEntity videoSource = new MemberSourceEntity(); videoSource.setMemberId(task.getMemberId()); videoSource.setType(1); videoSource.setIsBuy(0); videoSource.setFaceId(task.getFaceId()); videoSource.setScenicId(faceSample.getScenicId()); videoSource.setSourceId(sourceEntity.getId()); if (imgSource != null) { sourceEntity.setUrl(imgSource.getUrl()); sourceEntity.setPosJson(imgSource.getPosJson()); } sourceEntity.setVideoUrl(url); sourceEntity.setFaceSampleId(faceSample.getId()); sourceEntity.setScenicId(faceSample.getScenicId()); sourceEntity.setDeviceId(faceSample.getDeviceId()); sourceEntity.setType(1); sourceMapper.add(sourceEntity); sourceMapper.addRelation(videoSource); } public boolean startFfmpegTask(FfmpegTask task) { boolean result; if (task.getFileList().size() == 1) { // 单个文件切割,用简单方法 result = runFfmpegForSingleFile(task); } else { // 多个文件切割,用速度快的 result = runFfmpegForMultipleFile1(task); } // 先尝试方法1 if (result) { return true; } log.warn("FFMPEG简易方法失败,尝试复杂方法转码"); // 不行再尝试方法二 return runFfmpegForMultipleFile2(task); } private boolean runFfmpegForMultipleFile1(FfmpegTask task) { // 多文件,方法一:先转换成ts,然后合并切割 // 步骤一:先转换成ts,并行转换 boolean notOk = task.getFileList().stream().map(file -> { try { if (file.isNeedDownload() || (!file.getName().endsWith(".ts"))) { String tmpFile = file.getName() + ".ts"; boolean result = convertMp4ToTs(file, tmpFile); // 因为是并行转换,没法保证顺序,就直接存里面 if (result) { file.setUrl(tmpFile); } else { // 失败了,务必删除临时文件 (new File(tmpFile)).delete(); } return result; } else { return true; } } catch (IOException e) { log.warn("转码出错"); return false; } }).anyMatch(b -> !b); // 转码进程中出现问题 if (notOk) { return false; } // 步骤二:使用concat协议拼接裁切 boolean result; try { result = quickVideoCut( "concat:" + task.getFileList().stream().map(FileObject::getUrl).collect(Collectors.joining("|")), task.getOffsetStart(), task.getDuration(), task.getOutputFile() ); } catch (IOException e) { return false; } // 步骤三:删除临时文件 task.getFileList().stream().map(FileObject::getUrl).forEach(tmpFile -> { File f = new File(tmpFile); if (f.exists() && f.isFile()) { f.delete(); } }); return result; } private boolean runFfmpegForMultipleFile2(FfmpegTask task) { // 多文件,方法二:使用计算资源编码 try { return slowVideoCut(task.getFileList(), task.getOffsetStart(), task.getDuration(), task.getOutputFile()); } catch (IOException e) { return false; } } private boolean runFfmpegForSingleFile(FfmpegTask task) { try { return quickVideoCut(task.getFileList().get(0).getUrl(), task.getOffsetStart(), task.getDuration(), task.getOutputFile()); } catch (IOException e) { return false; } } /** * 把MP4转换成可以拼接的TS文件 * * @param file MP4文件,或ffmpeg支持的输入 * @param outFileName 输出文件路径 * @return 是否成功 * @throws IOException 奇奇怪怪的报错 */ private boolean convertMp4ToTs(FileObject file, String outFileName) throws IOException { List ffmpegCmd = new ArrayList<>(); ffmpegCmd.add("ffmpeg"); ffmpegCmd.add("-hide_banner"); ffmpegCmd.add("-y"); ffmpegCmd.add("-i"); ffmpegCmd.add(file.getUrl()); ffmpegCmd.add("-c"); ffmpegCmd.add("copy"); ffmpegCmd.add("-bsf:v"); ffmpegCmd.add("h264_mp4toannexb"); ffmpegCmd.add("-f"); ffmpegCmd.add("mpegts"); ffmpegCmd.add(outFileName); return handleFfmpegProcess(ffmpegCmd); } private boolean convertHevcToTs(FileObject file, String outFileName) throws IOException { List ffmpegCmd = new ArrayList<>(); ffmpegCmd.add("ffmpeg"); ffmpegCmd.add("-hide_banner"); ffmpegCmd.add("-y"); ffmpegCmd.add("-i"); ffmpegCmd.add(file.getUrl()); ffmpegCmd.add("-c"); ffmpegCmd.add("copy"); ffmpegCmd.add("-bsf:v"); ffmpegCmd.add("hevc_mp4toannexb"); ffmpegCmd.add("-f"); ffmpegCmd.add("mpegts"); ffmpegCmd.add(outFileName); return handleFfmpegProcess(ffmpegCmd); } /** * 快速切割,不产生转码,速度快,但可能会出现:第一帧数据不是I帧导致前面的数据无法使用 * * @param inputFile 输入文件,ffmpeg支持的协议均可 * @param offset 离输入文件开始的偏移 * @param length 输出文件时长 * @param outputFile 输出文件名称 * @return 是否成功 * @throws IOException 奇奇怪怪的报错 */ private boolean quickVideoCut(String inputFile, BigDecimal offset, BigDecimal length, String outputFile) throws IOException { List ffmpegCmd = new ArrayList<>(); ffmpegCmd.add("ffmpeg"); ffmpegCmd.add("-hide_banner"); ffmpegCmd.add("-y"); ffmpegCmd.add("-i"); ffmpegCmd.add(inputFile); ffmpegCmd.add("-c:v"); ffmpegCmd.add("copy"); ffmpegCmd.add("-an"); ffmpegCmd.add("-ss"); ffmpegCmd.add(offset.toPlainString()); ffmpegCmd.add("-t"); ffmpegCmd.add(length.toPlainString()); ffmpegCmd.add("-f"); ffmpegCmd.add("mp4"); ffmpegCmd.add(outputFile); return handleFfmpegProcess(ffmpegCmd); } /** * 转码切割,兜底逻辑,速度慢,但优势:成功后转码视频绝对可用 * * @param inputFiles 输入文件List,ffmpeg支持的协议均可 * @param offset 离输入文件开始的偏移 * @param length 输出文件时长 * @param outputFile 输出文件名称 * @return 是否成功 * @throws IOException 奇奇怪怪的报错 */ private boolean slowVideoCut(List inputFiles, BigDecimal offset, BigDecimal length, String outputFile) throws IOException { List ffmpegCmd = new ArrayList<>(); ffmpegCmd.add("ffmpeg"); ffmpegCmd.add("-hide_banner"); ffmpegCmd.add("-y"); for (FileObject file : inputFiles) { ffmpegCmd.add("-i"); ffmpegCmd.add(file.getUrl()); } // 使用filter_complex做拼接 ffmpegCmd.add("-filter_complex"); ffmpegCmd.add( IntStream.range(0, inputFiles.size()).mapToObj(i -> "[" + i + ":v]").collect(Collectors.joining("")) + "concat=n="+inputFiles.size()+":v=1[v]" ); ffmpegCmd.add("-map"); ffmpegCmd.add("[v]"); ffmpegCmd.add("-preset:v"); ffmpegCmd.add("fast"); ffmpegCmd.add("-an"); // 没有使用copy,因为使用了filter_complex ffmpegCmd.add("-ss"); ffmpegCmd.add(offset.toPlainString()); ffmpegCmd.add("-t"); ffmpegCmd.add(length.toPlainString()); ffmpegCmd.add("-f"); ffmpegCmd.add("mp4"); ffmpegCmd.add(outputFile); return handleFfmpegProcess(ffmpegCmd); } /** * 运行ffmpeg,并确认ffmpeg是否正常退出 * * @param ffmpegCmd ffmpeg命令 * @return 是否正常退出 */ private static boolean handleFfmpegProcess(List ffmpegCmd) throws IOException { Date _startDt = new Date(); log.info("FFMPEG执行命令:【{}】", String.join(" ", ffmpegCmd)); ProcessBuilder pb = new ProcessBuilder(ffmpegCmd); Process ffmpegProcess = pb.start(); // 如果需要额外分析输出之类 if (log.isTraceEnabled()) { InputStream stderr = ffmpegProcess.getErrorStream(); BufferedReader reader = new BufferedReader(new InputStreamReader(stderr)); String line; while ((line = reader.readLine()) != null) { log.trace(line); } } try { // 最长1分钟 boolean exited = ffmpegProcess.waitFor(1, TimeUnit.MINUTES); if (exited) { int code = ffmpegProcess.exitValue(); Date _endDt = new Date(); log.info("FFMPEG执行命令结束,Code:【{}】,耗费时间:【{}ms】,命令:【{}】", code, _endDt.getTime() - _startDt.getTime(), String.join(" ", ffmpegCmd)); return 0 == code; } else { log.error("FFMPEG执行命令没有在1分钟内退出,命令:【{}】", String.join(" ", ffmpegCmd)); ffmpegProcess.destroy(); return false; } } catch (InterruptedException e) { // TODO: 被中断了 log.warn("FFMPEG执行命令:【{}】,被中断了", String.join(" ", ffmpegCmd)); return false; } } }