使用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转码后文件位置

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

相关推荐
活于未来22 分钟前
从零搭建期权波动率曲面可视化平台:FastAPI + Vue 3 实战
vue.js
橘子海全栈攻城狮29 分钟前
【最新源码】基于SpringBoot + Vue的超市管理系统的设计与实现D002
java·开发语言·vue.js·spring boot·后端·spring
拾贰_C2 小时前
【Java | SpringBoot | GlobalExceptionHandler】全局异常拦截器实现思路
java·开发语言·spring boot
Lyra_Infra2 小时前
故障排查报告:Spring boot连接 Nacos 异常及启动失败
java·spring boot
wuqingshun3141594 小时前
如何理解Spring Boot中的starter?
java·spring boot·后端
wuqingshun3141594 小时前
SpringBoot是如何实现自动配置的
java·spring boot·后端
Cobyte4 小时前
使用 JavaScript 实现有限状态机的经典问题
前端·javascript·vue.js
zhouhui0014 小时前
AI帮我写了个Spring Boot校验,线上漏掉了这组边界条件
java·spring boot·redis·ai编程
acheding4 小时前
File System Access API 实战:让网页真正读写本地文件
前端·javascript·vue.js·编辑器·markdown
hdsoft_huge4 小时前
SpringBoot系列17:日志体系Logback配置,日志分割、脱敏、异常堆栈收集规范(生产级落地)
java·spring boot·logback