You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

FFmpeg+SpringBoot视频流接口无时长显示,无法拖拽播放求助

解决HTTP视频流无法快进/显示时长的问题

问题核心分析

当前实现将FFmpeg编码后的视频直接流式输出,存在三个关键缺陷:

  1. 未支持HTTP范围请求(Range Requests),播放器无法请求指定时间段内容,导致无法快进/后退。
  2. 未返回视频总时长、总大小等元数据,播放器无法渲染进度条和显示时长。
  3. FFmpeg参数设置偏向直播场景(empty_moov),缺少可seek的索引信息。

解决方案步骤

1. 支持HTTP范围请求

修改Spring控制器,处理Range请求头,返回206 Partial Content响应,同时设置Accept-Ranges、Content-Range等必要响应头。

2. 预获取视频元数据

使用FFprobe获取原视频总时长,结合编码码率估算总大小,用于填充响应头,让播放器知晓视频整体信息。

3. 调整FFmpeg参数

移除empty_moov(适合无时长的直播场景),保留frag_keyframe+faststart,确保MP4的moov原子包含索引信息且位于文件头部,让播放器快速获取元数据。

4. 修改编码逻辑,支持起始位置编码

在FFmpeg命令中添加-ss参数,实现从指定时间点开始编码,响应播放器的Range请求。


代码修改示例

1. 新增FFprobe工具类获取元数据

import net.bramp.ffmpeg.probe.FFmpegProbeResult;
import net.bramp.ffmpeg.probe.FFmpegStream;
import net.bramp.ffmpeg.FFprobe;
import org.springframework.stereotype.Component;
import java.io.IOException;

@Component
public class VideoMetadataService {
    private final FFprobe ffprobe;

    public VideoMetadataService() {
        this.ffprobe = FFprobe.atPath();
    }

    // 获取视频总时长(秒)
    public double getVideoDuration(String videoPath) throws IOException {
        FFmpegProbeResult probeResult = ffprobe.probe(videoPath);
        FFmpegStream videoStream = probeResult.getStreams().stream()
                .filter(s -> s.codec_type.equals("video"))
                .findFirst()
                .orElseThrow(() -> new RuntimeException("No video stream found"));
        return videoStream.duration;
    }

    // 估算编码后的视频大小(基于码率和时长)
    public long getEstimatedEncodedSize(double duration, int videoBitrate, int audioBitrate) {
        long totalBitrate = (videoBitrate + audioBitrate) * 1000;
        return (long) (totalBitrate * duration) / 8;
    }
}

2. 修改控制器支持Range请求

@RestController
@RequestMapping("/api")
@Log4j2
public class StreamController {

    @Autowired
    VideoStreamingService videoStreamingService;
    @Autowired
    VideoService videoService;
    @Autowired
    VideoMetadataService metadataService;

    private static final int VIDEO_BITRATE = 5000; // 对应FFmpeg的-b:v 5000k
    private static final int AUDIO_BITRATE = 320; // 对应FFmpeg的-b:a 320k

    @GetMapping("/stream/{videoId}")
    public ResponseEntity<StreamingResponseBody> streamVideo(@PathVariable Long videoId,
                                                             @RequestParam(required = false) String codec,
                                                             @RequestHeader(value = "Range", required = false) String range) throws IOException {
        Video video = videoService.findVideoById(videoId);
        if (video == null) {
            return ResponseEntity.notFound().build();
        }

        Codec codecEnum = Codec.fromString(codec);
        double totalDuration = metadataService.getVideoDuration(video.getPath());
        long totalSize = metadataService.getEstimatedEncodedSize(totalDuration, VIDEO_BITRATE, AUDIO_BITRATE);

        if (range == null) {
            // 完整视频请求
            return ResponseEntity.ok()
                    .contentType(MediaType.valueOf("video/mp4"))
                    .header("Accept-Ranges", "bytes")
                    .header("Content-Length", String.valueOf(totalSize))
                    .body(outputStream -> videoStreamingService.streamVideo(video.getPath(), outputStream, codecEnum, 0));
        } else {
            // 处理Range请求
            long startByte = parseRangeStart(range, totalSize);
            double startTime = (startByte * 8.0) / ((VIDEO_BITRATE + AUDIO_BITRATE) * 1000);
            long contentLength = totalSize - startByte;

            return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
                    .contentType(MediaType.valueOf("video/mp4"))
                    .header("Accept-Ranges", "bytes")
                    .header("Content-Range", String.format("bytes %d-%d/%d", startByte, totalSize - 1, totalSize))
                    .header("Content-Length", String.valueOf(contentLength))
                    .body(outputStream -> videoStreamingService.streamVideo(video.getPath(), outputStream, codecEnum, startTime));
        }
    }

    private long parseRangeStart(String range, long totalSize) {
        String[] parts = range.replace("bytes=", "").split("-");
        long start = Long.parseLong(parts[0]);
        return Math.min(start, totalSize - 1);
    }
}

3. 修改服务层支持起始时间编码

@Service
public class VideoStreamingService {

    public void streamVideo(String videoPath, OutputStream outputStream, Codec codec, double startTime) {
        FFmpegBuilder builder = new FFmpegBuilder()
                .setInput(videoPath)
                .addExtraArgs("-ss", String.valueOf(startTime)) // 从指定时间点开始编码
                .addExtraArgs("-b:v", "5000k")
                .addExtraArgs("-maxrate", "5000k")
                .addExtraArgs("-bufsize", "10000k")
                .addExtraArgs("-c:a", "aac")
                .addExtraArgs("-b:a", "320k")
                .addExtraArgs("-movflags", "frag_keyframe+faststart") // 移除empty_moov
                .setOutput(PipeOutput.pumpTo(outputStream))
                .setFormat("mp4")
                .addExtraArgs("-nostdin");

        if (codec == Codec.AMD) {
            builder.addExtraArgs("-profile:v", "high");
        }
        builder.addExtraArgs("-c:v", codec.getFfmpegArgument());

        FFmpeg ffmpeg = FFmpeg.atPath();
        try {
            ffmpeg.run(builder);
        } catch (IOException | InterruptedException e) {
            log.error("Error streaming video", e);
            throw new RuntimeException(e);
        }
    }
}

关键参数说明

  • -movflags frag_keyframe+faststart:faststart将MP4的moov原子(包含索引和元数据)移到文件开头,播放器加载初期即可获取时长和索引;frag_keyframe确保视频按关键帧分段,方便seek操作。
  • HTTP Range请求:播放器通过Range头请求指定字节范围,服务器返回对应片段,实现快进/后退功能。
  • FFprobe元数据:提前获取原视频时长,结合编码码率估算总大小,让播放器能渲染完整进度条。

内容的提问来源于stack exchange,提问作者lxluxo23

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.26 03:35:02