完整教程:视频上传以及在线播放
大文件分片上传:
<!-- 视频上传按钮 -->
<button @click="triggerFileInput">视频上传</button>
<!-- 隐藏的文件输入 -->
<input
type="file"
ref="fileInputRef"
style="display: none"
@change="handleFileSelect"
accept="video/*"
/>
<script>
// 触发文件选择
const triggerFileInput = () =>
{
fileInputRef.value?.click();
};
// 处理文件选择
const handleFileSelect = (e) =>
{
const file = e.target?.files?.[0];
if (!file) return;
preRequest(file);
// 开始上传
};
const chunkSize = 1 * 1024 * 1024;
const number = ref(null);
//预请求
const preRequest = async (file) =>
{
try {
// Step 1: 计算文件基本信息
const fileName = file.name;
const fileSize = file.size;
const fileType = file.type;
const totalChunks = Math.ceil(fileSize / chunkSize);
// ✅ 使用 FormData 替代 JSON.stringify
const formData = new FormData();
formData.append('fileName', fileName);
formData.append('fileSize', fileSize);
formData.append('fileType', fileType);
formData.append('chunkSize', chunkSize);
formData.append('totalChunks', totalChunks);
// Step 2: 发送预请求,服务端返回 uploadId 和已上传的切片列表(用于断点续传)
const preRes = await fetch('/api/api/v1/indexRatingPollution/pre', {
method: 'POST',
headers: {
// 'Content-Type': 'application/json',
'Authorization': 'Bearer ' + getToken(),
},
body: formData
});
const preData = await preRes.json();
console.log(preData)
const { uploadId,number
} = preData.data;
console.log('预请求成功:', { uploadId, number
});
// Step 3: 开始上传切片(跳过已上传的,实现断点续传)
await uploadChunks(file, uploadId, number,totalChunks);
} catch (err) {
console.error('预请求失败:', err);
alert('上传准备失败: ' + err.message);
}
};
const uploadChunks = async (file, uploadId, currentChunkIndex, totalChunks) =>
{
currentChunkIndex = Number(currentChunkIndex);
// 递归终止条件:所有切片已上传完成
if (currentChunkIndex >=totalChunks) {
console.log('✅ 所有切片上传完成,开始合并文件...');
mergeFile(uploadId);
reload()
// await mergeFile(uploadId, file.name);
return;
// 递归结束
}
// 计算当前切片范围
const start = currentChunkIndex * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
// 构造 FormData
const formData = new FormData();
formData.append('file', chunk);
formData.append('uploadId', uploadId);
formData.append('number', currentChunkIndex);
// 当前是第几片
formData.append('fileName', file.name);
// 建议也传,便于后端记录
try {
console.log(` 正在上传第 ${currentChunkIndex + 1
} / ${totalChunks
} 片...`);
const res = await fetch('/api/api/v1/indexRatingPollution/uploadFile', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + getToken(),
// 不要手动设置 Content-Type
},
body: formData
});
const result = await res.json();
if (result.success) {
console.log(`✅ 第 ${currentChunkIndex + 1
} 片上传成功`);
// ✅ 递归:上传下一片
await uploadChunks(file, uploadId, currentChunkIndex + 1, totalChunks);
} else {
throw new Error(result.message || '上传失败');
}
} catch (err) {
alert(`第 ${currentChunkIndex + 1
} 片上传失败: ${err.message
}`);
// 可在此加入重试机制(见下方优化)
}
};
const mergeFile = async (uploadId) =>
{
const formData = new FormData();
formData.append('uploadId', uploadId);
const res = await fetch('/api/api/v1/indexRatingPollution/mergeFile', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + getToken()
},
body: formData
});
const result = await res.json();
if (result.success) {
console.log('合并成功,文件地址:', result.data);
}
};
</script>
@PostMapping("/pre")
public Result pre(@RequestParam("fileName") String fileName,@RequestParam("fileSize") String fileSize,
@RequestParam("fileType") String fileType,@RequestParam("chunkSize") String chunkSize,
@RequestParam("totalChunks") String totalChunks){
HashMap<
String, String> map = new HashMap<
>();
map.put("fileName", fileName);
map.put("fileSize", fileSize);
map.put("fileType", fileType);
map.put("chunkSize", chunkSize);
map.put("totalChunks", totalChunks);
map.put("number", "0");
double random = Math.random();
map.put("uploadId", String.valueOf(random));
redisTemplate.opsForValue().set( String.valueOf(random), map);
return Result.success(map);
}
@PostMapping("/uploadFile")
public Result uploadFile(@RequestParam("file") MultipartFile file,
@RequestParam("uploadId") String uploadId,
@RequestParam("number") String number
){
HashMap<
String,String> hashMap = (HashMap<
String,String>) redisTemplate.opsForValue().get(uploadId);
String chunkNumber = (String) hashMap.get("number");
if(Integer.parseInt(chunkNumber) != Integer.parseInt(number)){
ExceptionTool.throwException("xx");
}else{
hashMap.put("number", String.valueOf(Integer.parseInt(number) + 1));
redisTemplate.opsForValue().set(uploadId, hashMap);
}
try {
File file1 = new File(Paths.get("C:\\code\\environment-backend\\src\\main\\resources", uploadId).toString());
if(!file1.exists()){
file1.mkdirs();
}
file.transferTo(new File(file1.getPath() +"/" +number));
} catch (IOException e) {
throw new RuntimeException(e);
}
return Result.success(hashMap);
}
@PostMapping("/mergeFile")
public Result mergeFile(@RequestParam("uploadId") String uploadId) {
// 1. 从 Redis 获取上传元信息
Object obj = redisTemplate.opsForValue().get(uploadId);
if (!(obj instanceof HashMap)) {
return Result.fail("上传会话不存在或已过期");
}
HashMap<
String, String> uploadInfo = (HashMap<
String, String>) obj;
String totalChunksStr = uploadInfo.get("totalChunks");
String originalFileName = uploadInfo.get("fileName");
if (totalChunksStr == null || originalFileName == null) {
return Result.fail("缺少必要上传信息");
}
int totalChunks;
try {
totalChunks = Integer.parseInt(totalChunksStr);
} catch (NumberFormatException e) {
return Result.fail("分片总数格式错误");
}
// 2. 检查所有分片是否存在
String chunkDirPath = "C:\\code\\environment-backend\\src\\main\\resources\\" + uploadId;
File chunkDir = new File(chunkDirPath);
if (!chunkDir.exists()) {
return Result.fail("上传目录不存在");
}
for (int i = 0; i < totalChunks; i++) {
File chunkFile = new File(chunkDir, String.valueOf(i));
if (!chunkFile.exists()) {
return Result.fail("第 " + i + " 个分片缺失,无法合并");
}
}
// 3. 准备合并输出目录和文件
String mergedOutputDir = "C:\\code\\environment-backend\\src\\main\\resources\\static\\upload\\" + uploadId + "\\";
File outDir = new File(mergedOutputDir);
if (!outDir.exists()) {
outDir.mkdirs();
}
File mergedFile = new File(outDir, originalFileName);
// 4. 开始合并分片
try (FileOutputStream fos = new FileOutputStream(mergedFile);
BufferedOutputStream bos = new BufferedOutputStream(fos)) {
byte[] buffer = new byte[8192];
// 8KB 缓冲区
for (int i = 0; i < totalChunks; i++) {
File chunkFile = new File(chunkDir, String.valueOf(i));
try (FileInputStream fis = new FileInputStream(chunkFile);
BufferedInputStream bis = new BufferedInputStream(fis)) {
int bytesRead;
while ((bytesRead = bis.read(buffer)) != -1) {
bos.write(buffer, 0, bytesRead);
}
}
}
bos.flush();
// 确保写入磁盘
} catch (IOException e) {
return Result.fail("文件合并失败: " + e.getMessage());
}
// 5. 生成 HLS 流媒体(.m3u8 + .ts)
String hlsOutputDir = "C:\\code\\environment-backend\\src\\main\\resources\\static\\hls\\"+uploadId+"\\";
String m3u8Url;
try {
m3u8Url = generateHLS(mergedFile.getAbsolutePath(), hlsOutputDir, originalFileName);
} catch (Exception e) {
return Result.fail("HLS 转换失败: " + e.getMessage());
}
// 6. 清理临时文件
for (int i = 0; i < totalChunks; i++) {
File chunkFile = new File(chunkDir, String.valueOf(i));
if (chunkFile.exists()) chunkFile.delete();
}
if (chunkDir.exists()) chunkDir.delete();
// 7. 清理 Redis 记录
redisTemplate.delete(uploadId);
// 8. 返回 HLS 播放地址(前端可直接用 hls.js 播放)
return Result.success("合并并转码成功","hls/"+uploadId+"/" +m3u8Url);
} //返回前端预览
private String generateHLS(String inputFilePath, String outputDir, String originalFileName)
throws IOException, InterruptedException {
File outDir = new File(outputDir);
if (!outDir.exists() &&
!outDir.mkdirs()) {
throw new IOException("无法创建 HLS 输出目录: " + outputDir);
}
// 去扩展名
String baseName = originalFileName.replaceFirst("\\.[^.]+$", "");
String randomSuffix = UUID.randomUUID().toString().substring(0, 8);
// 更唯一
String m3u8FileName = baseName + "_" + randomSuffix + ".m3u8";
String m3u8OutputPath = outputDir + m3u8FileName;
String segmentPattern = outputDir + baseName + "_" + randomSuffix + "_%03d.ts";
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg",
"-i", inputFilePath,
"-c:v", "libx264",
"-c:a", "aac",
"-strict", "experimental",
"-preset", "medium",
"-hls_time", "10",
"-hls_list_size", "0",
"-hls_segment_filename", segmentPattern,
"-f", "hls",
m3u8OutputPath
);
Process process = pb.start();
// 读取 stdout 和 stderr //不读取的话会有问题
new Thread(() ->
{
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println( line);
}
} catch (IOException e) {
System.out.println("读取 FFmpeg stdout 流失败");
}
}).start();
new Thread(() ->
{
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getErrorStream()))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println( line);
}
} catch (IOException e) {
System.out.println("读取 FFmpeg stderr 流失败");
}
}).start();
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new RuntimeException("HLS 转换失败,FFmpeg 错误码: " + exitCode);
}
return m3u8FileName;
}
@Configuration
public class WebConfig
implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/hls/**")
.allowedOriginPatterns("*") // ✅ 允许所有源(包括 credentials)
.allowedMethods("GET", "HEAD")
.allowedHeaders("*")
.exposedHeaders("*")
.allowCredentials(true) // ✅ 支持携带 Cookie 等凭证
.maxAge(3600);
}
}
server:
port: 8520
tomcat:
mime-types:
m3u8: application/vnd.apple.mpegurl
ts: video/MP2T
返回的ts文件需要以这种形式才能播放
浙公网安备 33010602011771号