流程图
前端代码
<template>
<view class="container">
<button @click="startRecord">录制视频</button>
<button @click="chooseVideo">选择视频</button>
<video v-if="videoSrc" :src="videoSrc" controls></video>
</view>
</template>
<script>
export default {
data() {
return {
videoSrc: '', // 存储录制或选择的视频源
recordedVideoPath: '', // 存储录制视频的临时路径
status: 'Ready',
previewSrc: '',
intervalId:null,
};
},
//
beforeCreate() {
},
methods: {
// 开始录制视频
startRecord() {
uni.chooseVideo({
sourceType: ['camera'],
maxDuration: 60000, // 视频最长录制时间,单位为毫秒
camera: 'back', // 使用后置摄像头
success: (res) => {
this.recordedVideoPath = res.tempFilePath;
//this.compressVideo(res.tempFilePath);
console.log(res);
// this.download(res.tempFilePath);
this.calculateFileHash(res.tempFile);
},
fail: (err) => {
uni.showToast({
title: '录制失败',
icon: 'none'
});
}
});
},
download(tempFilePath) {
const a = document.createElement("a");
a.style.display = "none";
a.href = tempFilePath;
a.download = "recorded-video.webm";
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(tempFilePath);
},
sliceFile(file,hashHex) {
const chunkSize = 10 * 1024 * 1024; // 10MB,以字节为单位
const chunks = [];
let start = 0;
while (start < file.size) {
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end,file.type);
chunks.push(chunk);
start = end;
}
console.log(`文件被切片成 ${chunks.length} 个部分。`);
//发起post请求
uni.request({
url: 'http://localhost:8080/shardUpload/init', // API地址
method: 'POST',
data: {
fileName:file.name,
partNum:chunks.length,
hash:hashHex
},
success: (res) => {
console.log('请求成功:', res.data);
let shardUploadId = res.data.data;
// 现在可以对每个切片进行操作,例如上传
chunks.forEach((chunk, index) => {
var fileData = new File([chunk], index, {
type: file.type,
lastModified: new Date().getTime()
});
var fileUrl = URL.createObjectURL(fileData);
// 上传分片的代码可以在这里实现
uni.uploadFile({
url: 'http://localhost:8080/shardUpload/uploadPart', // API地址
filePath:fileUrl,
formData: {
partOrder:index+1,
shardUploadId:shardUploadId
},
success: (res) => {
console.log('请求成功:', res.data);
},
fail: (err) => {
console.error('请求失败:', err);
}
});
});
//定时监控上传进度
setTimeout(() =>{
this.intervalId = setInterval(() => {
//查看上传进度
uni.request({
url: 'http://localhost:8080/shardUpload/detail', // API地址
method: 'POST',
data: shardUploadId,
success: (res) => {
console.log('查看进度成功:', res.data);
if(res.data.data.partNum === res.data.data.partOrderList.length){
clearInterval(this.intervalId);
//合成文件
uni.request({
url: 'http://localhost:8080/shardUpload/complete', // API地址
method: 'POST',
data: shardUploadId,
success: (res) => {
console.log('合成成功:', res.data);
},
fail: (err) => {
console.error('请求失败:', err);
}
});
}
},
fail: (err) => {
console.error('请求失败:', err);
}
});
}, 2000);
},3000)
},
fail: (err) => {
console.error('请求失败:', err);
}
});
},
// 定义一个函数,用于计算文件的SHA-256哈希值
calculateFileHash(file) {
// 创建一个FileReader对象,用于读取文件内容
const reader = new FileReader();
var that = this;
// 为FileReader对象设置onload事件处理函数
// 当文件读取完成时,此函数将被调用
reader.onload = (e) => {
// 获取文件内容,存储在e.target.result中
const buffer = e.target.result;
// 使用Web Crypto API的crypto.subtle.digest方法计算SHA-256哈希值
// 'SHA-256'是指定的哈希算法,buffer是要计算哈希的文件数据
const digestOp = crypto.subtle.digest('SHA-256', buffer);
// 当哈希计算完成时,处理Promise对象
digestOp.then((hashBuffer) => {
// 将哈希值的ArrayBuffer转换为Uint8Array
const hashArray = Array.from(new Uint8Array(hashBuffer));
// 将每个字节转换为十六进制字符串,并确保每两个字符为一组,不足两位前面补零
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
// 打印计算出的哈希值
console.log("SHA-256"+hashHex);
that.sliceFile(file,hashHex)
}).catch((error) => {
// 如果在计算哈希值过程中出现错误,打印错误信息
console.error('Error calculating hash:', error);
});
};
// 以ArrayBuffer格式读取文件内容
// 这将触发onload事件,当读取操作完成时
reader.readAsArrayBuffer(file);
},
// 选择视频
chooseVideo() {
uni.chooseVideo({
sourceType: ['album'],
maxDuration: 60000, // 视频最长录制时间,单位为毫秒
success: (res) => {
this.compressVideo(res.tempFilePath);
},
fail: (err) => {
uni.showToast({
title: '选择失败',
icon: 'none'
});
}
});
},
// 压缩视频
compressVideo(tempFilePath) {
// 初始化 ffmpeg.js
let ffmpeg = new FFmpeg({ logger: console });
this.setData({ status: 'Compressing...' });
const that = this;
// 创建一个 Blob 对象
fetch(tempFilePath)
.then(res => res.arrayBuffer())
.then(buffer => {
// 将视频文件加载到 FFmpeg 的文件系统中
ffmpeg.FS('writeFile', 'input.mp4', buffer);
// 压缩视频
ffmpeg.run('-i', 'input.mp4', '-vcodec', 'libx264', '-crf', '28', '-preset', 'ultrafast', 'output.mp4')
.on('progress', progress => {
console.log(`Progress: ${progress.percent}%`);
that.setData({ status: `Progress: ${progress.percent}%` });
})
.on('end', () => {
console.log('Compression finished.');
// 读取压缩后的视频
const outputBuffer = ffmpeg.FS('readFile', 'output.mp4');
const outputBlob = new Blob([outputBuffer], { type: 'video/mp4' });
// 将压缩后的视频显示为预览
const outputUrl = URL.createObjectURL(outputBlob);
that.setData({ previewSrc: outputUrl, status: 'Compression complete.' });
})
.on('error', err => {
console.error('Error during compression:', err);
that.setData({ status: 'Error during compression.' });
});
// 运行 FFmpeg
ffmpeg.run();
})
.catch(err => {
console.error('Failed to load video file:', err);
this.setData({ status: 'Failed to load video file.' });
});
}
}
};
</script>
<style>
.container {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
video {
width: 100%;
margin-top: 20px;
}
</style>
java代码
一共四个接口,对应流程图的四个过程,创建分片任务接口、上传分片文件接口、分片任务详情接口、合成文件接口,代码如下
- controller
package com.gwb.template.controller;
import cn.hutool.core.util.IdUtil;
import com.gwb.template.entity.ShardUpload;
import com.gwb.template.service.ShardUploadService;
import com.gwb.template.util.AjaxJson;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;
/**
* 分片上传文件
*/
@RestController
@RequestMapping("/shardUpload")
public class ShardUploadController {
@Autowired
private ShardUploadService shardUploadService;
/**
* 创建分片上传任务
*
* @return 分片任务id
*/
@PostMapping("/init")
public AjaxJson init(@RequestBody ShardUpload shardUpload) {
return shardUploadService.init(shardUpload);
}
/**
* 上传分片(客户端需遍历上传分片文件)
*
* @return
*/
@PostMapping("/uploadPart")
public AjaxJson uploadPart(@RequestParam("file")MultipartFile file,@RequestParam("partOrder")String partOrder,
@RequestParam("shardUploadId")String shardUploadId) throws IOException {
return shardUploadService.uploadPart(file,partOrder,shardUploadId);
}
/**
* 合并分片,完成上传
*
* @return
*/
@PostMapping("/complete")
public AjaxJson complete(@RequestBody String shardUploadId) throws IOException {
return shardUploadService.complete(shardUploadId);
}
/**
* 获取分片任务详细信息
*
* @param shardUploadId 分片任务id
* @return
*/
@PostMapping("/detail")
public AjaxJson detail(@RequestBody String shardUploadId) {
return shardUploadService.detail(shardUploadId);
}
}
- service
package com.gwb.template.service.impl;
import cn.hutool.core.util.IdUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gwb.template.entity.ShardUpload;
import com.gwb.template.entity.ShardUploadDetail;
import com.gwb.template.entity.ShardUploadPart;
import com.gwb.template.exception.ServiceException;
import com.gwb.template.mapper.ShardUploadMapper;
import com.gwb.template.mapper.ShardUploadPartMapper;
import com.gwb.template.service.ShardUploadService;
import com.gwb.template.util.AjaxJson;
import com.gwb.template.util.FileHashCalculator;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
@Service
public class ShardUploadServiceImpl extends ServiceImpl<ShardUploadMapper, ShardUpload> implements ShardUploadService {
public static final String path = "D:/luren/sharduploadGwb/";
@Autowired
ShardUploadPartMapper shardUploadPartMapper;
@Override
public AjaxJson init(ShardUpload shardUpload) {
String uuid = IdUtil.fastSimpleUUID();
shardUpload.setId(uuid);
int insert = this.baseMapper.insert(shardUpload);
if (insert >0){
Path storagePath = Paths.get(path+uuid);
// 确保存储路径存在
if (!Files.exists(storagePath)) {
try {
Files.createDirectories(storagePath);
} catch (IOException e) {
e.printStackTrace();
}
}
return AjaxJson.getSuccess("创建分片任务成功",uuid);
}
return AjaxJson.getError("创建分片任务失败");
}
@Override
public AjaxJson uploadPart(MultipartFile file, String partOrder, String shardUploadId) {
// 指定存储路径,例如D盘的uploads文件夹
Path storagePath = Paths.get(path+shardUploadId);
// 保存文件
try {
// 构建文件存储路径
Path filePath = storagePath.resolve(Paths.get(partOrder ));
file.transferTo(filePath);
ShardUploadPart shardUploadPart = new ShardUploadPart();
String uuid = IdUtil.fastSimpleUUID();
shardUploadPart.setId(uuid);
shardUploadPart.setFileFullPath(filePath.toString());
shardUploadPart.setPartOrder(Integer.valueOf(partOrder));
shardUploadPart.setShardUploadId(shardUploadId);
shardUploadPartMapper.insert(shardUploadPart);
System.out.println("File uploaded: " + filePath.toString());
} catch (IOException e) {
e.printStackTrace();
}
return AjaxJson.getSuccess("上传分片成功");
}
@Override
public AjaxJson complete(String shardUploadId) {
ShardUpload shardUpload = this.baseMapper.selectById(shardUploadId);
String fileName = shardUpload.getFileName();
String resultFilePath = path+shardUploadId+"/"+fileName;
// 创建最终的文件对象,如果不存在则创建它
File file = new File(resultFilePath);
FileOutputStream fileOutputStream = null;
// 打开输出流以写入文件,设置为追加模式
try {
fileOutputStream = FileUtils.openOutputStream(file, true);
//获取所有分片文件
List<ShardUploadPart> list = shardUploadPartMapper.selectList(Wrappers.lambdaQuery(ShardUploadPart.class).eq(ShardUploadPart::getShardUploadId, shardUploadId).orderByAsc(ShardUploadPart::getPartOrder));
for (ShardUploadPart item : list) {
String fileFullPath = item.getFileFullPath();
File partFile = new File(fileFullPath);
FileInputStream partFileInputStream = null;
try {
// 打开输入流读取当前分片部分的内容
partFileInputStream = FileUtils.openInputStream(partFile);
// 将当前分片部分的内容复制到输出流中
IOUtils.copyLarge(partFileInputStream, fileOutputStream);
}finally {
// 确保关闭输入流
IOUtils.closeQuietly(partFileInputStream);
}
System.out.println("已经和成过的文件:"+fileFullPath);
// 删除已经处理过的分片部分文件
partFile.delete();
};
} catch (IOException e) {
e.printStackTrace();
}finally {
// 确保关闭输出流
IOUtils.closeQuietly(fileOutputStream);
}
//对比文件hash值
try {
if (!FileHashCalculator.calculateSHA256(file).equals(shardUpload.getHash())){
throw new ServiceException("最终文件和分片文件hash值不一样");
}
}catch (Exception e){
e.printStackTrace();
}
shardUpload.setFileFullPath(resultFilePath);
this.baseMapper.updateById(shardUpload);
return AjaxJson.getSuccess("分片文件合成成功");
}
@Override
public AjaxJson detail(String shardUploadId) {
ShardUpload shardUpload = this.baseMapper.selectById(shardUploadId);
List<Integer> list = shardUploadPartMapper.getPartDetail(shardUploadId);
ShardUploadDetail shardUploadDetail = new ShardUploadDetail();
BeanUtils.copyProperties(shardUpload,shardUploadDetail);
shardUploadDetail.setPartOrderList(list);
return AjaxJson.getSuccess("获取任务详情",shardUploadDetail);
}
}
- mapper
@Mapper
public interface ShardUploadMapper extends BaseMapper<ShardUpload> {
// 这里可以添加自定义的方法
}
@Mapper
public interface ShardUploadPartMapper extends BaseMapper<ShardUploadPart> {
List<Integer> getPartDetail(@Param("shardUploadId") String shardUploadId);
// 这里可以添加自定义的方法
}
- mapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gwb.template.mapper.ShardUploadPartMapper">
<!-- 这里可以定义自定义的SQL语句 -->
<select id="getPartDetail" resultType="java.lang.Integer">
select part_order from shard_upload_part where shard_upload_id = #{shardUploadId}
</select>
</mapper>
- utils
在这里插入代码片
- maven
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Mysql驱动包 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- 阿里数据库连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>${druid.version}</version>
</dependency>
<!-- hutool工具包 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.9</version>
</dependency>
<!--常用工具类 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-boot-starter -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Sa-Token 权限认证, 在线文档:https://sa-token.cc/ -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>${sa-token.version}</version>
</dependency>
<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>1.38.0</version>
</dependency>
<!-- 提供Redis连接池 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
<scope>provided</scope>
</dependency>
<!-- ui界面 -->
<!--引入Knife4j的官方start包,该指南选择Spring Boot版本<3.0,开发者需要注意-->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-spring-boot-starter</artifactId>
<version>4.4.0</version>
</dependency>
<!-- fastjson2 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.52</version>
</dependency>
<!-- 本地线程变量 可在子线程之间传输 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>2.11.2</version> <!-- 请使用最新的稳定版本 -->
</dependency>
<!-- Spring Boot Starter AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- io常用工具类 -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
- yaml
spring:
servlet:
multipart:
max-file-size: 500MB
max-request-size: 500MB
- sqlTable
-- shard_upload:分片任务表,shard_upload_part:分片文件表,两个表是1:n的关系
create table if not exists shard_upload(
id varchar(64) primary key,
file_name varchar(256) not null comment '文件名称',
part_num int not null comment '分片数量',
md5 varchar(512) comment '文件hash值',
file_full_path varchar(512) comment '文件完整路径'
) comment = '分片上传任务表';
create table if not exists shard_upload_part(
id varchar(64) primary key,
shard_upload_id varchar(64) not null comment '分片任务id(shard_upload.id)',
part_order int not null comment '第几个分片,从1开始',
file_full_path varchar(512) comment '文件完整路径',
UNIQUE KEY `uq_part_order` (`shard_upload_id`,`part_order`)
) comment = '分片文件表,每个分片文件对应一条记录';