diff --git a/CHANGELOG.md b/CHANGELOG.md index 78efde3..aa11b68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,14 @@ * [V 1.0.1]() * [V 1.0.0]() +------ +## [V 1.0.4] - 2023.4.28 +### SectionModule +- 🎈新增: 新增 `VideoUtil` ,操作视频文件的类,目前可以进行视频剪辑,视频格式转换,视频封面截取 +- 🧪测试: 测试 `VideoUtil` 剪辑,格式转换,视频封面截取,目前都可以正常使用,格式转换m3u8转mp4目前会出现片段丢失的情况 + +### common +- 🎈新增: `ConstPool` 新增 `PIC_TYPE`数组,用于存储图片类型常量例如 `jpg,jepg,png` ------ ## [V 1.0.4] - 2023.4.26 ### FileModule diff --git a/FileModule/pom.xml b/FileModule/pom.xml index e18c0a0..18f5c3f 100644 --- a/FileModule/pom.xml +++ b/FileModule/pom.xml @@ -18,6 +18,8 @@ + + org.example common diff --git a/SectionModule/pom.xml b/SectionModule/pom.xml index 21b9c6b..34879d3 100644 --- a/SectionModule/pom.xml +++ b/SectionModule/pom.xml @@ -12,16 +12,100 @@ SectionModule http://maven.apache.org + + + + org.apache.maven.plugins + maven-compiler-plugin + + 9 + 9 + + + + - UTF-8 + 1.8 + + 0.6.2 + 1.5.8 + 1.5.8 + 5.1.2-1.5.8 + 4.6.0-1.5.8 + + windows-x86_64 + linux-x86_64 + + + true + - junit - junit - 3.8.1 + org.example + common + 1.0-SNAPSHOT + + + + + org.bytedeco + javacv + ${javacv.version} + + + org.bytedeco + javacpp + + + + + + + org.bytedeco + javacpp + ${javacpp.version} + ${javacpp.platform.dependencies} + + + org.bytedeco + javacpp + ${javacpp.version} + ${javacpp.platform.linux-x86_64} + + + + + org.bytedeco + ffmpeg + ${bytedeco-ffmpeg.version} + linux-x86_64-gpl + + + + org.bytedeco + ffmpeg + ${bytedeco-ffmpeg.version} + windows-x86_64-gpl + + + + net.bramp.ffmpeg + ffmpeg + ${ffmpeg.version} + + + + org.bytedeco + opencv-platform-gpu + ${opencv-platform-gpu.version} + + + + org.springframework.boot + spring-boot-starter-test test diff --git a/SectionModule/src/main/java/org/example/util/VideoUtil.java b/SectionModule/src/main/java/org/example/util/VideoUtil.java new file mode 100644 index 0000000..59a409f --- /dev/null +++ b/SectionModule/src/main/java/org/example/util/VideoUtil.java @@ -0,0 +1,403 @@ +package org.example.util; + +import com.google.common.base.Joiner; +import com.google.common.base.Splitter; +import org.apache.commons.lang3.StringUtils; +import org.bytedeco.ffmpeg.avcodec.AVCodecParameters; +import org.bytedeco.ffmpeg.avformat.AVFormatContext; +import org.bytedeco.ffmpeg.avformat.AVStream; +import org.bytedeco.ffmpeg.avutil.AVRational; +import org.bytedeco.ffmpeg.global.avcodec; +import org.bytedeco.javacv.*; +import org.bytedeco.javacv.Frame; +import org.example.constpool.ConstPool; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.text.MessageFormat; +import java.util.*; +import java.util.List; + +/** + * @author Genius + * @date 2023/04/27 16:01 + **/ +public class VideoUtil { + + private static final Logger logger = LoggerFactory.getLogger(VideoUtil.class); + + /** + * 获取视频采集器 + * @param sourcePath 视频地址 + * @return FFmpegFrameGrabber + */ + private static FFmpegFrameGrabber getGrabber(String sourcePath){ + if(Objects.isNull(sourcePath)){ + return null; + } + return new FFmpegFrameGrabber(sourcePath); + } + + /** + * 获取视频录制器 + * @param targetPath 目标地址 + * @return FFmpegFrameRecorder + */ + private static FFmpegFrameRecorder getRecorder(String targetPath,FFmpegFrameGrabber grabber){ + if(Objects.isNull(targetPath)){ + return null; + } + return new FFmpegFrameRecorder(targetPath,grabber.getImageWidth(),grabber.getImageHeight(),grabber.getAudioChannels()); + } + + /** + * 获取视频录制器 + * @param targetPath 目标地址 + * @param width 宽度 + * @param height 高度 + * @param audioChannels 音频通道 + * @return + */ + private static FFmpegFrameRecorder getRecorder(String targetPath,Integer width,Integer height,Integer audioChannels){ + if(Objects.isNull(targetPath)){ + return null; + } + return new FFmpegFrameRecorder(targetPath,width,height,audioChannels); + } + + /*-----------------------------视频信息---------------------------------*/ + + /** + * 获取视频时长 + * @param videoPath 视频地址 + * @return long 时长 + */ + public static long getVideoTime(String videoPath){ + return getVideoTime(getGrabber(videoPath)); + } + + //获取视频时长 + public static long getVideoTime(FFmpegFrameGrabber grabber){ + long times = 0L; + try(grabber){ + grabber.start(); + times = grabber.getLengthInTime() /(1000*1000); + }catch (Exception e){ + e.printStackTrace(); + } + return times; + } + + + /*------------------------------截图------------------------------------*/ + + /** + * 获取视频某一帧 作为截图 + * @param videoPath 视频地址 + * @param savePicPath 保存地址 + * @param fileName 保存文件名 + * @param seconds 要截取的秒数数组 + * @return + */ + public static List getVideoPic(String videoPath,String savePicPath,String fileName,List seconds){ + return getVideoPic(videoPath,savePicPath,fileName,"jpg",-1,seconds); + } + + /** + * 获取视频某一帧 作为截图 + * @param videoPath 视频地址 + * @param savePicPath 保存地址 + * @param fileName 文件名 + * @param suffix 文件保存后缀 + * @param resizeWidth 重新缩放的宽度 + * @param seconds 时间 + * @return + */ + public static List getVideoPic(String videoPath,String savePicPath,String fileName,String suffix,Integer resizeWidth,List seconds){ + return getVideoPic(getGrabber(videoPath),savePicPath,fileName,suffix,resizeWidth,seconds); + } + + public static List getVideoPic(FFmpegFrameGrabber grabber,String savePicPath,String fileName,String suffix,Integer resizeWidth,List seconds){ + ArrayList files = new ArrayList<>(); + try(grabber){ + assert grabber != null; + grabber.start(); + int length = grabber.getLengthInAudioFrames(); + logger.info("视频长度{}",length); + double rate = grabber.getFrameRate(); + logger.info("视频帧率{}",rate); + + int i =0; + Frame frame; + Arrays.sort(seconds.toArray()); + int first = 0; + int secondLength = seconds.size(); + while(i= (int) (grabber.getFrameRate() * seconds.get(first)) && frame.image != null) { + files.add(writeToPIC(frame, savePicPath,fileName,suffix,resizeWidth,first)); + first++; + if(first>=secondLength){ + break; + } + } + i++; + } + }catch (Exception e){ + e.printStackTrace(); + } + return files; + } + + + /** + * 视频帧写成图片 + * @param frame + * @param saveFile + * @param fileName + * @param suffix + * @param newWidth + * @param second + * @return + */ + public static File writeToPIC(Frame frame, String saveFile,String fileName,String suffix,Integer newWidth,int second){ + suffix = suffix.toLowerCase(); + + if(!ConstPool.PIC_TYPES.contains(suffix)){ + return null; + } + + fileName = Paths.get(saveFile,fileName+second+"."+suffix).toString(); + File targetFile = new File(fileName); + + Java2DFrameConverter converter = new Java2DFrameConverter(); + BufferedImage srcBi = converter.getBufferedImage(frame); + int owidth = srcBi.getWidth(); + int oheight = srcBi.getHeight(); + // 对截取的帧进行等比例缩放 + newWidth = newWidth==-1?owidth:newWidth; + BufferedImage bi = proResize(owidth, oheight, newWidth); + bi.getGraphics().drawImage(srcBi.getScaledInstance(bi.getWidth(), bi.getHeight(), Image.SCALE_SMOOTH), 0, 0, null); + try { + ImageIO.write(bi, suffix, targetFile); + } catch (Exception e) { + e.printStackTrace(); + } + return targetFile; + } + + //按比例缩放图片 + public static BufferedImage proResize(int oldWidth,int oldHeight,int newWidth){ + int newHeight = (int) (((double) newWidth / oldWidth) * oldHeight); + return new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_3BYTE_BGR); + } + + + /*------------------------------视频转换-----------------------------------*/ + + /** + * 视频转分辨率转视频编码 + * + * @param inputFile 源文件 + * @param outputPath 输出目录 + * @param fileName 文件名 + * @param width 需要转成的视频的宽度 + * @param height 需要转成的视频的高度 + * @param videoFormat 需要转换成的视频格式 + * @return 返回新文件名称,可以根据实际使用修改 + */ + public static String videoConvert(String inputFile, String outputPath, String fileName,Integer width, Integer height, String videoFormat) {; + String newFileName = fileName + "." + videoFormat; + String resultPath = Paths.get(outputPath,newFileName).toString(); + //FFmpegLogCallback.set(); + Frame frame; + FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(inputFile); + FFmpegFrameRecorder recorder = null; + try { + // 初始化帧抓取器,例如数据结构(时间戳、编码器上下文、帧对象等), + // 如果入参等于true,还会调用avformat_find_stream_info方法获取流的信息,放入AVFormatContext类型的成员变量oc中 + grabber.start(true); + // grabber.start方法中,初始化的解码器信息存在放在grabber的成员变量oc中 + AVFormatContext avformatcontext = grabber.getFormatContext(); + // 文件内有几个媒体流(一般是视频流+音频流) + int streamNum = avformatcontext.nb_streams(); + // 没有媒体流就不用继续了 + if (streamNum < 1) { + logger.info("视频文件格式不对"); + return "error"; + } + width = width==-1?grabber.getImageWidth():width; + height = height==-1?grabber.getImageHeight():height; + // 取得视频的帧率 + int framerate = (int) grabber.getVideoFrameRate(); + logger.info("视频帧率:{},视频时长:{}秒,媒体流数量:{}", framerate, avformatcontext.duration() / 1000000, + streamNum); + // 遍历每一个流,检查其类型 + for (int i = 0; i < streamNum; i++) { + AVStream avstream = avformatcontext.streams(i); + AVCodecParameters avcodecparameters = avstream.codecpar(); + logger.info("流的索引:{},编码器类型:{},编码器ID:{}", i, avcodecparameters.codec_type(), + avcodecparameters.codec_id()); + } + // 源视频宽度 + int frameWidth = grabber.getImageWidth(); + // 源视频高度 + int frameHeight = grabber.getImageHeight(); + // 源音频通道数量 + int audioChannels = grabber.getAudioChannels(); + logger.info("源视频宽度:{},源视频高度:{},音频通道数:{}", frameWidth, frameHeight, audioChannels); + recorder = new FFmpegFrameRecorder(resultPath, width, height, audioChannels); + // 设置H264编码 + recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); + // 设置视频格式 + recorder.setFormat(videoFormat); + // 使用原始视频的码率,若需要则自行修改码率 + recorder.setVideoBitrate(grabber.getVideoBitrate()); + // 一秒内的帧数,帧率 + recorder.setFrameRate(framerate); + // 两个关键帧之间的帧数 + recorder.setGopSize(framerate); + // 设置音频通道数,与视频源的通道数相等 + recorder.setAudioChannels(grabber.getAudioChannels()); + recorder.start(); + int videoFrameNum = 0; + int audioFrameNum = 0; + int dataFrameNum = 0; + // 持续从视频源取帧 + while (null != (frame = grabber.grab())) { + // 有图像,就把视频帧加一 + if (null != frame.image) { + videoFrameNum++; + // 取出的每一帧,都保存到视频 + recorder.record(frame); + } + // 有声音,就把音频帧加一 + if (null != frame.samples) { + audioFrameNum++; + // 取出的每一帧,都保存到视频 + recorder.record(frame); + } + // 有数据,就把数据帧加一 + if (null != frame.data) { + dataFrameNum++; + } + } + logger.info("转码完成,视频帧:{},音频帧:{},数据帧:{}", videoFrameNum, audioFrameNum, dataFrameNum); + } catch (Exception e) { + logger.error(e.getMessage()); + return "error"; + } finally { + if (recorder != null) { + try { + recorder.close(); + } catch (Exception e) { + logger.info("recorder.close异常" + e); + } + } + try { + grabber.close(); + } catch (FrameGrabber.Exception e) { + logger.info("frameGrabber.close异常" + e); + } + } + return newFileName; + } + + + /*------------------------------视频剪辑-----------------------------------*/ + /**视频剪辑 + * @param videoPath 视频地址 + * @param outPutPath 输出地址 + * @param startSecond 视频开始时间 + * @param endSecond 视频结束时间 + */ + public static String cutVideo(String videoPath,String outPutPath,int startSecond,int endSecond){ + Frame frame; + FFmpegFrameRecorder recorder = null; + try(FFmpegFrameGrabber grabber = getGrabber(videoPath)) { + // 初始化帧抓取器,例如数据结构(时间戳、编码器上下文、帧对象等), + // 如果入参等于true,还会调用avformat_find_stream_info方法获取流的信息,放入AVFormatContext类型的成员变量oc中 + grabber.start(true); + // grabber.start方法中,初始化的解码器信息存在放在grabber的成员变量oc中 + AVFormatContext avformatcontext = grabber.getFormatContext(); + // 文件内有几个媒体流(一般是视频流+音频流) + int streamNum = avformatcontext.nb_streams(); + // 没有媒体流就不用继续了 + if (streamNum < 1) { + logger.info("视频文件格式不对"); + return "error"; + } + // 取得视频的帧率 + int framerate = (int) grabber.getVideoFrameRate(); + logger.info("视频帧率:{},视频时长:{}秒,媒体流数量:{}", framerate, avformatcontext.duration() / 1000000, + streamNum); + // 遍历每一个流,检查其类型 + for (int i = 0; i < streamNum; i++) { + AVStream avstream = avformatcontext.streams(i); + AVCodecParameters avcodecparameters = avstream.codecpar(); + logger.info("流的索引:{},编码器类型:{},编码器ID:{}", i, avcodecparameters.codec_type(), + avcodecparameters.codec_id()); + } + // 源视频宽度 + int frameWidth = grabber.getImageWidth(); + // 源视频高度 + int frameHeight = grabber.getImageHeight(); + // 源音频通道数量 + int audioChannels = grabber.getAudioChannels(); + logger.info("源视频宽度:{},源视频高度:{},音频通道数:{}", frameWidth, frameHeight, audioChannels); + recorder = new FFmpegFrameRecorder(outPutPath, frameWidth, frameHeight, audioChannels); + // 设置H264编码 + recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); + // 设置视频格式 + recorder.setFormat(grabber.getFormat()); + // 使用原始视频的码率,若需要则自行修改码率 + recorder.setVideoBitrate(grabber.getVideoBitrate()); + // 一秒内的帧数,帧率 + recorder.setFrameRate(framerate); + // 两个关键帧之间的帧数 + recorder.setGopSize(framerate); + // 设置音频通道数,与视频源的通道数相等 + recorder.setAudioChannels(grabber.getAudioChannels()); + recorder.start(); + // 持续从视频源取帧 + int startFrames = framerate*startSecond; + int endFrames = framerate*endSecond; + int i = 0; + while (i<=endFrames) { + frame = grabber.grabImage(); + if(i>=startFrames){ + // 有图像,就把视频帧加一 + if (null != frame.image) { + recorder.record(frame); + } + // 有声音,就把音频帧加一 + if (null != frame.samples) { + // 取出的每一帧,都保存到视频 + recorder.record(frame); + } + } + i++; + } + } catch (Exception e) { + logger.error(e.getMessage()); + return "error"; + }finally { + if (recorder != null) { + try { + recorder.close(); + } catch (Exception e) { + logger.info("recorder.close异常" + e); + } + } + + } + return outPutPath; + } + +} diff --git a/SectionModule/src/main/resources/video/76000.mp4 b/SectionModule/src/main/resources/video/76000.mp4 new file mode 100644 index 0000000..f78e261 Binary files /dev/null and b/SectionModule/src/main/resources/video/76000.mp4 differ diff --git a/SectionModule/src/test/java/org/example/util/VideoUtilTest.java b/SectionModule/src/test/java/org/example/util/VideoUtilTest.java new file mode 100644 index 0000000..4cc2de5 --- /dev/null +++ b/SectionModule/src/test/java/org/example/util/VideoUtilTest.java @@ -0,0 +1,47 @@ +package org.example.util; + +import org.junit.jupiter.api.Test; + +import java.lang.annotation.Target; +import java.util.List; + +/** + * @author Genius + * @date 2023/04/28 00:17 + **/ +public class VideoUtilTest { + + //截取封面视频 + @Test + public void generateVideoPic(){ + + VideoUtil.getVideoPic( + "E:\\Project\\ChopperBot\\SectionModule\\src\\main\\resources\\video\\76000.mp4", + "E:\\Project\\ChopperBot\\SectionModule\\src\\main\\resources\\pic", + "pic", + List.of(10,20,30,40) + ); + } + + //获取视频时长 + @Test + public void getVideoTime(){ + System.out.println(VideoUtil.getVideoTime("E:\\Project\\ChopperBot\\SectionModule\\src\\main\\resources\\video\\76000.mp4")); + } + + //视频转换 + @Test + public void convertVideoType(){ + VideoUtil.videoConvert( + "E:\\Project\\ChopperBot\\SectionModule\\src\\main\\resources\\video\\temp.m3u8", + "E:\\Project\\ChopperBot\\SectionModule\\src\\main\\resources\\video\\", + "temp",-1,-1,"mp4" + ); + } + + @Test + public void cutVideo(){ + VideoUtil.cutVideo("E:\\Project\\ChopperBot\\SectionModule\\src\\main\\resources\\video\\76000.mp4", + "E:\\Project\\ChopperBot\\SectionModule\\src\\main\\resources\\video\\76001.mp4",10,30); + } +} diff --git a/common/src/main/java/org/example/constpool/ConstPool.java b/common/src/main/java/org/example/constpool/ConstPool.java index 732fc14..4e44bca 100644 --- a/common/src/main/java/org/example/constpool/ConstPool.java +++ b/common/src/main/java/org/example/constpool/ConstPool.java @@ -1,5 +1,7 @@ package org.example.constpool; +import java.util.List; + /** * @author Genius * @date 2023/04/21 03:03 @@ -16,4 +18,6 @@ public class ConstPool { public static final String HOT = "hot"; public static final String PUBLISH = "publish"; + /**图片格式**/ + public static final List PIC_TYPES = List.of("jpg","jpeg","png","svg"); }