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