React + SpringBoot实现图片预览和视频在线播放,其中视频实现切片保存和分段播放

图片预览和视频在线播放

需求描述

实现播放视频的需求时,往往是前端直接加载一个mp4文件,这样做法在遇到视频文件较大时,容易造成卡顿,不能及时加载出来。我们可以将视频进行切片,然后分段加载。播放一点加载一点,这样同一时间内只会加载一小部分的视频,不容易出现播放卡顿的问题。下面是实现方法。

对视频切片使用的是 ffmpeg,可查看我的这个文章安装使用

后端接口处理

后端需要处理的逻辑有

  1. 根据视频的完整地址找到视频源文件
  2. 根据视频名称进行MD5,在同级目录下创建MD5文件夹,用于存放生成的索引文件和视频切片
  3. 前端调用视频预览接口时先判断有没有索引文件
    1. 如果没有,则先将mp4转为ts,然后对ts进行切片处理并生成index.m3u8索引文件,然后删除ts文件
    2. 如果有,则直接读取ts文件写入到响应头,以流的方式返回给浏览器
  4. 加载视频分片文件时会重复调用视频预览接口,需要对请求进来的参数做判断,判断是否是请求的索引还是分片

首先定义好接口,接收一个文件ID获取到对应的文件信息

java 复制代码
@ApiOperation("文件预览")
@GetMapping("preview/{fileId}")
public void preview(@PathVariable String fileId, HttpServletResponse response) {
    if (fileId.endsWith(".ts")) {
        filePanService.readFileTs(fileId, response);
    } else {
        LambdaUpdateWrapper<FilePan> qw = new LambdaUpdateWrapper<>();
        qw.eq(FilePan::getFileId, fileId);
        FilePan one = filePanService.getOne(qw);
        if (ObjectUtil.isEmpty(one)) {
            throw new CenterExceptionHandler("文件不存在");
        }
        filePanService.preview(one, response);
    }
}

视频信息如下图

在磁盘上对应的视频

数据库中存放是视频信息

当点击视频时,前端会拿到当前的文件ID请求上面定义好的接口,此时 fielId 肯定不是以 ts 结尾,所以会根据这个 fileId 查询数据库中保存的这条记录,然后调用 filePanService.preview(one, response) 方法

preview方法

preview方法主要处理的几个事情

  1. 首先判断文件类型是图片还是视频
  2. 如果是图片是直接读取图片并返回流
  3. 如果是视频
    1. 首先拿到视频名称,对名称进行md5处理,并生成文件夹
    2. 创建视频ts文件,并对ts进行切片和生成索引
  4. 加载分片文件时调用readFileTs方法
java 复制代码
/**
 * 文件预览
 */
@Override
public void preview(FilePan filePan, HttpServletResponse response) {
    // 区分图片还是视频
    if (FileTypeUtil.isImage(filePan.getFileName())) {
        previewImg(filePan, response);
    } else if (FileTypeUtil.isVideo(filePan.getFileName())) {
        previewVideo(filePan, response);
    } else {
        throw new CenterExceptionHandler("该文件不支持预览");
    }
}

/**
 * 图片预览
 *
 * @param filePan
 * @param response
 */
private void previewImg(FilePan filePan, HttpServletResponse response) {
    if (StrUtil.isEmpty(filePan.getFileId())) {
        return;
    }
    // 源文件路径
    String realTargetFile = filePan.getFilePath();
    File file = new File(filePan.getFilePath());
    if (!file.exists()) {
        return;
    }
    readFile(response, realTargetFile);
}

/**
 * 视频预览
 *
 * @param filePan
 * @param response
 */
private void previewVideo(FilePan filePan, HttpServletResponse response) {
    // 根据文件名称创建对应的MD5文件夹
    String md5Dir = FileChunkUtil.createMd5Dir(filePan.getFilePath());
    // 去这个目录下查看是否有index.m3u8这个文件
    String m3u8Path = md5Dir + "/" + FileConstants.M3U8_NAME;
    if (!FileUtil.exist(m3u8Path)) {
        // 创建视频ts文件
        createVideoTs(filePan.getFilePath(), filePan.getFileId(), md5Dir, response);
    } else {
        // 读取切片文件
        readFile(response, m3u8Path);
    }
}

// 创建视频切片文件
private void createVideoTs(String videoPath, String fileId, String targetPath, HttpServletResponse response) {
    // 1.生成ts文件
    String video_2_TS = "ffmpeg -y -i %s -vcodec copy -acodec copy -bsf:v h264_mp4toannexb %s";
    String tsPath = targetPath + "/" + FileConstants.TS_NAME;
    String cmd = String.format(video_2_TS, videoPath, tsPath);
    ProcessUtils.executeCommand(cmd, false);

    // 2.创建切片文件
    String ts_chunk = "ffmpeg -i %s -c copy -map 0 -f segment -segment_list %s -segment_time 60 %s/%s_%%4d.ts";
    String m3u8Path = targetPath + "/" + FileConstants.M3U8_NAME;
    cmd = String.format(ts_chunk, tsPath, m3u8Path, targetPath, fileId);
    ProcessUtils.executeCommand(cmd, false);

    // 删除index.ts文件
    FileUtil.del(tsPath);

    // 读取切片文件
    readFile(response, m3u8Path);
}

// 加载视频切片文件
@Override
public void readFileTs(String tsFileId, HttpServletResponse response) {
    String[] tsArray = tsFileId.split("_");
    String videoFileId = tsArray[0];
    LambdaUpdateWrapper<FilePan> qw = new LambdaUpdateWrapper<>();
    qw.eq(FilePan::getFileId, videoFileId);
    FilePan one = this.getOne(qw);
    // 获取文件对应的MD5文件夹地址
    String md5Dir = FileChunkUtil.createMd5Dir(one.getFilePath());
    // 去MD5目录下读取ts分片文件
    String tsFile = md5Dir + "/" + tsFileId;
    readFile(response, tsFile);
}

用到的几个工具类代码

FileTypeUtil

java 复制代码
package com.szx.usercenter.util;

/**
 * @author songzx
 * @create 2024-06-07 13:39
 */
public class FileTypeUtil {
    /**
     * 是否是图片类型的文件
     */
    public static boolean isImage(String fileName) {
        String[] imageSuffix = {"jpg", "jpeg", "png", "gif", "bmp", "webp"};
        String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);
        for (String s : imageSuffix) {
            if (s.equals(suffix)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 是否是视频文件
     */
    public static boolean isVideo(String fileName) {
        String[] videoSuffix = {"mp4", "avi", "rmvb", "mkv", "flv", "wmv"};
        String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);
        for (String s : videoSuffix) {
            if (s.equals(suffix)) {
                return true;
            }
        }
        return false;
    }
}

FileChunkUtil

java 复制代码
package com.szx.usercenter.util;

import cn.hutool.core.io.FileUtil;
import cn.hutool.crypto.digest.MD5;

import java.io.File;

/**
 * 文件上传后的各种处理操作
 * @author songzx
 * @create 2024-06-07 13:25
 */
public class FileChunkUtil {
    /**
     * 合并完文件后根据文件名称创建MD5目录
     * 用于存放文件缩略图
     */
    public static String createMd5Dir(String filePath) {
        File targetFile = new File(filePath);
        String md5Dir = MD5.create().digestHex(targetFile.getName());
        String targetDir = targetFile.getParent() + File.separator + md5Dir;
        FileUtil.mkdir(targetDir);
        return targetDir;
    }
}

readFile

java 复制代码
/**
 * 读取文件方法
 *
 * @param response
 * @param filePath
 */
public static void readFile(HttpServletResponse response, String filePath) {
    OutputStream out = null;
    FileInputStream in = null;
    try {
        File file = new File(filePath);
        if (!file.exists()) {
            return;
        }
        in = new FileInputStream(file);
        byte[] byteData = new byte[1024];
        out = response.getOutputStream();
        int len = 0;
        while ((len = in.read(byteData)) != -1) {
            out.write(byteData, 0, len);
        }
        out.flush();
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        if (out != null) {
            try {
                out.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        if (in != null) {
            try {
                in.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

ProcessUtils

这个方法用于执行CMD命令

java 复制代码
package com.szx.usercenter.util;

import com.szx.usercenter.handle.CenterExceptionHandler;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;

/**
 * 可以执行命令行命令的工具
 *
 * @author songzx
 * @create 2024-06-06 8:56
 */
public class ProcessUtils {
  private static final Logger logger = LoggerFactory.getLogger(ProcessUtils.class);

  public static String executeCommand(String cmd, Boolean outPrintLog) {
    if (StringUtils.isEmpty(cmd)) {
      logger.error("--- 指令执行失败!---");
      return null;
    }

    Runtime runtime = Runtime.getRuntime();
    Process process = null;
    try {
      process = Runtime.getRuntime().exec(cmd);
      // 取出输出流
      PrintStream errorStream = new PrintStream(process.getErrorStream());
      PrintStream inputStream = new PrintStream(process.getInputStream());
      errorStream.start();
      inputStream.start();
      // 获取执行的命令信息
      process.waitFor();
      // 获取执行结果字符串
      String result = errorStream.stringBuffer.append(inputStream.stringBuffer + "\n").toString();
      // 输出执行的命令信息
      if (outPrintLog) {
        logger.info("执行命令:{},已执行完毕,执行结果:{}", cmd, result);
      } else {
        logger.info("执行命令:{},已执行完毕", cmd);
      }
      return result;
    } catch (Exception e) {
      e.printStackTrace();
      throw new CenterExceptionHandler("命令执行失败");
    } finally {
      if (null != process) {
        ProcessKiller processKiller = new ProcessKiller(process);
        runtime.addShutdownHook(processKiller);
      }
    }
  }

  private static class ProcessKiller extends Thread {
    private Process process;

    public ProcessKiller(Process process) {
      this.process = process;
    }

    @Override
    public void run() {
      this.process.destroy();
    }
  }

  static class PrintStream extends Thread {
    InputStream inputStream = null;
    BufferedReader bufferedReader = null;

    StringBuffer stringBuffer = new StringBuffer();

    public PrintStream(InputStream inputStream) {
      this.inputStream = inputStream;
    }

    @Override
    public void run() {
      try {
        if (null == inputStream) {
          return;
        }
        bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
        String line = null;
        while ((line = bufferedReader.readLine()) != null) {
          stringBuffer.append(line);
        }
      } catch (Exception e) {
        logger.error("读取输入流出错了!错误信息:" + e.getMessage());
      } finally {
        try {
          if (null != bufferedReader) {
            bufferedReader.close();
          }
          if (null != inputStream) {
            inputStream.close();
          }
        } catch (IOException e) {
          logger.error("关闭流时出错!");
        }
      }
    }
  }
}

前端方法实现

前端使用的是React

定义图片预览组件 PreviewImage

tsx 复制代码
import React, { forwardRef, useImperativeHandle } from 'react';
import {
  DownloadOutlined,
  UndoOutlined,
  RotateLeftOutlined,
  RotateRightOutlined,
  SwapOutlined,
  ZoomInOutlined,
  ZoomOutOutlined,
} from '@ant-design/icons';
import { Image, Space } from 'antd';

const PreviewImage: React.FC = forwardRef((props, ref) => {
  const [src, setSrc] = React.useState('');

  const showPreview = (fileId: string) => {
    setSrc(`/api/pan/preview/${fileId}`);
    document.getElementById('previewImage').click();
  };

  useImperativeHandle(ref, () => {
    return {
      showPreview,
    };
  });

  const onDownload = () => {
    fetch(src)
      .then((response) => response.blob())
      .then((blob) => {
        const url = URL.createObjectURL(new Blob([blob]));
        const link = document.createElement('a');
        link.href = url;
        link.download = 'image.png';
        document.body.appendChild(link);
        link.click();
        URL.revokeObjectURL(url);
        link.remove();
      });
  };

  return (
    <Image
      id={'previewImage'}
      style={{ display: 'none' }}
      src={src}
      preview={{
        toolbarRender: (
          _,
          {
            transform: { scale },
            actions: {
              onFlipY,
              onFlipX,
              onRotateLeft,
              onRotateRight,
              onZoomOut,
              onZoomIn,
              onReset,
            },
          },
        ) => (
          <Space size={12} className="toolbar-wrapper">
            <DownloadOutlined onClick={onDownload} />
            <SwapOutlined rotate={90} onClick={onFlipY} />
            <SwapOutlined onClick={onFlipX} />
            <RotateLeftOutlined onClick={onRotateLeft} />
            <RotateRightOutlined onClick={onRotateRight} />
            <ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
            <ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
            <UndoOutlined onClick={onReset} />
          </Space>
        ),
      }}
    />
  );
});

export default PreviewImage;

定义视频预览组件

视频预览用到了 dplayer ,安装

sh 复制代码
pnpm add dplayer hls.js
tsx 复制代码
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import DPlayer from 'dplayer';
import './style/video-model.less';

const Hls = require('hls.js');

const PreviewVideo = forwardRef((props, ref) => {
  let dp = useRef();

  const [modal2Open, setModal2Open] = useState(false);
  const [fileId, setFileId] = useState('');

  const showPreview = (fileId) => {
    setFileId(fileId);
    setModal2Open(true);
  };

  const hideModal = () => {
    setModal2Open(false);
  };

  const clickModal = (e) => {
    if (e.target.dataset.tagName === 'parentBox') {
      hideModal();
    }
  };

  useEffect(() => {
    if (modal2Open) {
      console.log(fileId, 'videovideovideo');
      dp.current = new DPlayer({
        container: document.getElementById('video'), // 注意:这里一定要写div的dom
        lang: 'zh-cn',
        video: {
          url: `/api/pan/preview/${fileId}`, // 这里填写.m3u8视频连接
          type: 'customHls',
          customType: {
            customHls: function (video) {
              const hls = new Hls();
              hls.loadSource(video.src);
              hls.attachMedia(video);
            },
          },
        },
      });
      dp.current.play();
    }
  }, [modal2Open]);

  useImperativeHandle(ref, () => {
    return {
      showPreview,
    };
  });

  return (
    <>
      {modal2Open && (
        <div className={'video-box'} data-tag-name={'parentBox'} onClick={clickModal}>
          <div id="video"></div>
          <button className="ant-image-preview-close" onClick={hideModal}>
            <span role="img" aria-label="close" className="anticon anticon-close">
              <svg
                fill-rule="evenodd"
                viewBox="64 64 896 896"
                focusable="false"
                data-icon="close"
                width="1em"
                height="1em"
                fill="currentColor"
                aria-hidden="true"
              >
                <path d="M799.86 166.31c.02 0 .04.02.08.06l57.69 57.7c.04.03.05.05.06.08a.12.12 0 010 .06c0 .03-.02.05-.06.09L569.93 512l287.7 287.7c.04.04.05.06.06.09a.12.12 0 010 .07c0 .02-.02.04-.06.08l-57.7 57.69c-.03.04-.05.05-.07.06a.12.12 0 01-.07 0c-.03 0-.05-.02-.09-.06L512 569.93l-287.7 287.7c-.04.04-.06.05-.09.06a.12.12 0 01-.07 0c-.02 0-.04-.02-.08-.06l-57.69-57.7c-.04-.03-.05-.05-.06-.07a.12.12 0 010-.07c0-.03.02-.05.06-.09L454.07 512l-287.7-287.7c-.04-.04-.05-.06-.06-.09a.12.12 0 010-.07c0-.02.02-.04.06-.08l57.7-57.69c.03-.04.05-.05.07-.06a.12.12 0 01.07 0c.03 0 .05.02.09.06L512 454.07l287.7-287.7c.04-.04.06-.05.09-.06a.12.12 0 01.07 0z"></path>
              </svg>
            </span>
          </button>
        </div>
      )}
    </>
  );
});

export default PreviewVideo;

父组件引入并使用

tsx 复制代码
import PreviewImage from '@/components/Preview/PreviewImage';
import PreviewVideo from '@/components/Preview/PreviewVideo';

const previewRef = useRef();
const previewVideoRef = useRef();

// 点击的是文件
const clickFile = async (item) => {
    // 预览图片
    if (isImage(item.fileType)) {
        previewRef.current.showPreview(item.fileId);
        return;
    }

    // 预览视频
    if (isVideo(item.fileType)) {
        previewVideoRef.current.showPreview(item.fileId);
        return;
    }

    message.error('暂不支持预览该文件');
};

// 点击的文件夹
const clickFolder = (item) => {
    props.pushBread(item);  // 更新面包屑
};

// 点击某一行时触发
const clickRow = (item: { fileType?: string }) => {
    if (item.fileType) {
        clickFile(item);
    } else {
        clickFolder(item);
    }
};

<PreviewImage ref={previewRef} />
<PreviewVideo ref={previewVideoRef} />

判断文件类型的方法

tsx 复制代码
// 判断文件是否为图片
export function isImage(fileType): boolean {
  const imageTypes = ['.jpg', '.png', '.jpeg', '.gif', '.bmp', '.webp']
  return imageTypes.includes(fileType);
}

// 判断是否为视频
export function isVideo(fileType): boolean {
  const videoTypes = ['.mp4', '.avi', '.rmvb', '.mkv', '.flv', '.wmv']
  return videoTypes.includes(fileType);
}

实现效果

图片预览效果

视频预览效果

并且在播放过程中是分段加载的视频

查看源文件,根据文件名创建一个MD5的文件夹

文件夹中对视频进行了分片处理,每一片都是以文件ID开头,方便加载分片时找到分片对应的位置

相关推荐
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭8 小时前
SpringBoot如何实现缓存预热?
java·spring boot·spring·缓存·程序员
darkdragonking10 小时前
FLV视频封装格式详解
音视频
AskHarries10 小时前
Spring Cloud OpenFeign快速入门demo
spring boot·后端
isolusion11 小时前
Springboot的创建方式
java·spring boot·后端
元争栈道12 小时前
webview和H5来实现的android短视频(短剧)音视频播放依赖控件
android·音视频
Yvemil712 小时前
《开启微服务之旅:Spring Boot Web开发举例》(一)
前端·spring boot·微服务
哑巴语天雨13 小时前
React+Vite项目框架
前端·react.js·前端框架
初遇你时动了情13 小时前
react 项目打包二级目 使用BrowserRouter 解决页面刷新404 找不到路由
前端·javascript·react.js
元争栈道13 小时前
webview+H5来实现的android短视频(短剧)音视频播放依赖控件资源
android·音视频
码农老起13 小时前
掌握 React:组件化开发与性能优化的实战指南
react.js·前端框架