使用spring boot vue 上传mp4转码为dash并播放

1.前端实现

java 复制代码
<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前端依赖

java 复制代码
  "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接收文件

java 复制代码
    @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文件转码

java 复制代码
@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转码进度查询

java 复制代码
@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视频播放

java 复制代码
@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);
    }

}

注:/dash/{videoId}/manifest.mpd中manifest.mpd是固定的不能删除

MPD文件中无时:

播放器会以MPD文件自身的URL路径为基准,拼接分片文件名。

示例:

MPD文件URL: http://example.com/videos/video1/manifest.mpd

分片文件名: segment_1.m4s

实际请求URL: http://example.com/videos/video1/segment_1.m4s

3.5上传完成后未转码文件位置

3.6转码后文件位置

播放的是转码分片后的文件

相关推荐
山海上的风25 分钟前
Vue里面elementUi-aside 和el-main不垂直排列
前端·vue.js·elementui
李白的粉1 小时前
基于springboot的在线教育系统
java·spring boot·毕业设计·课程设计·在线教育系统·源代码
小马爱打代码2 小时前
SpringBoot原生实现分布式MapReduce计算
spring boot·分布式·mapreduce
iuyou️2 小时前
Spring Boot知识点详解
java·spring boot·后端
一弓虽2 小时前
SpringBoot 学习
java·spring boot·后端·学习
jjw_zyfx3 小时前
成熟的前端vue vite websocket,Django后端实现方案包含主动断开websocket连接的实现
前端·vue.js·websocket
来自星星的猫教授3 小时前
spring,spring boot, spring cloud三者区别
spring boot·spring·spring cloud
A阳俊yi6 小时前
Spring Boot日志配置
java·spring boot·后端