IT俱乐部 Java SpringBoot使用FFmpeg实现M3U8切片转码播放

SpringBoot使用FFmpeg实现M3U8切片转码播放

概述

视频上传到本地之后(此处可分片上传到本地,然后合并),使用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俱乐部其它相关文章!

本文收集自网络,不代表IT俱乐部立场,转载请注明出处。https://www.2it.club/code/java/13252.html
上一篇
下一篇
联系我们

联系我们

在线咨询: QQ交谈

邮箱: 1120393934@qq.com

工作时间:周一至周五,9:00-17:30,节假日休息

关注微信
微信扫一扫关注我们

微信扫一扫关注我们

返回顶部