概述
视频上传到本地之后(此处可分片上传到本地,然后合并),使用ffmpeg对视频处理成M3U8文件,暂时只测试了avi和mp4格式的文件。
代码
pom.xml
1 | 4.0.0io.springbootspringboot-ffmpeg-demo0.0.1-SNAPSHOTorg.springframework.bootspring-boot-starter-parent2.5.4org.springframework.bootspring-boot-starter-testtestorg.junit.vintagejunit-vintage-enginetestorg.springframework.bootspring-boot-starter-weborg.springframework.bootspring-boot-starter-tomcatorg.springframework.bootspring-boot-starter-undertowcommons-codeccommons-codeccom.google.code.gsongson${project.artifactId}org.springframework.bootspring-boot-maven-plugintrue |
ffmpeg
FFmpegUtils
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 | package com.demo.ffmpeg; import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.List; import javax.crypto.KeyGenerator; import org.apache.commons.codec.binary.Hex; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.util.StringUtils; import com.google.gson.Gson; public class FFmpegUtils { private static final Logger LOGGER = LoggerFactory.getLogger(FFmpegUtils. class ); // 跨平台换行符 private static final String LINE_SEPARATOR = System.getProperty( "line.separator" ); /** * 生成随机16个字节的AESKEY * @return */ private static byte [] genAesKey () { try { KeyGenerator keyGenerator = KeyGenerator.getInstance( "AES" ); keyGenerator.init( 128 ); return keyGenerator.generateKey().getEncoded(); } catch (NoSuchAlgorithmException e) { return null ; } } /** * 在指定的目录下生成key_info, key文件,返回key_info文件 * @param folder * @throws IOException */ private static Path genKeyInfo(String folder) throws IOException { // AES 密钥 byte [] aesKey = genAesKey(); // AES 向量 String iv = Hex.encodeHexString(genAesKey()); // key 文件写入 Path keyFile = Paths.get(folder, "key" ); Files.write(keyFile, aesKey, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); // key_info 文件写入 StringBuilder stringBuilder = new StringBuilder(); stringBuilder.append( "key" ).append(LINE_SEPARATOR); // m3u8加载key文件网络路径 stringBuilder.append(keyFile.toString()).append(LINE_SEPARATOR); // FFmeg加载key_info文件路径 stringBuilder.append(iv); // ASE 向量 Path keyInfo = Paths.get(folder, "key_info" ); Files.write(keyInfo, stringBuilder.toString().getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); return keyInfo; } /** * 指定的目录下生成 master index.m3u8 文件 * @param fileName master m3u8文件地址 * @param indexPath 访问子index.m3u8的路径 * @param bandWidth 流码率 * @throws IOException */ private static void genIndex(String file, String indexPath, String bandWidth) throws IOException { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.append( "#EXTM3U" ).append(LINE_SEPARATOR); stringBuilder.append( "#EXT-X-STREAM-INF:BANDWIDTH=" + bandWidth).append(LINE_SEPARATOR); // 码率 stringBuilder.append(indexPath); Files.write(Paths.get(file), stringBuilder.toString().getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); } /** * 转码视频为m3u8 * @param source 源视频 * @param destFolder 目标文件夹 * @param config 配置信息 * @throws IOException * @throws InterruptedException */ public static void transcodeToM3u8(String source, String destFolder, TranscodeConfig config) throws IOException, InterruptedException { // 判断源视频是否存在 if (!Files.exists(Paths.get(source))) { throw new IllegalArgumentException( "文件不存在:" + source); } // 创建工作目录 Path workDir = Paths.get(destFolder, "ts" ); Files.createDirectories(workDir); // 在工作目录生成KeyInfo文件 Path keyInfo = genKeyInfo(workDir.toString()); // 构建命令 List commands = new ArrayList(); commands.add( "ffmpeg" ); commands.add( "-i" ) ;commands.add(source); // 源文件 commands.add( "-c:v" ) ;commands.add( "libx264" ); // 视频编码为H264 commands.add( "-c:a" ) ;commands.add( "copy" ); // 音频直接copy commands.add( "-hls_key_info_file" ) ;commands.add(keyInfo.toString()); // 指定密钥文件路径 commands.add( "-hls_time" ) ;commands.add(config.getTsSeconds()); // ts切片大小 commands.add( "-hls_playlist_type" ) ;commands.add( "vod" ); // 点播模式 commands.add( "-hls_segment_filename" ) ;commands.add( "%06d.ts" ); // ts切片文件名称 if (StringUtils.hasText(config.getCutStart())) { commands.add( "-ss" ) ;commands.add(config.getCutStart()); // 开始时间 } if (StringUtils.hasText(config.getCutEnd())) { commands.add( "-to" ) ;commands.add(config.getCutEnd()); // 结束时间 } commands.add( "index.m3u8" ); // 生成m3u8文件 // 构建进程 Process process = new ProcessBuilder() .command(commands) .directory(workDir.toFile()) .start() ; // 读取进程标准输出 new Thread(() -> { try (BufferedReader bufferedReader = new BufferedReader( new InputStreamReader(process.getInputStream()))) { String line = null ; while ((line = bufferedReader.readLine()) != null ) { LOGGER.info(line); } } catch (IOException e) { } }).start(); // 读取进程异常输出 new Thread(() -> { try (BufferedReader bufferedReader = new BufferedReader( new InputStreamReader(process.getErrorStream()))) { String line = null ; while ((line = bufferedReader.readLine()) != null ) { LOGGER.info(line); } } catch (IOException e) { } }).start(); // 阻塞直到任务结束 if (process.waitFor() != 0 ) { throw new RuntimeException( "视频切片异常" ); } // 切出封面 if (!screenShots(source, String.join(File.separator, destFolder, "poster.jpg" ), config.getPoster())) { throw new RuntimeException( "封面截取异常" ); } // 获取视频信息 MediaInfo mediaInfo = getMediaInfo(source); if (mediaInfo == null ) { throw new RuntimeException( "获取媒体信息异常" ); } // 生成index.m3u8文件 genIndex(String.join(File.separator, destFolder, "index.m3u8" ), "ts/index.m3u8" , mediaInfo.getFormat().getBitRate()); // 删除keyInfo文件 Files.delete(keyInfo); } /** * 获取视频文件的媒体信息 * @param source * @return * @throws IOException * @throws InterruptedException */ public static MediaInfo getMediaInfo(String source) throws IOException, InterruptedException { List commands = new ArrayList(); commands.add( "ffprobe" ); commands.add( "-i" ) ;commands.add(source); commands.add( "-show_format" ); commands.add( "-show_streams" ); commands.add( "-print_format" ) ;commands.add( "json" ); Process process = new ProcessBuilder(commands) .start(); MediaInfo mediaInfo = null ; try (BufferedReader bufferedReader = new BufferedReader( new InputStreamReader(process.getInputStream()))) { mediaInfo = new Gson().fromJson(bufferedReader, MediaInfo. class ); } catch (IOException e) { e.printStackTrace(); } if (process.waitFor() != 0 ) { return null ; } return mediaInfo; } /** * 截取视频的指定时间帧,生成图片文件 * @param source 源文件 * @param file 图片文件 * @param time 截图时间 HH:mm:ss.[SSS] * @throws IOException * @throws InterruptedException */ public static boolean screenShots(String source, String file, String time) throws IOException, InterruptedException { List commands = new ArrayList(); commands.add( "ffmpeg" ); commands.add( "-i" ) ;commands.add(source); commands.add( "-ss" ) ;commands.add(time); commands.add( "-y" ); commands.add( "-q:v" ) ;commands.add( "1" ); commands.add( "-frames:v" ) ;commands.add( "1" ); commands.add( "-f" ); ;commands.add( "image2" ); commands.add(file); Process process = new ProcessBuilder(commands) .start(); // 读取进程标准输出 new Thread(() -> { try (BufferedReader bufferedReader = new BufferedReader( new InputStreamReader(process.getInputStream()))) { String line = null ; while ((line = bufferedReader.readLine()) != null ) { LOGGER.info(line); } } catch (IOException e) { } }).start(); // 读取进程异常输出 new Thread(() -> { try (BufferedReader bufferedReader = new BufferedReader( new InputStreamReader(process.getErrorStream()))) { String line = null ; while ((line = bufferedReader.readLine()) != null ) { LOGGER.error(line); } } catch (IOException e) { } }).start(); return process.waitFor() == 0 ; } } |
MediaInfo
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | package com.demo.ffmpeg; import java.util.List; import com.google.gson.annotations.SerializedName; public class MediaInfo { public static class Format { @SerializedName ( "bit_rate" ) private String bitRate; public String getBitRate() { return bitRate; } public void setBitRate(String bitRate) { this .bitRate = bitRate; } } public static class Stream { @SerializedName ( "index" ) private int index; @SerializedName ( "codec_name" ) private String codecName; @SerializedName ( "codec_long_name" ) private String codecLongame; @SerializedName ( "profile" ) private String profile; } // ---------------------------------- @SerializedName ( "streams" ) private List streams; @SerializedName ( "format" ) private Format format; public List getStreams() { return streams; } public void setStreams(List streams) { this .streams = streams; } public Format getFormat() { return format; } public void setFormat(Format format) { this .format = format; } } |
TranscodeConfig
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | package com.demo.ffmpeg; public class TranscodeConfig { private String poster; // 截取封面的时间 HH:mm:ss.[SSS] private String tsSeconds; // ts分片大小,单位是秒 private String cutStart; // 视频裁剪,开始时间 HH:mm:ss.[SSS] private String cutEnd; // 视频裁剪,结束时间 HH:mm:ss.[SSS] public String getPoster() { return poster; } public void setPoster(String poster) { this .poster = poster; } public String getTsSeconds() { return tsSeconds; } public void setTsSeconds(String tsSeconds) { this .tsSeconds = tsSeconds; } public String getCutStart() { return cutStart; } public void setCutStart(String cutStart) { this .cutStart = cutStart; } public String getCutEnd() { return cutEnd; } public void setCutEnd(String cutEnd) { this .cutEnd = cutEnd; } @Override public String toString() { return "TranscodeConfig [poster=" + poster + ", tsSeconds=" + tsSeconds + ", cutStart=" + cutStart + ", cutEnd=" + cutEnd + "]" ; } } |
application.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | server: port: 80 #logging: # level: # "root": DEBUG app: # 存储转码视频的文件夹 video-folder: D : video spring: servlet: multipart: enabled: true # 不限制文件大小 max-file-size: -1 # 不限制请求体大小 max-request-size: -1 # 临时IO目录 location: "${java.io.tmpdir}" # 不延迟解析 resolve-lazily: false # 超过1Mb,就IO到临时目录 file-size-threshold: 1MB web: resources: static-locations: - "classpath:/static/" - "file:${app.video-folder}" # 把视频文件夹目录,添加到静态资源目录列表 |
Application
1 2 3 4 5 6 | @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application. class , args); } } |
UploadController
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 | package com.demo.web.controller; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import com.demo.ffmpeg.FFmpegUtils; import com.demo.ffmpeg.TranscodeConfig; @RestController @RequestMapping ( "/upload" ) public class UploadController { private static final Logger LOGGER = LoggerFactory.getLogger(UploadController. class ); @Value ( "${app.video-folder}" ) private String videoFolder; private Path tempDir = Paths.get(System.getProperty( "java.io.tmpdir" )); /** * 上传视频进行切片处理,返回访问路径 * @param video * @param transcodeConfig * @return * @throws IOException */ @PostMapping public Object upload ( @RequestPart (name = "file" , required = true ) MultipartFile video, @RequestPart (name = "config" , required = true ) TranscodeConfig transcodeConfig) throws IOException { LOGGER.info( "文件信息:title={}, size={}" , video.getOriginalFilename(), video.getSize()); LOGGER.info( "转码配置:{}" , transcodeConfig); // 原始文件名称,也就是视频的标题 String title = video.getOriginalFilename(); // io到临时文件 Path tempFile = tempDir.resolve(title); LOGGER.info( "io到临时文件:{}" , tempFile.toString()); try { video.transferTo(tempFile); // 删除后缀 title = title.substring( 0 , title.lastIndexOf( "." )); // 按照日期生成子目录 String today = DateTimeFormatter.ofPattern( "yyyyMMdd" ).format(LocalDate.now()); // 尝试创建视频目录 Path targetFolder = Files.createDirectories(Paths.get(videoFolder, today, title)); LOGGER.info( "创建文件夹目录:{}" , targetFolder); Files.createDirectories(targetFolder); // 执行转码操作 LOGGER.info( "开始转码" ); try { FFmpegUtils.transcodeToM3u8(tempFile.toString(), targetFolder.toString(), transcodeConfig); } catch (Exception e) { LOGGER.error( "转码异常:{}" , e.getMessage()); Map result = new HashMap(); result.put( "success" , false ); result.put( "message" , e.getMessage()); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result); } // 封装结果 Map videoInfo = new HashMap(); videoInfo.put( "title" , title); videoInfo.put( "m3u8" , String.join( "/" , "" , today, title, "index.m3u8" )); videoInfo.put( "poster" , String.join( "/" , "" , today, title, "poster.jpg" )); Map result = new HashMap(); result.put( "success" , true ); result.put( "data" , videoInfo); return result; } finally { // 始终删除临时文件 Files.delete(tempFile); } } } |
index.html
在resources/static/index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | < title >Title</ title > 选择转码文件: < hr >< video id = "video" width = "500" height = "400" controls = "controls" data-origwidth = "500" data-origheight = "400" style = "width: 1264px; height: 1011.2px;" ></ video > const video = document.getElementById('video'); function upload (e){ let files = e.target.files if (!files) { return } // TODO 转码配置这里固定死了 var transCodeConfig = { poster: "00:00:00.001", // 截取第1毫秒作为封面 tsSeconds: 15, cutStart: "", cutEnd: "" } // 执行上传 let formData = new FormData(); formData.append("file", files[0]) formData.append("config", new Blob([JSON.stringify(transCodeConfig)], {type: "application/json; charset=utf-8"})) fetch('/upload', { method: 'POST', body: formData }) .then(resp => resp.json()) .then(message => { if (message.success){ // 设置封面 video.poster = message.data.poster; // 渲染到播放器 var hls = new Hls(); hls.loadSource(message.data.m3u8); hls.attachMedia(video); } else { alert("转码异常,详情查看控制台"); console.log(message.message); } }) .catch(err => { alert("转码异常,详情查看控制台"); throw err }) } |
测试
avi格式视频转码m3u8
1 2 3 4 5 6 | 01-JVM内存与垃圾回收篇概述 // 文件夹名称就是视频标题 |-index.m3u8 // 主m3u8文件,里面可以配置多个码率的播放地址 |-poster.jpg // 截取的封面图片 |-ts // 切片目录 |-index.m3u8 // 切片播放索引 |-key // 播放需要解密的AES KEY |
mp4格式视频转码m3u8
以上就是SpringBoot使用FFmpeg实现M3U8切片转码播放的详细内容,更多关于SpringBoot M3U8转码播放的资料请关注IT俱乐部其它相关文章!