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