视频播放与传输设计方案
这篇文章将通过SpringBoot + FFMPEG + hls.js 介绍一种前端视频播放器的实现方案
现代浏览器播放视频一般通过video标签请求mp4视频资源
但是通过src访问视频资源存在局限性
- 必须在视频加载完毕后才能播放视频
- 不支持视频分片播放
M3U8和TS
如果不支持分片播放视频,那么如果有大视频需要播放,用户就必须等待视频文件流全部传输完毕才行,这不仅效率低下,而且还有可能因为网络波动导致视频流丢失,造成视频无法正常播放。
因此这里介绍下 .m3u8
和 .ts
文件
M3U8
M3U8文件是一种用于在线流媒体播放的视频文件格式。它通常是一组直播视频流的列表文件,包含了多个视频流的地址,用于在互联网上传输和播放视频直播。
当M3U8文件作为媒体播放列表(Media Playlist)时,其内部信息记录的是一系列媒体片段资源,顺序播放该片段资源,即可完整展示多媒体资源。
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-ALLOW-CACHE:YES
#EXT-X-TARGETDURATION:11
#EXTINF:10.021333,
1405227008_00000.ts
#EXTINF:10.000000,
1405227008_00001.ts
#EXT-X-ENDLIST
这是一个m3u8文件格式,记录了视频时间戳以及对应的ts文件
TS
TS文件,全称为Transport Stream(传输流),是一种封装的格式,用于传输和存储音视频、节目和系统信息。
由于TS的传输包长度是固定的,因此可以将视频、音频和数据信息进行实时的、灵活的分配。
这里的TS文件正是我们需要的视频分片,这里我们只需要把视频分片传输给前端即可播放。
FFMPEG
FFMPEG是一套开源的计算机程序,可以用来记录、转换数字音频、视频,并能将其转化为流。它提供了录制、转换以及流化音视频的完整解决方案。这里我们就需要在用户播放指定视频文件时让后端调用ffmpeg程序去生成我们需要的m3u8文件和ts文件
后端设计
MVC
这里映射请求地址对应接口,调用media服务,传输相关的鉴权,请求参数
@RestController
@RequestMapping("/media")
public class MediaController {
@Autowired
private MediaService mediaService;
@ApiOperation(value = "播放视频",notes = "播放视频",httpMethod = "POST")
@PostMapping("/playVideo")
public ResponseEntity playVideo(@RequestAttribute(Constant.AUTHENTICATION) JwtUser jwtUser,
@RequestParam("fid") Integer fid) throws GlobalException {
String m3u8Path = mediaService.playVideo(jwtUser, fid);
return ResponseEntityUtils.getSuccessResult(m3u8Path);
}
@ApiOperation(value = "media资源请求",notes = "m3u8请求",httpMethod = "GET")
@GetMapping("/{source}")
public void fetchMedia(@RequestAttribute(Constant.AUTHENTICATION) JwtUser jwtUser,
@PathVariable("source") String source) throws GlobalException {
mediaService.fetchMedia(jwtUser, source);
}
}
接口地址
媒体服务
媒体服务有两个接口,分别处理后端对视频播放前准备逻辑,和视频播放过程的逻辑
playVideo
接口负责播放用户指定文件,并执行生成ts文件任务,在生成完毕后返回m3u8文件请求地址给前端fetchMedia
接口负责解析请求,并返回对应的m3u8文件和ts文件流给前端
@Service
public class MediaServiceImpl implements MediaService {
@Autowired
private SourceService sourceService;
@Autowired
private FileService fileService;
@Value("${media.mediaBase}")
private String mediaBase;
@Override
public String playVideo(JwtUser jwtUser, Integer fid) {
//1 查询文件信息
FilePo filePo = fileService.queryUserFileById(jwtUser.getUid(), fid);
//2 检查文件类型
if (FileConstant.FILE_TYPE_VIDEO != filePo.getFileType()){
throw new GlobalException("非视频格式资源,无法播放视频");
}
//3 获取资源地址
Integer sourceId = filePo.getSourceId();
String path = sourceService.locateSource(sourceId);
//4 ffmpeg执行视频切割 生成ts 和 m3u8文件
VideoConverter videoConverter = VideoConverter.getInstance();
VideoOutput videoOutput = videoConverter.converter(path, mediaBase);
//返回请求视频资源url
String m3u8Path = videoOutput.getFetchUrl();
return m3u8Path;
}
@Override
public void fetchMedia(JwtUser jwtUser, String source) {
HttpServletResponse currentResponse = ResponseUtils.getCurrentResponse();
File file;
if (source.endsWith(".ts") && source.contains("_")){
String[] s = source.split("_");
file = new File(mediaBase + File.separator + s[0] + File.separator + source);
}else if (source.endsWith(".m3u8")){
file = new File(mediaBase + File.separator + source.replace(".m3u8","") + File.separator + source);
}else {
throw new GlobalException("非法媒体资源请求格式");
}
if (file.exists()){
try {
FileInputStream inputStream = new FileInputStream(file);
IOUtils.copy(inputStream,currentResponse.getOutputStream());
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException ioException) {
ioException.printStackTrace();
}
}
}
}
playVideo服务最关键的是VideoConverter
这个类执行的ts文件生成方法,详细看下文FFMPEG生成TS,M3U8
fetchMedia服务根据请求查询对应的ts文件,将ts文件通过IO工具写入响应流即可
相关工具类
响应工具类通过Spring提供的请求上下文获取到当前线程服务请求的响应对象
/**
* 响应工具类
* author: os467
*/
public class ResponseUtils {
public static HttpServletResponse getCurrentResponse() {
//获取到当前线程的请求对象
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
HttpServletRequest request;
//向下转型
ServletRequestAttributes requestAttributes = (ServletRequestAttributes)attributes;
HttpServletResponse response = requestAttributes.getResponse();
return response;
}
public static void setResponseZIP(HttpServletResponse response,String fileName){
response.setContentType("application/zip");
setUTF8ResponseFileName(response, fileName,true);
}
public static void setResponseFile(HttpServletResponse response, String fileName, Long contentLength){
//比特流
response.setContentType("application/octet-stream");
setUTF8ResponseFileName(response, fileName,false);
//设置响应实体大小
response.setContentLengthLong(contentLength);
}
private static void setUTF8ResponseFileName(HttpServletResponse response, String fileName,boolean zip) {
//设置响应头
response.setCharacterEncoding("utf-8");
response.setHeader("Access-Control-Expose-Headers","Content-Disposition");
//设置文件名,设置字符集是避免文件名中有中文时出现乱码
String encodeFileName = null;
try {
//UTF-8编码
encodeFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString());
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
//设置到响应头
response.addHeader("Content-Disposition", "attachment;filename=" + encodeFileName+(zip?".zip":""));
}
}
IO工具类负责将ts文件流写入Http响应流
package pers.os467.support.utils;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
/**
* 输入输出流工具类
* author: os467
*/
public class IOUtils {
public static void copy(InputStream inputStream, OutputStream outputStream) throws IOException {
boolean normal = true;
try {
byte[] buffer = new byte[1024];
int read;
while (( read = inputStream.read(buffer) ) != -1){
outputStream.write(buffer,0,read);
}
} catch (IOException e) {
normal = false;
throw e;
}finally {
try {
inputStream.close();
if (normal){
outputStream.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
FFMPEG生成TS,M3U8
VideoConverter负责调用FFMPEG进程切割mp4文件,生成视频媒体文件夹和对应的m3u8和ts资源
这个类使用了单例模式获取一个全局单例对象,并且每次需要传入mp4视频所在资源位置,首先考虑到的是线程需要是安全的,因此使用了一个tasks集合来记录正在生成资源的表。
当有其它线程进入要生成视频资源时,首先会查看指定mp4分割后的ts资源所在路径文件夹是否存在。若存在则有两种可能性
- 有其它线程正在生成m3u8和ts
- 已经生成完毕
这里使用了一个上方法锁的hasTask方法,保证检查表和放置新任务的步骤是原子性的。
当检查到有其它线程正在处理生成ts任务时,就让该线程循环检查,直到其它线程的ts生成任务完成。
若不存在ts资源则启动ffmpeg生成mp4的ts分片文件和m3u8文件。并在生成完毕后移除tasks中的任务信息。
package pers.os467.support.utils.media;
import pers.os467.support.utils.FileUtils;
import java.io.*;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class VideoConverter {
private static VideoConverter videoConverter;
private volatile static Object mutex = new Object();
//正在处理的媒体切割任务
private Map tasks = new ConcurrentHashMap();
public static VideoConverter getInstance(){
if (videoConverter == null){
synchronized (mutex){
if (videoConverter == null){
videoConverter = new VideoConverter();
}
}
}
return videoConverter;
}
public VideoOutput converter(String videoPath,String mediaBase){
//检查文件夹是否存在
if (!new File(mediaBase).exists()){
FileUtils.makeAccessDir(mediaBase);
}
File videoSource = new File(videoPath);
String sourceName = videoSource.getName();
String mediaPath = mediaBase + File.separator + sourceName;
// 输出的m3u8文件路径
String m3u8Path = mediaPath + File.separator + sourceName + ".m3u8";
// 输出的ts文件路径,%05d表示将替换为五位数的序列号
String tsPath = mediaPath + File.separator + sourceName+"_%05d.ts";
//api路径信息
VideoOutput videoOutput = new VideoOutput(m3u8Path,tsPath,"/media/"+sourceName + ".m3u8");
if (!new File(mediaPath).exists()){
//构建该视频输出文件夹
FileUtils.makeAccessDir(mediaPath);
//构建切割mp4任务
buildTask(videoPath, mediaPath, m3u8Path, tsPath);
}else {
//该目录已经构建过,无需处理
return videoOutput;
}
return videoOutput;
}
private void buildTask(String videoPath, String mediaPath, String m3u8Path, String tsPath) {
//检查是否有正在构建的任务
if (hasTask(mediaPath)){
//阻塞等到任务被其它线程完成
for (int i = 0; i < 600; i++) {
if (!hasTask(mediaPath)) {
return;
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
//中断该线程
Thread.currentThread().interrupt();
}
}
throw new RuntimeException("media converter task timeout");
}else {
//没有任务,则创建任务
try {
List<String> command = buildConverterCommand(videoPath, m3u8Path, tsPath);
ProcessBuilder processBuilder = new ProcessBuilder();
processBuilder.command(command);
processBuilder.redirectErrorStream(true);
Process process = processBuilder.start();
//保证连接到子进程的标准输出的标准输入流不被阻塞
InputStream inputStream = process.getInputStream();
while (inputStream.read() != -1){
//do nothing
}
process.destroy();
} catch (IOException e) {
e.printStackTrace();
throw new RuntimeException("media ts task failed");
}finally {
//删除任务
tasks.remove(mediaPath);
}
}
}
private synchronized boolean hasTask(String mediaPath) {
if (tasks.containsKey(mediaPath)){
return true;
}else {
tasks.put(mediaPath,true);
return false;
}
}
private List<String> buildConverterCommand(String videoPath, String m3u8Path, String tsPath) {
// 构造ffmpeg命令
List<String> command = new ArrayList<>();
command.add("ffmpeg");
command.add("-i");
command.add(videoPath);
command.add("-c:v");
command.add("copy");
command.add("-c:a");
command.add("copy");
command.add("-f");
command.add("ssegment");
command.add("-segment_format");
command.add("mpegts");
command.add("-segment_list");
command.add(m3u8Path);
command.add("-segment_time");
command.add("10");
command.add(tsPath);
return command;
}
}
前端视频播放组件
需要引入hls.js,如果使用npm详细查看网上的hls.js模块安装过程。
这个组件是一个简单的视频播放器组件,可以嵌入到其它组件中使用。
- playVideo(fid) 视频播放方法,需要传递一个fid作为要播放文件的标识,将会拼接到请求中传递给后端
<template>
<video style="width: 768px;height: 576px" ref="videoPlayer" controls></video>
</template>
<script>
module.exports = {
name: "MediaPlayerComponent.vue",
data() {
return {
}
},
methods: {
closeVideo(){
const video = this.$refs.videoPlayer;
//清除video正在播放的视频资源
video.src = '';
},
playVideo(fid) {
axios.post(`${BACKEND_URL}/media/playVideo?fid=${fid}`).then((resp)=>{
if (resp.data.code === STATUS_CODE_SUCCESS){
this.loadHLS(resp.data.data);
}else if (resp.data.code === STATUS_CODE_FAIL){
this.message.error("视频资源加载失败");
}
})
},
loadHLS(m3u8){
const video = this.$refs.videoPlayer;
if(Hls.isSupported()) {
console.log("support hls")
const hls = new Hls();
// 加载m3u8文件
hls.loadSource(m3u8);
// 将hls附加到video元素
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function() {
// 当m3u8文件解析完成后,开始播放视频
video.play();
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// 对于原生支持HLS的浏览器(如Safari),直接设置video的src
video.src = m3u8;
video.addEventListener('loadedmetadata', function() {
video.play();
});
}
}
},
computed:{
},
watch:{
},
mounted(){
}
}
</script>
<style scoped>
</style>
需要引入 hls.js
这里先通过axios请求后端m3u8地址,接收到地址后表明后端ts文件已经生成,接下来交给hls去发起ts请求即可
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以邮件至 1300452403@qq.com