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

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

相关推荐
XMYX-04 小时前
Spring Boot + Prometheus 实现应用监控(基于 Actuator 和 Micrometer)
spring boot·后端·prometheus
@yanyu6666 小时前
springboot实现查询学生
java·spring boot·后端
酷爱码7 小时前
Spring Boot项目中JSON解析库的深度解析与应用实践
spring boot·后端·json
萌萌哒草头将军7 小时前
🚀🚀🚀Prisma 发布无 Rust 引擎预览版,安装和使用更轻量;支持任何 ORM 连接引擎;支持自动备份...
前端·javascript·vue.js
java干货8 小时前
虚拟线程与消息队列:Spring Boot 3.5 中异步架构的演进与选择
spring boot·后端·架构
武昌库里写JAVA10 小时前
iview Switch Tabs TabPane 使用提示Maximum call stack size exceeded堆栈溢出
java·开发语言·spring boot·学习·课程设计
ai产品老杨10 小时前
减少交通拥堵、提高效率、改善交通安全的智慧交通开源了。
前端·vue.js·算法·ecmascript·音视频
小白杨树树11 小时前
【WebSocket】SpringBoot项目中使用WebSocket
spring boot·websocket·网络协议
张老爷子12 小时前
记录uniapp开发安卓使用webRTC实现语音推送
vue.js