The client uploads the video to the server, the server slices the video, AES encrypts it, and returns the m3u8 index file, cover and other information. It can be played online.

The server can do some simple processing of the video, such as cropping, cover interception time.

Video folder layout

1
2
3
4
5
6
mymovie         # The folder name is the video title
  |-index.m3u8  #the main m3u8 file, which can be configured with multiple bitrates for playback
  |-poster.jpg  # the cover image of the cutscene
  |-ts          # directory of the cutscene
    |-index.m3u8    # index of the cutscene
    |-key           # the AES KEY to be decrypted for playback

Code of the core

You need to install FFmpeg locally first and add it to the PATH environment variable, if you don’t know how to do it, you can find the information through search engines first.

Project

eclipse project

Pom.xml

 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
<project xmlns="http://maven.apache.org/POM/4.0.0"
 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
 <modelVersion>4.0.0</modelVersion>
 <groupId>com.demo</groupId>
 <artifactId>demo</artifactId>
 <version>0.0.1-SNAPSHOT</version>


 <parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>2.4.5</version>
  <relativePath /> <!-- lookup parent from repository -->
 </parent>

 <dependencies>
  <dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-test</artifactId>
   <scope>test</scope>
  </dependency>
  <dependency>
   <groupId>org.junit.vintage</groupId>
   <artifactId>junit-vintage-engine</artifactId>
   <scope>test</scope>
  </dependency>

  <dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-web</artifactId>
   <exclusions>
    <exclusion>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-tomcat</artifactId>
    </exclusion>
   </exclusions>
  </dependency>
  <dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-undertow</artifactId>
  </dependency>
  <dependency>
   <groupId>commons-codec</groupId>
   <artifactId>commons-codec</artifactId>
  </dependency>
  <dependency>
   <groupId>com.google.code.gson</groupId>
   <artifactId>gson</artifactId>
  </dependency>

 </dependencies>

 <build>
  <finalName>${project.artifactId}</finalName>
  <plugins>
   <plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
     <executable>true</executable>
    </configuration>
   </plugin>
  </plugins>
 </build>
</project>

Application.yaml

 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
server:
  port: 80


app:
  # Folder for storing videos
  video-folder: "C:\\Users\\Administrator\\Desktop\\tmp"

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}" # 把视频文件夹目录,添加到静态资源目录列表

TranscodeConfig

Used to control some parameters of the video slice, such as ts slice size, cover screenshot time, etc.

 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 + "]";
 }
}

MediaInfo

defines some meta information about the video.

 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<Stream> streams;

 @SerializedName("format")
 private Format format;

 public List<Stream> getStreams() {
  return streams;
 }

 public void setStreams(List<Stream> streams) {
  this.streams = streams;
 }

 public Format getFormat() {
  return format;
 }

 public void setFormat(Format format) {
  this.format = format;
 }
}

FFmpegUtils

A tool class that starts a new process and calls FFmpeg for video slicing.

  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
265
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<String> 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<String> 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<String> 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;
 }
}

UploadController

Process the video files uploaded by the client. Finish slicing and return the on-demand m3u8 index file to the client.

  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<String, Object> result = new HashMap<>();
    result.put("success", false);
    result.put("message", e.getMessage());
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
   }
   
   // 封装结果
   Map<String, Object> 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<String, Object> result = new HashMap<>();
   result.put("success", true);
   result.put("data", videoInfo);
   return result;
  } finally {
   // 始终删除临时文件
   Files.delete(tempFile);
  }
 }
}

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
49
50
51
52
53
54
55
56
57
58
59
60
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
        <script src="https://cdn.jsdelivr.net/hls.js/latest/hls.min.js"></script>
    </head>
    <body>
        选择转码文件: <input name="file" type="file" accept="video/*" onchange="upload(event)">
        <hr/>
  <video id="video"  width="500" height="400" controls="controls"></video>
    </body>
    <script>
    
     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
            })
        }
    </script>
</html>

Full Source Code

https://github.com/KevinBlandy/springboot-ffmpeg-demo

How does it work?

  1. Modify the local video folder in the application.yaml file (optional).
  2. Start the application.
  3. Open a browser and visit localhost.
  4. Click [select file], select a video file to upload and wait for the execution to finish (the slicing process will be long, be patient).
  5. After the server finishes slicing, it will automatically load the video information to the player, at this time you can manually click the play button to play.

Reference https://springboot.io/t/topic/3669