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

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

相关推荐
一勺菠萝丶4 分钟前
Spring Boot + MyBatis/MyBatis Plus:XML中循环处理List参数的终极指南
xml·spring boot·mybatis
RainbowSea1 小时前
问题:后端由于字符内容过长,前端展示精度丢失修复
java·spring boot·后端
风象南2 小时前
SpringBoot 控制器的动态注册与卸载
java·spring boot·后端
markyankee1012 小时前
Vue.js 入门指南:从零开始构建你的第一个应用
vue.js
我是一只代码狗2 小时前
springboot中使用线程池
java·spring boot·后端
hello早上好2 小时前
JDK 代理原理
java·spring boot·spring
PanZonghui2 小时前
Centos项目部署之运行SpringBoot打包后的jar文件
linux·spring boot
khalil3 小时前
基于 Vue3实现一款简历生成工具
前端·vue.js
沉着的码农3 小时前
【设计模式】基于责任链模式的参数校验
java·spring boot·分布式
zyxzyx6663 小时前
Flyway 介绍以及与 Spring Boot 集成指南
spring boot·笔记