图片预览和视频在线播放
需求描述
实现播放视频的需求时,往往是前端直接加载一个mp4文件,这样做法在遇到视频文件较大时,容易造成卡顿,不能及时加载出来。我们可以将视频进行切片,然后分段加载。播放一点加载一点,这样同一时间内只会加载一小部分的视频,不容易出现播放卡顿的问题。下面是实现方法。
对视频切片使用的是 ffmpeg,可查看我的这个文章安装使用
后端接口处理
后端需要处理的逻辑有
- 根据视频的完整地址找到视频源文件
- 根据视频名称进行MD5,在同级目录下创建MD5文件夹,用于存放生成的索引文件和视频切片
- 前端调用视频预览接口时先判断有没有索引文件
- 如果没有,则先将mp4转为ts,然后对ts进行切片处理并生成index.m3u8索引文件,然后删除ts文件
- 如果有,则直接读取ts文件写入到响应头,以流的方式返回给浏览器
- 加载视频分片文件时会重复调用视频预览接口,需要对请求进来的参数做判断,判断是否是请求的索引还是分片
首先定义好接口,接收一个文件ID获取到对应的文件信息
@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方法主要处理的几个事情
- 首先判断文件类型是图片还是视频
- 如果是图片是直接读取图片并返回流
- 如果是视频
- 首先拿到视频名称,对名称进行md5处理,并生成文件夹
- 创建视频ts文件,并对ts进行切片和生成索引
- 加载分片文件时调用readFileTs方法
/**
* 文件预览
*/
@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
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
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
/**
* 读取文件方法
*
* @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命令
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
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
,安装
pnpm add dplayer hls.js
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;
父组件引入并使用
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} />
判断文件类型的方法
// 判断文件是否为图片
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开头,方便加载分片时找到分片对应的位置