Bootstrap

FFmpeg Muxer HLS

使用FFmpeg命令来研究它对HLS协议的支持程度是最好的方法:

ffmpeg -h muxer=hls

Muxer HLS

Muxer hls [Apple HTTP Live Streaming]:
    Common extensions: m3u8.
    Default video codec: h264.
    Default audio codec: aac.
    Default subtitle codec: webvtt.

这里面告诉我们,FFmpeg中的Muxer hls实际上是对于Apple HTTP Live Streaming的一种实现(HLS,全称HTTP Live Streaming,是Apple公司发布的协议),这里明确说明了HLS只是一种封装格式而与编码无关。

默认的文件扩展名为m3u8​,我们在浏览器中观看动漫、电影的时候,可以使用工具去查看它里面的链接。最终,你大概率会发现这样一种m3u8​文件的访问链接。

FFmpeg的hls muxer默认支持的视频、音频和字幕的编码格式分别是:h264​、aac​和webvtt​。这也就意味着如果我们想要对其他编码格式的音频或者视频进行HLS封装,那么就需要显式地去指定这些编码格式。需要注意的是,这些其他的编码格式需要是HLS协议支持的编码格式。

假设我们需要对一个MP4文件进行HLS切片,更准确地说是将MP$的封装格式转换成HLS的封装格式,只不过HLS这个封装格式是由多个音视频文件和一个M3U8(该文件在HLS协议中被称为Media Playlist,用作指导这些切片后的音视频如何播放)组成的。

例子

多的不说,我这里找一个MP4文件,使用FFmpeg将其转换成hls的封装格式,看看会出现什么样的结果。

这里我使用ffprobe查看该MP4文件的编码,来明确是否需要进行转码操作:

Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'test_input.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2avc1mp41
    encoder         : Lavf60.16.100
  Duration: 00:00:14.82, start: 0.000000, bitrate: 2662 kb/s
  Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, bt709, progressive), 1920x1200 [SAR 1:1 DAR 8:5], 2489 kb/s, 60 fps, 60 tbr, 15360 tbn (default)
      Metadata:
        handler_name    : VideoHandler
        vendor_id       : [0][0][0][0]
  Stream #0:1[0x2](und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 159 kb/s (default)
      Metadata:
        handler_name    : SoundHandler
        vendor_id       : [0][0][0][0]

你可以看到,视频编码h264​和音频编码aac​都是默认的,这意味着我们不需要转码。

因此,我们可以使用以下命令来进行转封装:

mkdir output & ffmpeg -i test_input.mp4 -c copy -f hls output/index.m3u8

使用命令来查看output​目录中的内容:

> tree output
output
├── index.m3u8
├── index0.ts
├── index1.ts
├── index2.ts
└── index3.ts

> cat output/index.m3u8
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:4.166667,
index0.ts
#EXTINF:4.166667,
index1.ts
#EXTINF:4.166667,
index2.ts
#EXTINF:2.366667,
index3.ts
#EXT-X-ENDLIST

此刻,output​目录中的所有内容,组成了HLS协议的封装格式,虽然它们由多个文件组成。

但是,当需要切片的文件变大时,index.m3u8​的内容会和实际的切片数量对不上,原因就是:默认的hls_list_size​的值为5​。因此,index.m3u8文件中只会记录最新的5个切片。使用-hls_list_size​能够自己指定这个值。

选项

额外指定一些选项,让HLS的切片符合你的需求。

start_number

设置开始的序列号,默认从0开始。这里我们设置序列号为1进行切片,那么产生的结果为:

> mkdir output & ffmpeg -i test_input.mp4 -c copy -f hls -start_number 1 output/index.m3u8
> tree output
output
├── index.m3u8
├── index1.ts
├── index2.ts
├── index3.ts
└── index4.ts

注意,生成的切片文件名的序列号和m3u8文件中的序列号是对应的。

hls_time

指定切片的时间长度,单位为秒,默认值为2,类型是float​。

> cat output/index.m3u8
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:4.166667,
index0.ts
#EXTINF:4.166667,
index1.ts
#EXTINF:4.166667,
index2.ts
#EXTINF:2.366667,
index3.ts
#EXT-X-ENDLIST

#EXTINF​就是切片的时间长度,这里切片的时间长度为4.166667​,这和默认值2​不相符。这是什么原因导致的呢?我猜测,这可能是因为HLS对于每个切片的关键帧具有某种要求,导致了最终的切片时间按照原本视频的关键帧分布来进行切片。

使用-force_key_frames "expr:gte(t,n_forced*2)"​来让视频GOP大小为2秒,以此让切片能够按照我们所设置的参数运行:

$ ffmpeg -i test_input.mp4 \ 
> -f hls \ 
> -force_key_frames "expr:gte(t,n_forced*2)" \
> -hls_time 2 \
> output/index.m3u8

$ cat output/index.m3u8
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:3
#EXTINF:2.000000,
index3.ts
#EXTINF:2.000000,
index4.ts
#EXTINF:2.000000,
index5.ts
#EXTINF:2.000000,
index6.ts
#EXTINF:0.866667,
index7.ts
#EXT-X-ENDLIST

这证明我们的猜测是正确的。注意,由于-force_key_frames​选项改变了原本的视频帧,因此不能够指定-c copy​(如果指定,则会导致我们无法对原本的视频编码做出任何改变,-force_key_frames​就会失效)。

你可以看到上面只记录了5个切片,3 4 5 6 7​,而我们的切片明明是从0开始的,并且0 1 2​确实存在于output目录中,但却没有被m3u8文件记录,这就关系到-hls_list_size​的使用了

hls_list_size

前面说了,由于m3u8​这个Media Playlist只会记录最新的几个切片,这可能会导致播放错误。该选项默认值是5,当切片多于5个时,你就要考虑将其设置地大一些,防止切片错误。

hls_base_url

你可以指定切片的基础路径,比如:http://xxx.com/​。这样浏览器可以通过读取m3u8​文件,然后通过网络来访问这些切片。

比如:

> ffmpeg -i test_input.mp4 -c copy -f hls \
> -hls_base_url "http://www.aderversa.com/" \
> output/index.m3u8

> cat output/index.m3u8
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:4.166667,
http://www.aderversa.com/index0.ts
#EXTINF:4.166667,
http://www.aderversa.com/index1.ts
#EXTINF:4.166667,
http://www.aderversa.com/index2.ts
#EXTINF:2.316667,
http://www.aderversa.com/index3.ts
#EXT-X-ENDLIST

该选项通常和服务器配合使用。你设置了一个URL,然后浏览器可以通过从服务器获取m3u8​文件和视频切片文件。

远程播放HLS视频

在前面,我们已经知道了,HLS封装格式会生成以下文件:

output
├── index.m3u8
├── index0.ts
├── index1.ts
├── index2.ts
└── index3.ts

播放器拿到m3u8文件,它应该能够按照HLS协议并参考m3u8上的内容自主获取视频切片并播放。

这里我使用Qt6.6的QMediaPlayer​来播放m3u8文件(这样方便理解,实际上M3U8不包含视频数据,它只含有该怎样播放的信息)。

如何播放呢?抓住一个关键点:只要QMediaPlayer​拿到了M3U8文件,它就能够依照该M3U8文件播放上面的资源,而我们是不需要了解它具体如何播放的,因为播放器的开发者为我们完成了这部分工作。我们唯一需要关注的就是:如何让QMediaPlayer​获取到这份M3U8文件。

这里我在网上随便找一个M3U8文件的链接(随便找个不是很正规的视频网站一般都能够找到,查看其HTML代码你就能够发现隐藏在其中的M3U8文件):

具体是什么URL我就不放出来了。

我们可以发现该播放器需要一个M3U8的URL才能够播放。

我们将url=​后面的链接命名为VideoURL​,方便后续说明。

这里我们在浏览器中请求VideoURL​,看看会发生什么?结果就是,浏览器给我们下载了一个M3U8文件。

我们使用以下Qt6.6中的代码来播放VideoURL​,看看能否播放成功:

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    QMediaPlayer player;
    player.setSource(QUrl("https://play.modujx11.com/20250104/pZZhNChc/index.m3u8"));

    QVideoWidget video_widget;
    player.setVideoOutput(&video_widget);
    video_widget.show();

    QAudioOutput audio_output;
    player.setAudioOutput(&audio_output);

    player.play();
    return a.exec();
}

结果是,播放成功了。

这说明了什么呢?说明了只要服务器能够提供一个接口,让浏览器能够通过访问该接口URL下载到M3U8文件,且M3U8中的资源是可以被浏览器访问到的,那么实现了HLS协议的播放器应该就能播放该M3U8文件。

利用上面实验出来的特性搭建视频平台的一些猜想

那么,如果我们的应用程序想要通过HLS协议实现视频远程播放的功能,首先客户端需要有M3U8文件并进行播放的能力;而服务器只需要提供下载M3U8文件和下载M3U8文件中对应的切片文件的接口即可。

若应用程序能够播放的视频是服务器端规定好的,用户无法上传任何视频,那么我们在服务器端完全可以自己在相应的文件夹下使用FFmpeg命令来慢慢进行切片。

若用户可在应用程序中上传视频,那么上传完成之后,服务器端如果不追求性能和定制化,我个人认为直接调用FFmpeg的命令行程序来完成HLS的切片是没有问题的。如果用户上传的视频的编码或者封装格式不合适,那么要么禁止用户上传这类视频;要么就在后端慢慢进行转码,若同一时间有大量转码的视频,那么对于性能的消耗将是灾难性的,因此大部分应用程序都不会允许用户将格式不合适的视频直接上传到服务器端,而是让用户自行找方法转码,转码完成后再发送到服务器端。

用户若是能够上传视频,如果应用程序具有一定的用户基数,那么上传的视频数量大概率是会逐渐增加的,对服务器的性能要求也会逐渐提高(不管是空间上还是时间上)。

应用程序可能一开始就是将这些视频开放给所有用户的,用户可以通过客户端来访问或自己上传的、或别人上传的视频数据。我们可能需要使用数据库存业务数据 + 文件系统存视频数据 + 数据库和文件系统之间存在某种数据上的联系,以此数据存储为基础构建服务器,然后客户端/前端再基于服务器的接口构建符合需求的交互界面。

实现一个简单的服务器来验证猜想

首先,在SpringBoot中实现这样一个Controller,用来下载服务器上的文件:

@Controller
public class FileDownloadController {
    private static final String FILE_DIR = "/videos";

    @GetMapping("/download/{dirname}/{filename:.+}")
    @ResponseBody
    public ResponseEntity<Resource> downloadFile(@PathVariable("dirname") String dirname,
                                                 @PathVariable("filename") String filename) {
        String path = System.getProperty("user.dir") + FILE_DIR + "/" + dirname;
        Path media_list_path = Paths.get(path).resolve(filename).normalize();
        try {
            UrlResource resource =  new UrlResource(media_list_path.toUri());
            if (resource.exists() || resource.isReadable()) {
                return ResponseEntity.ok()
                        .contentType(MediaType.APPLICATION_OCTET_STREAM)
                        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.getFilename() + "\"")
                        .body(resource);
            }
            else {
                return ResponseEntity.notFound().build();
            }
        }
        catch (Exception e) {
            e.printStackTrace();
            return ResponseEntity.status(500).build();
        }
    }
}

接着,我们在指定的/videos​目录下创建好HLS封装的文件:

> ffmpeg -i hls_video.mp4 -f hls \
> -hls_base_url \  
> http://127.0.0.1:8080/download/hlstest1/ \
> -c copy \ 
> -hls_list_size 1000 \
> hlstest1/index.m3u8

> tree .
.
├── hls_video.mp4
└── hlstest1
    ├── index.m3u8
    ├── index0.ts
    ├── index1.ts
	...

大致内容如上所示,然后在替换Qt原本代码中的URL成http://localhost:8080/download/hlstest1/index.m3u8​,播放成功。

我这里的服务器运行在本机中,所以ip为localhost​。

;