1.前端实现
<template>
<div class="video-upload">
<el-upload
class="upload-demo"
action="/api/upload"
:before-upload="beforeUpload"
:on-success="handleSuccess"
:on-error="handleError"
:show-file-list="false"
:data="uploadData"
:headers="headers"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">将视频文件拖到此处,或<em>点击上传</em></div>
</el-upload>
<div class="video-preview">
<video :src="videoUrl" id="video" ref="videoPlayer" controls class="w-full"></video>
</div>
<div v-if="progress > 0" class="progress-container">
转码进度:
<el-progress :percentage="progress" :status="progressStatus"></el-progress>
<p>{{ progressMessage }}</p>
</div>
</div>
</template>
<script>
import axios from 'axios';
import * as dashjs from 'dashjs';
import '../../node_modules/dashjs/dist/modern/esm/dash.mss.min.js';
export default {
name: 'HelloWorld',
data() {
return {
timerId: null,
id: null,
uploadData: {
title: '',
description: ''
},
headers: {
},
videoUrl: '',
progress: 0,
progressStatus: '',
progressMessage: '',
playerOptions: {
autoplay: false,
controls: true,
sources: []
}
};
},
methods: {
beforeUpload(file) {
const isVideo = /\.(mp4|avi|mov|mkv|flv|wmv)$/i.test(file.name);
if (!isVideo) {
this.$message.error('只能上传视频文件!');
return false;
}
// 初始化上传状态
this.progress = 0;
this.progressStatus = '';
this.progressMessage = '准备上传...';
return true;
},
async handleSuccess(response, file) {
console.log("file",file);
if (response.success) {
this.progress = 100;
this.progressStatus = 'success';
this.progressMessage = '上传成功! 转码处理中...';
// 开始轮询转码状态
await this.pollTranscodingStatus(response.data.taskId);
} else {
this.handleError(response.message);
}
},
handleError(err) {
this.progressStatus = 'exception';
this.progressMessage = `上传失败: ${err.message || err}`;
console.error('上传错误:', err);
},
async pollTranscodingStatus(taskId) {
try {
const res = await axios.get(`/api/transcode/status/${taskId}`);
if (res.data.data.status === 'COMPLETED') {
this.progressMessage = '转码完成!';
this.id = res.data.data.fileName;
this.playVideo(res.data.data.fileName)
} else if (res.data.data.status === 'FAILED') {
this.progressStatus = 'exception';
this.progressMessage = `转码失败: ${res.data.data.message}`;
} else {
this.progressMessage = `转码进度: ${res.data.data.progress || 0}%`;
this.timerId = setTimeout(() => this.pollTranscodingStatus(taskId), 1000);
}
} catch (err) {
this.timerId = setTimeout(() => this.pollTranscodingStatus(taskId), 1000);
console.error('获取转码状态失败:', err);
}
},
async playVideo(fileName){
const videoId = fileName.substring(0,fileName.lastIndexOf('.'));
this.videoUrl = "http://localhost:3000/dashVideo/dash/"+videoId+"/manifest.mpd"
const player = dashjs.MediaPlayer().create();
player.initialize(document.querySelector('#video'), this.videoUrl, true);
}
}
};
</script>
<style scoped>
.video-upload {
padding: 20px;
}
.upload-demo {
margin-bottom: 20px;
}
.video-preview {
margin-top: 20px;
}
.progress-container {
margin-top: 20px;
}
</style>
2前端依赖
"dependencies": {
"core-js": "^3.8.3",
"axios": "^0.18.0",
"element-ui": "^2.15.14",
"dashjs": "^5.0.1",
"vue": "^2.5.2"
},
3后端实现
3.1接收文件
@PostMapping("/upload")
public ResponseEntity<?> uploadVideo(@RequestParam("file") MultipartFile file) {
try {
// 生成唯一文件名
String originalFilename = file.getOriginalFilename(); //客户端上传时的完整文件名
String extension = originalFilename.substring(originalFilename.lastIndexOf('.'));
String filename = UUID.randomUUID().toString() + extension;
// 上传原始文件
storageService.upload(file, filename);
// 创建转码任务
String taskId = UUID.randomUUID().toString();
TranscodeTask task = new TranscodeTask();
task.setOriginalFile(filename);
task.setStatus("UPLOADED");
transcodeTasks.put(taskId, task);
// 异步开始转码
transcodeService.transcodeToDash(filename, filename.substring(0, filename.lastIndexOf('.')))
.thenAccept(result -> {
task.setStatus(result.isSuccess() ? "COMPLETED" : "FAILED");
task.setPlayUrl(result.getPlaylistUrl());
task.setMessage(result.getMessage());
});
Map<String,String> taskIdMap = new HashMap<>();
taskIdMap.put("taskId", taskId);
return ResponseEntity.ok().body(
new ApiResponse(true, "上传成功dfgdf", taskIdMap));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ApiResponse(false, "上传失败: " + e.getMessage(), null));
}
}
3.2文件转码
@Service
public class VideoTranscodeService {
@Value("${video.transcode.ffmpeg-path}")
private String ffmpegPath;
@Value("${video.transcode.hls-time}")
private int hlsTime;
@Value("${video.storage.local.path}")
private String uploadLocation;
@Autowired
private StorageService storageService;
private Map<String, Double> transcodeprogress = new ConcurrentHashMap<>();
// 将本地视频转码为DASH分片(多码率)
@Async("asyncTranscodeExecutor")
public CompletableFuture<TranscodeResult> transcodeToDash(String filename, String outputBasePath) throws Exception {
String outputDir = "../dash/"+outputBasePath + "_dash";
Path outputPath = Paths.get(outputDir);
Files.createDirectories(outputPath);
FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(uploadLocation+"/"+filename);
grabber.start();
int totalFrames = grabber.getLengthInFrames();
System.out.println("totalFrames:"+totalFrames);
String outputInitPattern = "init_$RepresentationID$.m4s";
String playsegmentPath = "segment_$RepresentationID$_$Number$.m4s";
String playmanifestPath = outputDir + "/manifest.mpd";
List<String> commands = new ArrayList<>();
commands.add(ffmpegPath);
commands.add("-i");
commands.add(uploadLocation+"/"+filename);
commands.add("-map");
commands.add("0:v");
commands.add("-map");
commands.add("0:a");
commands.add("-c:v");
commands.add("libx264");
commands.add("-crf");
commands.add("22");
commands.add("-profile:v");
commands.add("high");
commands.add("-level");
commands.add("4.2");
commands.add("-keyint_min");
commands.add("60");
commands.add("-g");
commands.add("60");
commands.add("-sc_threshold");
commands.add("0");
commands.add("-b:v:0");
commands.add("1000k");
commands.add("-s:v:0");
commands.add("1280x720");
commands.add("-b:v:1");
commands.add("5000k");
commands.add("-s:v:1");
commands.add("1920x1080");
commands.add("-c:a");
commands.add("aac");
commands.add("-b:a");
commands.add("128k");
commands.add("-f");
commands.add("dash");
commands.add("-seg_duration");
commands.add("4");
commands.add("-init_seg_name");
commands.add(outputInitPattern);
commands.add("-media_seg_name");
commands.add(playsegmentPath);
commands.add(playmanifestPath);
ProcessBuilder builder = new ProcessBuilder(commands);
builder.redirectErrorStream(true);
Process process = builder.start();
// 读取输出流
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
// System.out.println(line); // 可以记录日志或解析进度
if (line.contains("frame=")) {
// 提取当前帧数
int currentFrame = extractFrame(line);
System.out.println("currentFrame:"+currentFrame);
double progress1 = ((double) currentFrame/totalFrames) * 100;
System.out.println("adasdasdasd:"+progress1);
transcodeprogress.put(filename, progress1);
}
}
}
process.waitFor(); // 等待转码完成
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new RuntimeException("FFmpeg转码失败,退出码: " + exitCode);
}
return CompletableFuture.completedFuture(
new TranscodeResult(true, "转码成功"));
}
//转码进度计算
public double getProgress(String filename) {
Double progress = transcodeprogress.get(filename);
System.out.println("progress:"+progress);
return progress;
}
private int extractFrame(String logLine) {
// 正则匹配 frame= 后的数字(兼容空格和不同分隔符)
Pattern pattern = Pattern.compile("frame=\\s*(\\d+)"); // 匹配示例:frame= 123 或 frame=456
Matcher matcher = pattern.matcher(logLine);
if (matcher.find()) {
try {
return Integer.parseInt(matcher.group(1)); // 提取捕获组中的数字
} catch (NumberFormatException e) {
throw new IllegalStateException("帧数解析失败:" + logLine);
}
}
return 0; // 未匹配时返回默认值或抛异常
}
@Data
@AllArgsConstructor
public static class TranscodeResult {
private boolean success;
private String message;
}
}
3.3转码进度查询
@GetMapping("/transcode/status/{taskId}")
public ResponseEntity<?> getTranscodeStatus(@PathVariable String taskId) {
TranscodeTask task = transcodeTasks.get(taskId);
if (task == null) {
return ResponseEntity.notFound().build();
}
double progres = transcodeService.getProgress(task.getOriginalFile());
Map<String, Object> data = new HashMap<>();
data.put("status", task.getStatus());
data.put("fileName", task.getOriginalFile());
data.put("message", task.getMessage());
data.put("progress", progres);
return ResponseEntity.ok().body(
new ApiResponse(true, "查询成功", data));
}
3.4视频播放
@RestController
@RequestMapping("/dashVideo")
public class DashController {
@Value("${video.storage.local.path}")
private String storagePath;
@GetMapping("/dash/{videoId}/manifest.mpd")
public ResponseEntity<Resource> getDashManifest(@PathVariable String videoId) {
String pathStr = "../dash/" + videoId+"_dash/manifest.mpd";
Path mpdPath = Paths.get(pathStr);
Resource resource = new FileSystemResource(mpdPath);
return ResponseEntity.ok()
.header("Content-Type", "application/dash+xml")
.body(resource);
}
@GetMapping("/dash/{videoId}/{segment}")
public ResponseEntity<Resource> getSegment(
@PathVariable String videoId,
@PathVariable String segment) {
Path segmentPath = Paths.get("../dash/"+videoId+"_dash/"+segment);
Resource resource = new FileSystemResource(segmentPath);
return ResponseEntity.ok()
.header("Content-Type", "video/mp4")
.body(resource);
}
}
3.5上传完成后未转码文件位置
3.6转码后文件位置
播放的是转码分片后的文件