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

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