likes
comments
collection
share

视频播放大文件上传

作者站长头像
站长
· 阅读数 11

整体流程图

视频播放大文件上传

步骤逻辑

  • 前端调取检查该视频是否有上传过的接口,后端就根据前端传递的hash(唯一)和type(mp4,avi)值找到对应暂时缓存文件的路径,如果没有就创建一个文件,并且读取本地暂存文件的字节数大小,返回给前端,然后前端就会根据返回的字节大小和前端文件的大小做比较。
  • 如果没有上传完毕,比前端本地的视频大小要小,那么就调取上传的接口,并且接受返回新的字节大小,然后就继续判断如果还是比本地的小,那么就继续上传
  • 在上传完成之后,因为视频很大,存在后端文件夹存储就会影响后端的打包体积,会影响服务器的性能,于是就上传到华为云上面。将一个媒资id拿到后,更新或者创建对应章集到数据库,然后返回给前端返回成功!

详细版本

  • 前端调取检查该视频是否有上传过的接口,后端就根据前端传递的hash(唯一)和type(mp4,avi)值找到对应暂时缓存文件的路径,如果没有就创建一个文件,并且读取本地暂存文件的字节数大小,返回给前端,然后前端就会根据返回的字节大小和前端文件的大小做比较。
  • 如果没有上传完毕,比前端本地的视频大小要小,那么就调取上传的接口,并且接受返回新的字节大小,然后就继续判断如果还是比本地的小,那么就继续上传
  • 后端每次通过req.file拿到新的片段,就追加写入到临时文件上,等临时文件的大小和前端传递到完整文件大小一样的时候(大),就会将临时文件放到合并的文件里面。在使用定时器将临时文件给删掉。
  • 在上传完成之后,因为视频很大,存在后端文件夹存储就会影响后端的打包体积,会影响服务器的性能,于是就上传到华为云上面。将一个媒资id拿到后,更新或者创建对应章集到数据库,然后返回给前端返回成功!
  1. 组件渲染: 组件首次渲染时,将显示一个带有集数和标题的页面元素以及一个上传按钮。该按钮实际上是一个隐藏的文件输入框,当点击按钮时,将触发文件输入框的点击事件。
  2. 文件选择: 当用户点击上传按钮时,handleVideoUpload 函数被调用。这个函数实际上触发了隐藏的文件输入框的点击事件,让用户选择要上传的视频文件。
  3. 文件选择事件处理: 当用户选择一个视频文件后,handleFileChange 函数被调用。在这个函数中,选定的文件信息被提取并存储在 fileReactive.current 中,包括文件对象、哈希值、类型、已上传字节数等信息。
  4. 计算文件哈希: getFileHash 函数计算所选文件的哈希值(使用SHA-256算法)。哈希值将用于检查文件是否已经上传过,以及在后续的分块上传中标识文件的唯一性。
  5. 上传前的校验: 在进行实际的上传之前,通过调用 uploadCheck 函数来检查文件是否已经上传过。如果文件已上传,则可以继续上传未上传的部分。如果文件未上传或上传未完成,代码将继续执行。
  6. 分块上传: 文件将被分成较小的块,并以每次上传10兆字节的大小进行上传。循环中的每一次迭代都将上传一个块(或分片)的数据,直到整个文件上传完毕。在每次上传之前,已上传字节数和文件块的偏移量会被更新,以确保正确切割和上传。
  7. 上传到华为云: 当整个文件上传完成后,代码将等待1秒钟,然后调用 uploadHWYun 函数将文件上传到华为云。这一步骤涉及到对文件的一些操作,然后最终上传文件。
  8. 显示消息: 上传完成后,根据上传结果,将显示成功或失败的消息。
  9. 重置文件信息: 最后,文件信息会被重置,以便下一次上传。

断点续传原理

请求视频字节数

  • 接口路由
router.post('/upload/check', AdminController.uploadCheck)
  • 逻辑开发
// AdminController.js
async uploadCheck(req, res) {
  let { hash, type } = req.body
  let handleRes = await AdminService.uploadCheck({ hash, type })
  res.send(handleRes)
}

// AdminService.js

// 前端检查上传的视频是否已经上传过,如果上传过则返回已经上传的字节数
uploadCheck: async ({ hash, type }) => {
  let uploadedBytes = 0 // 用于存储已上传字节数的变量
  // 上传的临时文件路径
  const tempFilePath = path.join(__dirname, '../temp_videos', `${hash}.${type.split('/').pop()}`)


  // 如果文件存在,则修改为文件大小
  if (fs.existsSync(tempFilePath)) uploadedBytes = fs.statSync(tempFilePath).size
  else fs.createWriteStream(tempFilePath) // 不存在则创建个文件,以便后续使用


  // 返回已上传的字节数
  return BackCode.buildSuccessAndData({ data: { uploadedBytes } })
}

切片上传视频

  • 接口路由
router.post('/upload/chunk', uploadVideo.single('chunk'), AdminController.uploadChunk)
  • 逻辑开发
// AdminController.js
async uploadChunk(req, res) {
  let { size, hash, title, type } = req.body
  let handleRes = await AdminService.uploadChunk({ size, hash, title, type, chunk: req.file })
  res.send(handleRes)
}
// AdminService.js


// 断点续传的具体逻辑
uploadChunk: async ({ size, hash, title, type, chunk: _chunk }) => {
  // 上传的临时文件路径,我们默认所有的请求都是检查过的,所以这个文件必定存在
  const tempFilePath = path.join(__dirname, '../temp_videos', `${hash}.${type}`)
  // 读取当前上传的chunk(切片)
  const chunk = fs.readFileSync(_chunk.path)


  // 将切片写入到临时文件里面
  // flag a === 追加内容
  fs.writeFileSync(tempFilePath, chunk, { flag: 'a' })
  const stats = fs.statSync(tempFilePath)
  const uploadedBytes = stats.size // 获取最新的已上传的字节数


  // 如果上传的字节数大于等于size,则说明上传完成,将临时文件移动到合并文件夹
  if (uploadedBytes >= size) {
    const mergedFilePath = path.join(__dirname, '../temp_videos/merged', `${title}`)
    fs.renameSync(tempFilePath, mergedFilePath)
    // 因为可能存在文件占用的问题,rename不一定能够删除原有的临时文件,所以我们要设置一个延时删除
    setTimeout(() => {
      fs.existsSync(tempFilePath) && fs.unlinkSync(tempFilePath)
    }, 1000)
  }
  // 删除已合并的chunk
  fs.unlinkSync(_chunk.path)
  return BackCode.buildSuccessAndData({ data: { uploadedBytes } })
}
  • 创建 /temp_videos/merged 目录

  • 储存上传完成后的视频  

  • 项目启动自动创建临时文件

    // 判断temp_videos和temp_videos/merged是否存在 不存在则创建相对应的文件夹
    if (!fs.existsSync('./temp_imgs')) fs.mkdirSync('./temp_imgs')
    if (!fs.existsSync('./temp_videos')) fs.mkdirSync('./temp_videos')
    if (!fs.existsSync('./temp_videos/merged')) fs.mkdirSync('./temp_videos/merged')
    

上传到华为云

// 上传视频
static async uploadVideo({ fileBuffer, name, type }) {
  // 获取华为云视频点播客户端
  let vodClient = HuaweiCloud.getVodClient()
  // 创建文件上传请求
  let uploadReq = new CreateAssetByFileUploadReq()
  let uploadRequest = new CreateAssetByFileUploadRequest()
  // 设置视频类型、名称和标题
  uploadReq.withVideoType(type.split('/').pop().toUpperCase()).withVideoName(name).withTitle(name)
  // 设置请求体
  uploadRequest.withBody(uploadReq)
  // 创建文件上传任务,获取临时令牌
  let tempObs = await vodClient.createAssetByFileUpload(uploadRequest)
  // 获取资源ID
  let assetId = tempObs.asset_id
   // 获取文件上传地址
  let videoUploadUrl = tempObs.video_upload_url
   // 上传文件
  let { status } = await request.put(videoUploadUrl, fileBuffer, {
    headers: { 'Content-Type': type }
  })
   // 如果上传文件失败,返回错误信息
  if (status !== 200) {
    console.error('上传视频失败')
    return
  }
  // 创建文件确认请求
  let confirmReq = new ConfirmAssetUploadReq()
  let confirmRequest = new ConfirmAssetUploadRequest()
  // 设置文件状态和资源ID
  confirmReq.withStatus(ConfirmAssetUploadReqStatusEnum.CREATED).withAssetId(assetId)
  // 设置请求体
  confirmRequest.withBody(confirmReq)
  // 确认文件上传
  await vodClient.confirmAssetUpload(confirmRequest)
  // 返回资源ID
  return assetId
}
  • 接口路由
router.post('/upload/hwcloud', AdminController.uploadHWCloud)
  • 逻辑开发
// AdminController.js
async uploadHWCloud(req, res) {
  let { type, title, episodeId } = req.body
  let handleRes = await AdminService.uploadHWCloud({ type, title, episodeId })
  res.send(handleRes)
},
// AdminService.js
const HuaweiCloud = require('../config/huaweiCloud')

// 将文件上传到华为云
uploadHWCloud: async ({ title, type, episodeId }) => {
  const mergedFilePath = path.join(__dirname, '../temp_videos/merged', `${title}.${type.split('/').pop()}`)
  // 如果待上传华为云的文件不存在则返回错误
  if (!fs.existsSync(mergedFilePath)) {
    return BackCode.buildError({ msg: '请先上传文件' })
  }
  // 读取文件内容
  const toUploadFileBuffer = fs.readFileSync(mergedFilePath)
  // 上传到华为云,获取上传后的媒资id
  const assetsId = await HuaweiCloud.uploadVideo({ fileBuffer: toUploadFileBuffer, name: title, type })
  // 如果没有找到要修改的episodeId,则返回错误
  const episode = DB.Episode.findOne({ where: { id: episodeId } })
  if (!episode) {
    return BackCode.buildError({ msg: '找不到episodeId,请重试' })
  }
  // 将华为云的媒资id存入到数据库中
  await DB.Episode.update({ hwyun_id: assetsId }, { where: { id: episodeId } })
  // 删除本地的文件
  fs.unlinkSync(mergedFilePath)
  return BackCode.buildSuccessAndMsg({ msg: '视频上传成功' })
}

前端部分

主要是 input这块

<input
  type="file"
  hidden
  ref={fileInputRef}
  onChange={handleFileChange}
/>
<Button disabled={fileReactive.current.isLoading} onClick={handleVideoUpload}>
  {hwyunId ? "修改视频" : "上传视频"}
</Button>
  • 完整代码
import React, { useState, useRef } from "react";
import { uploadCheck, uploadChunk, uploadHWYun } from "../../../api/upload";
import { Button, message } from "antd";

interface IProps {
  index: number;
  title: string;
  chapterIndex: number;
  notAllowOperation?: boolean;
  hwyunId?: string;
}

const Episode = ({
  index,
  title,
  chapterIndex,
  notAllowOperation,
  hwyunId,
}: IProps) => {
  const [titleContent, setTitleContent] = useState(title);
  const fileInputRef: any = useRef();
  // 初始化文件信息
  const fileReactive = useRef({
    file: undefined as File | undefined,
    hash: "",
    type: "",
    uploadedBytes: 0,
    offset: 0,
    size: 0,
    isLoading: false,
    title: "",
  });

  const handleVideoUpload = () => {
    fileInputRef?.current?.click();
  };

  // 视频上传点击
  async function handleFileChange(e: any) {
    const file = (e.target as HTMLInputElement).files![0];
    if (file) {
      fileReactive.current.file = file;
      fileReactive.current.hash = await getFileHash(file);
      fileReactive.current.type = file.type;
      fileReactive.current.offset = 0;
      fileReactive.current.uploadedBytes = 0;
      fileReactive.current.size = file.size;
      fileReactive.current.title = file.name;

      await handleUpload().finally(() => resetFileReactive());
    }
  }

  // 拿到视频文件的hash
  function getFileHash(file: File): Promise<string> {
    return new Promise((resolve) => {
      // 创建FileReader对象,用于读取文件内容
      const reader = new FileReader();
      // 将文件内容作为ArrayBuffer类型的对象传递给onload事件处理函数
      reader.onload = async function () {
        const arrayBuffer = reader.result as ArrayBuffer;
        // 计算哈希值
        crypto.subtle.digest("SHA-256", arrayBuffer).then((hash) => {
          const hashArray = Array.from(new Uint8Array(hash));
          // 将其转换为字符串格式
          const hashHex = hashArray
            .map((b) => b.toString(16).padStart(2, "0"))
            .join("");
          resolve(hashHex);
        });
      };
      // 读取文件内容
      reader.readAsArrayBuffer(file);
    });
  }

  // 上传具体视频文件
  async function handleUpload() {
    fileReactive.current.isLoading = true;
    if (!fileReactive.current.file) {
      alert("请选择文件");
      return;
    }
    // 校验视频是否上传、没上传或者没完成则继续
    const { data, code } = await uploadCheck({
      hash: fileReactive.current.hash,
      type: fileReactive.current.type,
    });
    if (code !== 0) {
      fileReactive.current.isLoading = false;
      message.error("上传失败,请重试");
      resetFileReactive();
      return;
    }
    // 上传的视频字节数
    fileReactive.current.uploadedBytes = data.uploadedBytes;

    // 定义键值对的表单、值必须为字符串
    const formData = new FormData();
    formData.append("hash", fileReactive.current.hash);
    formData.append("title", fileReactive.current.title);
    formData.append("size", String(fileReactive.current.size));
    formData.append("type", fileReactive.current.type.split("/").pop()!);

    // 当已经上传的文件字节数小于该文件的总字节数,则继续上传
    while (fileReactive.current.uploadedBytes < fileReactive.current.size) {
      // 切分的起点
      const startByte = fileReactive.current.uploadedBytes;
      // 切分结束点,每次上传视频的固定为10兆
      const endByte = Math.min(
        startByte + 1024 * 1024 * 10,
        fileReactive.current.size
      );
      // 切分文件上传
      const chunk = fileReactive.current.file.slice(startByte, endByte);
      // 开始的位置
      formData.append("offset", String(startByte));
      // 把上一次的删除,插入新chunk
      formData.delete("chunk");
      formData.append("chunk", chunk);
      // 上传视频文件
      const { data: uploadData, code: uploadCode } = await uploadChunk(
        formData
      );
      if (uploadCode !== 0) {
        fileReactive.current.isLoading = false;
        message.error("上传失败,请重试");
        resetFileReactive();
        return;
      }
      // 更新上传的字节数
      fileReactive.current.uploadedBytes = uploadData.uploadedBytes;
    }
    // 当该视频文件全部上传完成,上传华为云
    await new Promise((resolve) => {
      // 文件IO操作需要时间,延时1秒操作
      setTimeout(async () => {
        const { code, msg } = await uploadHWYun({
          title: fileReactive.current.title,
          episodeId: index,
          type: fileReactive.current.type,
        });
        if (code === 0) {
          message.success(msg || "上传成功");
        }
        resolve("");
      }, 1000);
    });
  }

  // 重置文件信息
  function resetFileReactive() {
    fileReactive.current = {
      file: undefined,
      hash: "",
      type: "",
      uploadedBytes: 0,
      offset: 0,
      size: 0,
      isLoading: false,
      title: "",
    };
  }

  return (
    <div className=" px-2 py-1 flex items-center justify-between text-base w-full">
      <div className="flex justify-center gap-4">
        <div className="flex v-center gap-0.8">
          <span className="flex-shrink-0">第 {index + 1} 集</span>
          <span>{titleContent}</span>
        </div>
      </div>
      <div>
        <input
          type="file"
          hidden
          ref={fileInputRef}
          onChange={(e) => handleFileChange(e)}
        />
        <Button
          disabled={fileReactive.current.isLoading}
          onClick={handleVideoUpload}
        >
          {hwyunId ? "修改视频" : "上传视频"}
        </Button>
      </div>
    </div>
  );
};

export default Episode;