视频播放与传输设计方案

  1. 视频播放与传输设计方案
    1. M3U8和TS
    2. FFMPEG
    3. 后端设计
    4. 相关工具类
    5. FFMPEG生成TS,M3U8
    6. 前端视频播放组件

视频播放与传输设计方案

这篇文章将通过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

文章标题:视频播放与传输设计方案

字数:2.8k

本文作者:Os467

发布时间:2024-02-07, 17:10:30

最后更新:2024-02-07, 17:14:53

原始链接:https://os467.github.io/2024/02/07/%E8%A7%86%E9%A2%91%E6%92%AD%E6%94%BE%E4%B8%8E%E4%BC%A0%E8%BE%93%E8%AE%BE%E8%AE%A1%E6%96%B9%E6%A1%88/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

×

喜欢就点赞,疼爱就打赏