前段日子在学习react和node时,做视频上传和播放时遇到大文件上传和播放问题,在此记录一下
前端开发在日常上传较大的视频文件(如mp4,超过1g)时,使用video标签播放时通常所有视频文件流全部加载完毕时才能播放,这样用户体验是非常不好的。我们可以将其转为m3u8格式的视频文件,通过按需加载对应视频切片进行播放,无需全部加载后播放,同时可以实现视频防盗。
所需环境
- node
- ffmpeg(音视频处理工具,官网安装并配置环境变量)
- react
- fluent-ffmpeg(通过node操作ffmpeg相关命令)
- hls.js(播放m3u8格式视频)
以react + node(koa)为例
- 前端实现
获取文件的hash值(需要npm包spark-md5)用于设置服务端文件片命名
async function getFileHash(file:File){
return new Promise((resolve,reject)=>{
const reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload=function(e){
if(e.target){
let buffer = e.target.result as ArrayBuffer;
let spark = new SparkMD5.ArrayBuffer();
let HASH;
let suffix;
if(buffer){
spark.append(buffer);
HASH = spark.end();
suffix = file.name.substring(file.name.lastIndexOf(".")+1);
resolve({
buffer,
HASH,
suffix
})
}
}
}
})
}
/*
HASH:文件hash值
index:文件序号
file:上传切片
name:名称
total:文件总大小
fn:文件上传进度
handle:服务端处理文件进度,
dest:文件存储目录(服务端)
dt:视频时长
uploadedSize:已经上传文件大小
*/
async function chunkHandle(HASH:string,index:number,file:File,name:string,
total:number,type:string,uploadedSize:number,dest:string,dt:number,
fn:(size:number)=>void,handle:(progress:number)=>void){
try{
fn(uploadedSize);//获取文件上传大小
if(uploadedSize>total) {//所有切片上传完毕,文件开始合并
let videoId="";
let params =`?dest=${dest}&hash=${HASH}&originalname=${name}&type=${type}&total=${total}&dt=${dt}`;
/*
打开websocket实时获取服务端处理文件进度
*/
let websocket = new WebSocket(`${WEBSOCKET_HOST_NAME}/video/merge${params}`);
await socketOpen(websocket);//打开websocket
videoId = await getSocketMsg(websocket,handle);//实时获取进度
return await socketClose(websocket,videoId);//服务端处理完毕
}
let end = index*chunkSize + chunkSize;//chunkSize 每个切片大小
if(end > total-1){
end = total-1
}
let blobFile = file.slice(index*chunkSize,end);//文件切片
let formData = new FormData();
let fileBlob = new File([blobFile],name,{
type:type,
});
formData.append("index",`${index}`);
formData.append("hash",HASH);
formData.append("name",name);
formData.append("size",`${total}`);
formData.append("type",type);
formData.append("chunkSize",`${chunkSize}`);
formData.append("uploadedSize",`${uploadedSize}`)
formData.append("video",fileBlob);
const result = await uploadVideo(formData); // 文件上传,formData设置服务端所需参数
if(result.status === 200){
const res:any = await chunkHandle(HASH,index+1,file,name,total,type,result.data.uploadedSize,result.data.dest,dt,fn,handle);//递归调用
return res;
}
}catch (e) {
console.log(e)
}
}
上传工具函数封装
async function shardUtils(file:File,fn:(size:number)=>void,handle:(progress:number)=>void){
const { name,size,type } = file;//获取文件名称,文件大小,文件类型
let index = 0
const fileData:any = await getFileHash(file);//获取文件上传hash值
const dt:number = await getVideoDuration(file); //自行封装(获取文件时长)
return await chunkHandle(fileData.HASH,index,file,name,size,type,0,"",dt,fn,handle);//开始递归分片上传
}
2. 服务端处理
使用koa-multer中间件处理
function fileStorageChunk(filePath){
try{
return Multer.diskStorage({
destination: function (req, file, cb) {
const { index,hash,name,size,type,chunkSize } = req.body;//获取上传参数
fs.access(`${filePath}/${hash}`,(err)=>{//上传目的路径,不存在则创建
if(err){
fs.mkdirSync(`${filePath}/${hash}`)//创建目录
cb(null, `${filePath}/${hash}`);//将文件存储至指定目录(这里hash为整个文件的hash值,创建一个文件名为文件hash值的文件夹)
}else{
cb(null, `${filePath}/${hash}`);
}
})
},
filename: function (req, file, cb) {
const { index,hash,name,size,type,chunkSize } = req.body;
req.body.dest = `${filePath}/${hash}`;
cb(null, hash+`-${index}`+ path.extname(file.originalname));//文件名为文件hash值+文件切片索引
}
})
}catch (e) {
console.log(e)
}
}
//中间件函数封装
function chunkHandle(path,uploadName,method){
try{
const methods=['single','array']//single为单文件上传,array为多文件上传
if(!methods.includes(method)){
return;
}
const upload = Multer({
storage:fileStorageChunk(path), //调用fileStorageChunk
})
return upload[method](uploadName)
}catch (e) {
console.log(e)
}
}
//视频上传
middleware
/*
./upload_temp/video 文件上传路径
video 前端formData添加文件名称
single 单个文件
*/
const videoUpload= chunkHandle("./upload_temp/video",'video','single');
直接导出videoUpload即可。
controller(在controller层返回已上传文件片信息)
async uploadVideo(ctx,next){
try{
const {
chunkSize, hash, index, name, size, type, uploadedSize,
} = ctx.req.body;
const data = {
chunkSize, hash, index, name, size, type,
uploadedSize:chunkSize*1+uploadedSize*1,
dest:ctx.req.body.dest
}
setResponse(ctx,"上传成功",200,data);
}catch (e) {
console.log(e);
setResponse(ctx,e.message,500,{})
}
}
文件合并(文件分片上传后需要将其合并 并且转为m3u8)
前端合并时调用websocket,后端使用koa-websocket
//文件合并中间件
async mergeVideo(ctx,next){
try{
const {dest="",hash="",originalname,type,total,dt=0} = ctx.query;
if(!isEmpty(ctx,dest,"目的路径不能为空") && !isEmpty(ctx,hash,"文件HASH值不能为空")){
const id = new Date().getTime();
const result = await mergeVideo2(ctx,dest,path.resolve(__dirname,"../../","./upload/video"),hash);
if(result){
const suffix = originalname.substring(originalname.lastIndexOf("."));
const sourcePath = path.resolve(__dirname,"../../",`./upload/video/${hash}${suffix}`);
const destPath = path.resolve(__dirname,"../../",`./upload/video/`)
const result = await videoToM3u8(sourcePath, destPath, hash,(progress)=>{
let res={
isProgress:true,
percent:progress.percent
}
ctx.websocket.send(JSON.stringify(res));
});//将文件转为m3u8并实时返回服务端处理文件路径
if(result){
await deleteFile(sourcePath);
const vioPath = videoPath.replace("./upload","");
const vioUrl = `${APP_HOST}:${APP_PORT}${vioPath}${hash}.m3u8`;
const result = await uploadVideoService(ctx,id,videoPath,`${hash}.m3u8`,originalname,vioUrl);
let res={
id:id,
isProgress:false,
percent:100
}
ctx.websocket.send(JSON.stringify(res));
ctx.websocket.close();
}
}
/*setResponse(ctx,"文件上传成功",200,{
id:id
})*/
}
}catch (e) {
console.log(e)
setResponse(ctx,e.message,500);
}
}
function mergeVideo2(ctx,dest,source,hash){//文件合并工具函数
return new Promise((resolve,reject)=>{
try{
fs.access(dest,(err)=>{
if(!err){
const fileList = fs.readdirSync(dest);//获取目的路径
if(fileList.length!==0){
fileList.sort((a,b)=>{
let reg = /-(\d+)/;
return reg.exec(a)[1] - reg.exec(b)[1]
})//对每一个切片的索引进行排序
fileList.forEach(item=>{
let suffix = /\.([0-9a-zA-Z]+)$/.exec(item)[1];
fs.appendFileSync(`${source}/${hash}.${suffix}`,
fs.readFileSync(`${dest}/${item}`));//将源路径的切片合并至新目录(采用文件追加的方式)
fs.unlinkSync(`${dest}/${item}`);
})
fs.rmdirSync(dest);//移除以文件切片命名的文件夹
resolve(true);
}
}else{
setResponse(ctx,err.message,500)
reject(false);
}
})
}catch (e) {
console.log(e)
setResponse(ctx,e.message,500)
}
})
}
文件转为m3u8工具函数(使用ffmpeg(安装并且配置环境变量))
function videoToM3u8(sourcePath,destPath,videoName,progressHandle){
console.log(sourcePath)
return new Promise((resolve,reject)=>{
fs.access(sourcePath,(err)=>{
if(err){
reject(new Error("视频源路径不存在"))
}else{
fs.access(destPath,(err)=>{
if(err){
reject(new Error("目的路径不存在"))
}else{
ffmpeg(sourcePath).videoCodec('libx264').format('hls')
.outputOption('-hls_list_size 0')
.outputOption('-hls_time 30')//每隔30s视频切一片(自行设置)
.output(path.resolve(destPath,`${videoName}.m3u8`))
.on('progress',(progress)=>{
progressHandle(progress)//实时进度
})
.on("end",()=>{
resolve(true);
})
.run();
}
})
}
})
})
}
3. 流程
5.效果
如果有什么不对,大家及时指正我也是初学者,热爱前端。
项目地址www.bilibili.com/video/BV1er…
项目演示地址www.bilibili.com/video/BV1er…