Node.js + vue3 大文件-切片上传全流程(视频文件)

Node.js + vue3 大文件-切片上传全流程(视频文件)

这个业务场景是在参与一个AI智能混剪视频切片的项目中碰到的,当时的第一版需求是视频文件直接上传,当时是考虑到视频切片不会很大,就默认用户直接上传,但后续需求调整,切片时长扩大且画质也许会有所提高,导致文件会很大。解决方案考虑过是否可以通过压缩来解决,但混剪视频需求,用户是极其在意画质的,因此就放弃这种方案,只能选择市面通用的方案,切片上传。

功能简述

  1. 支持手动上传、拖动上传。

  2. 支持切片上传,且上传时带有进度条。

    切片格式限制:Mp4,大小限制: 20M

  3. 支持断点续传(后续再添加...)

服务端(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>

预览

未上传

待上传

上传中

相关推荐
LuckySusu2 小时前
【vue篇】Vue 2 响应式“盲区”破解:如何监听对象/数组属性变化
前端·vue.js
LuckySusu2 小时前
【vue篇】Vue Mixin:可复用功能的“乐高积木”
前端·vue.js
洋不写bug3 小时前
前端环境搭建,保姆式教学
前端
需要兼职养活自己3 小时前
react高阶组件
前端·react.js
TechFrank3 小时前
Shadcn/ui 重磅更新:7 个实用新组件深度解析与实战指南
前端
快乐是一切3 小时前
PDF中的图像与外部对象
前端
前端开发呀3 小时前
无所不能的uniapp拦截器【三】uni-app 拦截器核心流程解析
前端·javascript·微信小程序
云枫晖3 小时前
破壁前行:深度解析前端跨域的本质与实战
前端·浏览器
文心快码BaiduComate3 小时前
代码·创想·未来——百度文心快码创意探索Meetup来啦
前端·后端·程序员