Node.js + vue3 大文件-切片上传全流程(视频文件)
这个业务场景是在参与一个AI智能混剪视频切片的项目中碰到的,当时的第一版需求是视频文件直接上传,当时是考虑到视频切片不会很大,就默认用户直接上传,但后续需求调整,切片时长扩大且画质也许会有所提高,导致文件会很大。解决方案考虑过是否可以通过压缩来解决,但混剪视频需求,用户是极其在意画质的,因此就放弃这种方案,只能选择市面通用的方案,切片上传。
功能简述
-
支持手动上传、拖动上传。
-
支持切片上传,且上传时带有进度条。
切片格式限制:
Mp4
,大小限制: 20M -
支持断点续传(后续再添加...)
服务端(node.js)
Install
shell
pnpm install express multer fluent-ffmpeg body-parser cors fs-extra
环境配置:由于多个切片需要合并成一个视频,因此本地机器需要配置 ffmpeg
shell
# 验证是否安装了 ffmpeg
ffmpeg -v
文件目录结构
markdown
your-project-name
├─ index.js
├─ cache
├─ output
├─ utils
│ ├─ multer.js
├─ public
├─ dist
multer 配置
js
const multer = require('multer')
const fse = require('fs-extra')
/**
* multer 配置
* @param { string } path 上传文件的目录
* @param { function } fileFilter 文件过滤
* @returns { multer } multer 实例
*/
module.exports = (path, fileFilter) => {
/**
* 上传文件的目录
*/
const storage = (path) => {
return multer.diskStorage({
// 上传文件的目录
destination: (req, file, cb) => {
cb(null, path)
},
// 上传文件的名称
filename: (req, file, cb) => {
const fileName = Buffer.from(file.originalname, 'latin1').toString('utf8')
cb(null, fileName)
}
})
}
const config = {
storage: storage(path)
}
/**
* 文件过滤
*/
if (fileFilter) {
config.fileFilter = fileFilter
}
/**
* 上传配置
*/
return multer(config)
}
创建服务
js
const express = require('express')
const fse = require('fs-extra')
const fs = require('fs')
const multer = require('./utils/multer.js')
const { sep, resolve } = require('path')
const app = express()
const router = express.Router()
// multer 配置
const multerOption = multer(resolve(__dirname, `.${sep}cache`))
/**
* 处理静态文件
* 静态资源 token 校验
*/
express.static(resolve(__dirname,`.${sep}public`)))
app.use(express.static(resolve(__dirname, `.${sep}public`)))
app.use(express.static(resolve(__dirname, `.${sep}dist`)))
/**
* 跨域
*/
app.use(cors())
/**
* 请求参数
*/
app.use(bodyParser.urlencoded({ extended: false }))
app.use(bodyParser.json())
/**
* 上传切片
*/
router.post('/upload/chunk', multerOption.single('file'), (req, res) => {
try {
const { file } = req
const { chunkIndex, name: fileName } = req.body
const cachePath = resolve(__dirname, `.${sep}cache`)
const filePath = resolve(cachePath, `.${sep}${fileName}`)
// 创建hash目录
createFolder(filePath)
// 移动chunk到指定文件目录
fs.renameSync(resolve(cachePath, `.${sep}${file.originalname}`), resolve(filePath, `.${sep}${file.originalname}`))
} catch (e) {
console.log('e', e)
throw new Error(e.message)
}
})
/**
* 合并切片
*/
router.post('/upload/chunk', (req, res) => {
try {
const { name: fileName, tagIds } = req.bodyd
const filePath = resolve(__dirname, `.${sep}cache${sep}${fileName}`)
const outputPath = resolve(__dirname, `.${sep}output`)
// 获取 分片 文件
const chunks = fs.readdirSync(hashPath)
// 排序分片
chunks.sort((a, b) => {
const numA = parseInt(a)
const numB = parseInt(b)
return numA - numB
})
// 合并分片
chunks.map(chunkPath => {
fs.appendFileSync(
resolve(filePath, `.${sep}${fileName}.mp4`),
fs.readFileSync(resolve(filePath, `.${sep}${chunkPath}`))
)
})
// 移动视频到指定目录
fs.renameSync(resolve(filePath, `.${sep}${fileName}.mp4`), resolve(outputPath, `.{sep}${fileName}.mp4`))
// 删除分片
chunks.map(chunkPath => {
fs.unlinkSync(resolve(filePath, `.${sep}${chunkPath}`))
})
// 删除hash目录
fs.rmdirSync(filePath)
} catch (e) {
throw new Error(e.message)
}
})
/**
* 创建文件夹
* @param {String} path 文件夹路径
*/
createFolder(path) {
try {
if (fse.existsSync(path)) {
return
}
fse.ensureDirSync(path)
} catch (error) {
throw new Error('[Create Folder]创建文件夹失败', error)
}
}
/**
* 启动服务
*/
try {
const port = process.env.PORT || 8081 // 端口号
const host = process.env.IP || '0.0.0.0' // 主机地址
app.listen(port, host, () => {
console.log(`服务已启动,访问地址:http://${host}:${port}`)
})
} catch (error) {
console.error('启动服务失败:', error)
}
客户端 (vue3 + element-plus)
vue3
<template>
<div class="upload-video round-8 pd-16 border-box scroll-y">
<div class="container" style="overflow: hidden;">
<input ref="uploadRef" type="file" :multiple="uploadOptions.multiple" :accept="uploadOptions.accept" @change="handleSelectFile" />
<!-- 等待上传 -->
<div v-if="uploadStatus === 'waiting'" class="upload-box flex-center text-center pointer hover"
@dragover="handlePreventDefault"
@dragenter="handlePreventDefault"
@drop="handleFileDrop"
@click="handleClickUpload">
<img src="@/assets/upload.png" alt="上传" class="upload-icon" />
<div class="mg-l-8" style="line-height: 22px;">
<p class="color-info font-12 ellipsis">拖拽到此区域上传或点击上传</p>
<p class="color-info font-12 ellipsis">仅支持 .mp4 格式</p>
</div>
</div>
<!-- 上传 -->
<div v-else class="upload-box flex-center-column pd-16 border-box">
<!-- 正在上传 -->
<div v-if="uploadStatus === 'uploading'" class="flex-column jc-c" style="width: 100%; height: 100%;">
<el-progress :percentage="progress" />
<div class="font-12 color-info flex ai-c jc-sb">
<el-button text type="info" size="small" loading style="margin-left: -8px;">
<span v-if="chunkInfo.total" class="mg-l-4">
{{ chunkInfo.uploaded !== chunkInfo.total ? `(${chunkInfo.uploaded}/${chunkInfo.total}) 正在上传...` : '上传成功,正在读取文件...' }}
</span>
</el-button>
<el-button text type="danger" size="small" class="mg-r-16">
取消
</el-button>
</div>
</div>
<!-- 上传完成 -->
<div v-if="uploadStatus === 'success'" class="flex-center-column">
<div class="preview-video mg-b-12 relative pointer" @click="handleClickPreview">
<div v-if="isPreview" class="preview-video-mask" />
<video ref="previewVideoRef" :src="previewUrl" preload="metadata" class="round-4" width="100%" height="100%" style="aspect-ratio: 16/9;" />
</div>
<span class="font-12 color-info flex ai-c" style="max-width: 326px;">
<el-icon class="mg-r-4 font-14 color-success"><CircleCheckFilled /></el-icon>
<span class="ellipsis">已选择文件【{{ fileInfo?.name }}】</span>
</span>
<el-button size="small" class="mg-t-8" type="primary" @click="handleClickUpload">重新上传文件</el-button>
</div>
</div>
</div>
<div class="form-box mg-t-16">
<el-form :model="form" ref="formRef" label-position="top" :rules="formRules">
<el-form-item label="视频名称" style="margin-bottom: 8px;" prop="name">
<el-input v-model="form.name" type="textarea" :rows="5" resize="none" placeholder="请输入视频名称" clearable />
</el-form-item>
<el-form-item label="视频标签" prop="tags">
<el-select v-model="form.tags" placeholder="请选择视频标签" clearable filterable multiple :disabled="!tags.length">
<el-option v-for="item in tags" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleConfirm">确定</el-button>
<el-button type="info" @click="handleClickBack">返回</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getTagList, checkVideoChunkApi, uploadChunkApi, mergeChunkApi } from '@/api'
const router = useRouter()
const fileInfo = ref(null)
/**
* 表单
*/
const form = reactive({
name: '',
tags: []
})
/**
* 表单验证
*/
const formRules = {
name: [{ required: true, message: '请输入视频名称', trigger: 'blur' }],
tags: [{ required: true, message: '请选择视频标签', trigger: 'blur' }]
}
/**
* 视频标签
*/
const tags = ref([])
/**
* 上传视频的配置
* @type {Object} { accept: 'video/mp4', multiple: true }
*/
const uploadOptions = {
accept: ['video/mp4'],
multiple: false
}
/**
* 上传进度
* @type {Number}
*/
const progress = ref(10)
/**
* 上传状态
* waiting | uploading | success | fail
*/
const uploadStatus = ref('waiting')
/**
* 阻止浏览器拖拽打开文件的默认行为
* @param {Object} e
*/
const handlePreventDefault = (e) => {
e.stopPropagation()
e.preventDefault()
}
/**
* 放开鼠标,拖拽结束时回调
* @param {Object} e
*/
const handleFileDrop = async (e) => {
try {
handlePreventDefault(e)
const filesList = []
const target = []
const types = e.dataTransfer.types
if (!types.includes('Files')) {
ElMessage.warning('仅支持MP4文件!')
return
}
// 特殊处理,不然直接看e的files始终为空
target.forEach.call(e.dataTransfer.files, (file) => { filesList.push(file) }, false)
if (!filesList.length) {
return
}
const file = filesList[0]
const fileEvent = {
target: {
files: [file]
}
}
handleSelectFile(fileEvent)
} catch (error) {
console.error(error)
uploadStatus.value = 'waiting'
} finally {
uploadRef.value.value = null
}
}
const previewUrl = ref('')
/**
* 手动选择本地文件
* @param {Object} fileEvent
*/
const handleSelectFile = async (fileEvent) => {
try {
const { target } = fileEvent
if (!target.files.length) {
return
}
const file = target.files[0]
console.log('🔅 ~ handleSelectFile ~ file:', file)
// 校验文件
if (file.type !== 'video/mp4') {
ElMessage.warning('仅支持MP4文件!')
return
}
uploadStatus.value = 'success'
fileInfo.value = file
// 设置视频名称 -- 去除文件后缀
form.name = file.name.replace(/.mp4$/, '')
previewUrl.value = URL.createObjectURL(file)
} catch (error) {
console.error(error)
uploadStatus.value = 'waiting'
} finally {
uploadRef.value.value = null
}
}
const uploadRef = ref(null)
/**
* 点击上传按钮
*/
const handleClickUpload = () => {
uploadRef.value.click()
}
const previewVideoRef = ref(null)
const isPreview = ref(true)
/**
* 点击预览
*/
const handleClickPreview = () => {
// 如果正在预览,则暂停
if (!isPreview.value) {
previewVideoRef.value.pause()
isPreview.value = true
return
}
// 如果未正在预览,则播放
isPreview.value = false
previewVideoRef.value.play()
}
/**
* 点击返回
*/
const handleClickBack = () => {
router.back()
}
/**
* 分片信息
*/
const chunkInfo = reactive({
total: 0,
uploaded: 0
})
const formRef = ref(null)
/**
* 点击确定
*/
const handleConfirm = async () => {
// console.log('handleConfirm', fileInfo.value)
try {
await formRef.value.validate()
// 检测视频-已上传了多少分片
const chunkCheckInfo = await checkVideoChunkApi({ name: form.name })
if (chunkCheckInfo.code === 1) {
return
}
// 已上传分片数量
const isUploadedChunkArr = chunkCheckInfo.data
// 分片大小
const chunkSize = 1024 * 1024 * 20 // 20MB
// 切片总数量
chunkInfo.total = Math.ceil(fileInfo.value.size / chunkSize)
// 切片列表
const chunkList = []
for (let i = 0; i < chunkInfo.total; i++) {
const start = i * chunkSize
const end = Math.min(fileInfo.value.size, start + chunkSize)
const chunk = fileInfo.value.slice(start, end)
chunkList.push(chunk)
}
uploadStatus.value = 'uploading'
// 上传切片
for (let i = 0; i < chunkList.length; i++) {
let chunkIndex = i + 1
if (isUploadedChunkArr.includes(`${chunkIndex}`)) {
chunkInfo.uploaded++
continue
}
let blobFile = new File([chunkList[i]], `${chunkIndex}.mp4`)
const formData = new FormData()
formData.append('file', blobFile)
formData.append('name', form.name)
formData.append('chunkIndex', chunkIndex)
const flag = await uploadChunkApi(formData, (evt) => {
progress.value = 0
progress.value = evt?.progress ? Math.floor(evt.progress * 100) : 0
})
if (flag.code === 1) {
break
}
chunkInfo.uploaded++
}
// 合并切片
await mergeChunkApi({
name: form.name,
tagIds: form.tags
})
uploadStatus.value = 'success'
ElMessage.success('上传成功')
router.push({
path: '/list',
query: {
tagId: form.tags[0]
}
})
} catch (error) {
console.log(error)
}
}
const getTagListData = async () => {
try {
const res = await getTagList()
if (res.code === 0) {
tags.value = res.data
}
} catch (error) {
console.log(error)
}
}
onMounted(() => {
getTagListData()
})
</script>
<script>
export default {
name: 'UploadVideo'
}
</script>
<style lang="scss" scoped>
.upload-video {
width: 100%;
height: 100%;
background-color: var(--el-bg-color);
}
.upload-box {
width: 100%;
height: 220px;
font-size: 16px;
border-radius: 8px;
background-color: var(--el-fill-color-light);
.upload-icon {
width: 160px;
}
&.hover {
&:hover {
border-color: #409EFF;
}
}
}
.preview-video {
width: 220px;
position: relative;
object-fit: cover;
aspect-ratio: 16/9;
border-radius: 4px;
background-color: var(--el-color-primary-light-9);
.preview-video-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: url('@/assets/play.png') no-repeat center center;
background-size: 22% 30%;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 4px;
}
}
input[type="file"] {
display: none;
}
:deep(){
.el-form-item__label {
margin-bottom: 4px;
}
}
</style>
预览
未上传

待上传

上传中
