Using the famous FFMPEG, video files are sliced into M3U8 and can be streamed online on demand via Springboot.
idea
The client uploads the video to the server. After the server slices the video, it returns to m3U8, cover and other access paths. It can be played online. The server can do some simple processing to the video, such as cropping, cover cutting time.
Video transcoding folder definition
Pleasant goat and Wolffy / / folder name is the video title | - index. M3u8 / / main m3u8 file, Can be configured in multiple rate of broadcast address | - poster. JPG / / interception cover photo / / slice | | - ts - index. M3u8 / / slice broadcast index | - key / / play need decryption of AES keyCopy the code
implementation
You need to install FFmpeg locally and add it to the PATH environment variable, if not search for information first
engineering
pom
The < 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. The demo < / groupId > < artifactId > demo < / artifactId > < version > 0.0.1 - the SNAPSHOT < / version > < the 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>Copy the code
The configuration file
server:
port: 80
app:
# address of the folder where transcoding videos are stored
video-folder: "C:\\Users\\Administrator\\Desktop\\tmp"
spring:
servlet:
multipart:
enabled: true
File size is not limited
max-file-size: - 1
Request body size is not limited
max-request-size: - 1
Temporary IO directory
location: "${java.io.tmpdir}"
# Do not delay parsing
resolve-lazily: false
> 1Mb, IO to temporary directory
file-size-threshold: 1MB
web:
resources:
static-locations:
- "classpath:/static/"
- "file:${app.video-folder}" Add the video folder directory to the static resources directory list
Copy the code
TranscodeConfig, used to control some parameters of transcoding
package com.demo.ffmpeg;
public class TranscodeConfig {
private String poster; HH:mm:ss.[SSS]
private String tsSeconds; // Ts fragment size, in seconds
private String cutStart; HH:mm:ss.[SSS]
private String cutEnd; HH:mm:ss.[SSS]
public String getPoster(a) {
return poster;
}
public void setPoster(String poster) {
this.poster = poster;
}
public String getTsSeconds(a) {
return tsSeconds;
}
public void setTsSeconds(String tsSeconds) {
this.tsSeconds = tsSeconds;
}
public String getCutStart(a) {
return cutStart;
}
public void setCutStart(String cutStart) {
this.cutStart = cutStart;
}
public String getCutEnd(a) {
return cutEnd;
}
public void setCutEnd(String cutEnd) {
this.cutEnd = cutEnd;
}
@Override
public String toString(a) {
return "TranscodeConfig [poster=" + poster + ", tsSeconds=" + tsSeconds + ", cutStart=" + cutStart + ", cutEnd="
+ cutEnd + "]"; }}Copy the code
MediaInfo encapsulates the basic information of a video
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(a) {
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(a) {
return streams;
}
public void setStreams(List<Stream> streams) {
this.streams = streams;
}
public Format getFormat(a) {
return format;
}
public void setFormat(Format format) {
this.format = format; }}Copy the code
FFmpegUtils, a utility class that encapsulates some operations of FFmpeg
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);
// cross-platform newline character
private static final String LINE_SEPARATOR = System.getProperty("line.separator");
/** * Generate a random 16-byte AESKEY *@return* /
private static byte[] genAesKey () {
try {
KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
keyGenerator.init(128);
return keyGenerator.generateKey().getEncoded();
} catch (NoSuchAlgorithmException e) {
return null; }}/** * Generate key_info, key file in the specified directory, return key_info file *@param folder
* @throws IOException
*/
private static Path genKeyInfo(String folder) throws IOException {
/ / AES keys
byte[] aesKey = genAesKey();
/ / AES vector
String iv = Hex.encodeHexString(genAesKey());
// Write to the key file
Path keyFile = Paths.get(folder, "key");
Files.write(keyFile, aesKey, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
// the key_info file is written
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("key").append(LINE_SEPARATOR); M3u8 Network path to load the key file
stringBuilder.append(keyFile.toString()).append(LINE_SEPARATOR); // FFmeg loads the key_info file path
stringBuilder.append(iv); / / ASE vector
Path keyInfo = Paths.get(folder, "key_info");
Files.write(keyInfo, stringBuilder.toString().getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
return keyInfo;
}
/** * generate the master index.m3u8 file * in the specified directory@paramFileName master m3u8 File address *@paramIndexPath Accesses the path * to child index.m3u8@paramBandWidth Stream bit rate *@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); / / bit rate
stringBuilder.append(indexPath);
Files.write(Paths.get(file), stringBuilder.toString().getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
}
/** * transcoding video to m3u8 *@paramSource video *@paramDestFolder Target folder *@paramConfig Configuration information *@throws IOException
* @throws InterruptedException
*/
public static void transcodeToM3u8(String source, String destFolder, TranscodeConfig config) throws IOException, InterruptedException {
// Check whether the source video exists
if(! Files.exists(Paths.get(source))) {throw new IllegalArgumentException("File does not exist:" + source);
}
// Create a working directory
Path workDir = Paths.get(destFolder, "ts");
Files.createDirectories(workDir);
// Generate KeyInfo in the working directory
Path keyInfo = genKeyInfo(workDir.toString());
// Build command
List<String> commands = new ArrayList<>();
commands.add("ffmpeg");
commands.add("-i") ;commands.add(source); / / the source file
commands.add("-c:v") ;commands.add("libx264"); // The video encoding is H264
commands.add("-c:a") ;commands.add("copy"); // Copy audio directly
commands.add("-hls_key_info_file") ;commands.add(keyInfo.toString()); // Specify the path to the key file
commands.add("-hls_time") ;commands.add(config.getTsSeconds()); // ts slice size
commands.add("-hls_playlist_type") ;commands.add("vod"); // On-demand mode
commands.add("-hls_segment_filename") ;commands.add("%06d.ts"); // ts slice file name
if (StringUtils.hasText(config.getCutStart())) {
commands.add("-ss") ;commands.add(config.getCutStart()); // Start time
}
if (StringUtils.hasText(config.getCutEnd())) {
commands.add("-to") ;commands.add(config.getCutEnd()); // End time
}
commands.add("index.m3u8"); // Generate the m3U8 file
// Build the process
Process process = new ProcessBuilder()
.command(commands)
.directory(workDir.toFile())
.start()
;
// Read the process standard output
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();
// The reading process is abnormal
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();
// block until the task is finished
if(process.waitFor() ! =0) {
throw new RuntimeException("Abnormal video section");
}
// Cut out the cover
if(! screenShots(source, String.join(File.separator, destFolder,"poster.jpg"), config.getPoster())) {
throw new RuntimeException("Abnormal cover interception");
}
// Get video information
MediaInfo mediaInfo = getMediaInfo(source);
if (mediaInfo == null) {
throw new RuntimeException("Abnormal access to media information");
}
// Generate the index.m3u8 file
genIndex(String.join(File.separator, destFolder, "index.m3u8"), "ts/index.m3u8", mediaInfo.getFormat().getBitRate());
// Delete the keyInfo file
Files.delete(keyInfo);
}
/** * Get the media information of the video file *@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;
}
/** * Capture the specified time frame of the video to generate a picture file *@paramSource Source file *@paramFile Image file *@paramTime Snapshot Indicates the 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();
// Read the process standard output
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();
// The reading process is abnormal
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; }}Copy the code
UploadController, perform transcoding operation
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"));
/** * Upload video for slice processing, return to access path *@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("Transcoding configuration: {}", transcodeConfig);
// The original file name is the title of the video
String title = video.getOriginalFilename();
// IO to temporary file
Path tempFile = tempDir.resolve(title);
LOGGER.info(IO to temporary file: {}, tempFile.toString());
try {
video.transferTo(tempFile);
// Delete the suffix
title = title.substring(0, title.lastIndexOf("."));
// Generate subdirectories by date
String today = DateTimeFormatter.ofPattern("yyyyMMdd").format(LocalDate.now());
// Try to create a video directory
Path targetFolder = Files.createDirectories(Paths.get(videoFolder, today, title));
LOGGER.info("Create folder directory: {}", targetFolder);
Files.createDirectories(targetFolder);
// Perform transcoding
LOGGER.info("Start transcoding");
try {
FFmpegUtils.transcodeToM3u8(tempFile.toString(), targetFolder.toString(), transcodeConfig);
} catch (Exception e) {
LOGGER.error("Transcoding exception: {}", 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);
}
// Encapsulate the 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 {
// Always delete temporary filesFiles.delete(tempFile); }}}Copy the code
Index.html, client
<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>Select transcoding files:<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 transcoding configuration here is fixed dead
var transCodeConfig = {
poster: "00:00:00. 001".// Capture the 1st millisecond as the cover
tsSeconds: 15.cutStart: "".cutEnd: ""
}
// Perform upload
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){
// Set the cover
video.poster = message.data.poster;
// Render to player
var hls = new Hls();
hls.loadSource(message.data.m3u8);
hls.attachMedia(video);
} else {
alert("Transcoding exception, see console for details.");
console.log(message.message);
}
})
.catch(err= > {
alert("Transcoding exception, see console for details.");
throw err
})
}
</script>
</html>
Copy the code
use
- In the configuration file, configure to the local video directory and start
- Open the page
localhost
- Click “Select File”, select a video file to upload, and wait until the execution is complete (no loading animation is made)
- After the backend transcoding is completed, the video information will be automatically loaded into the player. At this time, you can manually click the Play button to play the video
You can open the console to view the upload progress and the network loading information when playing
Starting: springboot. IO/topic / 366 / t…