Using the well-known FFMPEG, video files are sliced into M3U8 and can be streamed online on demand through SpringBoot.

idea

The client uploads the video to the server. The server slices the video and returns to m3U8, cover and other access paths. You can play it online. The server can do some simple things with the video, such as cropping, the capture time of the cover.

Definition of video transcoding folder

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 key

implementation

You need to install FFmpeg locally and add it to the PATH environment variable, if you don’t go through the search engine 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>

The configuration file

Server: port: 80 app: # Video-folder: "C:\ Users\ Administrator\ Desktop\ TMP "Spring: servlet: multipart: Max-request-size: -1 # Temporary I/O directory location: "${java.io.tmpdir}" # resolve-lazily: false # file size-threshold: 1Mb web: resources: Static -locations: - "classpath:/static/" - "file:${app.video-folder}" # Add the video folder directory to the static resource directory list

TranscodeConfig, which controls some of the 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() {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 encapsulates some of the basic information of the 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() { 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; }}

The FFmpegUtils utility class encapsulates some of the 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);
    
    
    // 跨平台换行符
    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, 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 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 (" File information: title={}, size={}", video.getOriginalFilename(), video.getSize()); Logger.info (" transcode configuration: {}", transcodeConfig); / / the original file name, the title of the video String title = video. GetOriginalFilename (); 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 (".")); / / subdirectories generated by date String today. = DateTimeFormatter ofPattern (" yyyyMMdd "). The format (LocalDate. Now ()); // Try to create a video directory: Path targetFolder = files.createDirectories (Path.get (videoFolder, today, title)); Logger.info (" create folder directory: {}", targetFolder); Files.createDirectories(targetFolder); // Perform the transcoding operation logger.info (" Start transcoding "); try { FFmpegUtils.transcodeToM3u8(tempFile.toString(), targetFolder.toString(), transcodeConfig); } catch (Exception e) {logger.error (" transcode Exception: {}", equetmessage ()); Map<String, Object> result = new HashMap<>(); result.put("success", false); result.put("message", e.getMessage()); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result); } // Wrap 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 Files files.delete (tempFile); }}}

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 file:  <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 (! Var transCodeConfig = {poster: "00:00:00.001", // capture the first milliseconds as the cover 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){// Set the cover video.poster = message.data.poster; Var HLS = new HLS (); hls.loadSource(message.data.m3u8); hls.attachMedia(video); } else {alert(" transcode exception, see console for details "); console.log(message.message); }}). Catch (err => {alert(" transcode exception "); throw err }) } </script> </html>

use

  1. In the configuration file, configure to the local video directory and then start
  2. Open the pagelocalhost
  3. Click “Select File”, select a video file to upload and wait for the execution to complete (no loading animation).
  4. After the back-end 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

You can open the console to see the upload progress, as well as network loading information while playing


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