使用 ffmpeg 生成单个 MPEG-Dash 片段

Posted

技术标签:

【中文标题】使用 ffmpeg 生成单个 MPEG-Dash 片段【英文标题】:Generate single MPEG-Dash segment with ffmpeg 【发布时间】:2019-07-04 15:47:13 【问题描述】:

我一直在尝试实现一个类似 Plex 的视频播放器,它可以按需对任意视频文件进行转码,并在网页上使用 MPEG-Dash 播放它。我能够使用dash.js 参考实现来实现客户端播放器,因此它将动态地从服务器请求片段(在mpd 文件中使用SegmentTemplate)。

但我在实时生成这些块时遇到了一些问题。 Ffmpeg 让我设置 -ss-t 来定义我需要的片段的边界,但它们在播放器中无法正常播放,因为它们是“完整”视频文件而不是 Dash 片段。

那么如何调整我的 ffmpeg 命令以仅将我需要的部分转码为 Dash 片段,而无需提前为整个视频文件生成片段?

输入视频文件可以是任何格式,因此不能假定它是兼容 mp4/dash 的编解码器。所以需要转码(使用ffmpeg或类似工具)。

我当前的 ffmpeg 命令看起来像这样(经过大量尝试):

ffmpeg -ss 10 -t 5 -i video.mkv -f mp4 -c:a aac -c:v h264 -copyts -movflags empty_moov+frag_keyframe temp/segment.mp4

客户端播放器应该能够缓冲接下来的 X 段,并且用户应该能够在持续时间栏上查看当前位置并寻找不同的位置。因此,不能将其视为直播。

【问题讨论】:

你解决了这个问题吗? 我不完全理解来源,所以我不能真正转录一个正确的答案,但 Plex 转码器只是带有补丁的 ffmpeg。他们需要提供它们,你可以在网上找到它的副本,或者他们自己的副本,并与 ffmpeg 源进行比较。我一直希望有人能将更改提交到上游,以便我们可以创建您所询问的确切功能。 【参考方案1】:

我知道这是一个相对较旧的问题,但我认为我设法实施了您所描述的解决方案。总而言之,我们的想法是向客户端提供一个 dash manifest,但仅在客户端请求时才转换这些段。

实现这一目标的步骤是:

使用 ffmpeg 转换原始文件的一个流的 10 秒部分(如果它已经在 x264 中,则将其提取) 使用 MP4Box 重新打包它,以便 MSE 在客户端使用它。

第 1 步的命令如下所示(对于流 0 的第 3 段):

ffmpeg -y -ss 30 -t 11 -threads 8 -copyts -start_at_zero -i "/path/to/original.mp4" -map 0:1 -c copy /tmp/output_segment.mp4

"-ss 30" 告诉 ffmpeg 在文件开始后 30 秒开始。 “-t 11”在此之后保留 11 秒的曲目(重叠避免了播放中的间隙)。 “-copyts”保持时间戳不变,因此提取的分段将从 30 秒开始,而不是 0。“-c 复制”复制原始流,并将替换为“-g 30 -c:v libx264 -crf” 22 -profile:v high -level 3.1" 如果必须转码。

重新打包工作流的第二个命令是:

MP4Box -dash 10000 -frag 500 -rap -single-file -segment-name segment_base_name_ -tfdt $TFDT_OFFSET /tmp/output_segment.mp4 -out /tmp/unused_ouput.mp4

输出可以被丢弃,但它也会创建一个名为 segment_base_name_init.mp4 的文件,这就是您需要的实际段。这里的 -tfdt 参数是最重要的,因为它在时间轴中正确地偏移了片段。为了获得正确的值,我使用了以下命令(因为关键帧并不完全在 10 秒标记处,所以片段的开头可能不是我们期望的位置):

ffprobe -print_format json -show_streams /tmp/output_segment.mp4

正确的值为 start_time * 1000(-tfdt 使用毫秒)

我希望这会有所帮助,我花了一段时间才让它工作,我偶然发现了这个问题,因为 MP4Box 自上次更新以来突然停止工作。另请注意,您也可以使用 VP9 和 Vorbis 实现这一点,然后您无需重新打包流。

编辑

对于任何对此感兴趣的人,我上面描述的方法存在一些问题,因为 MP4Box 自 1.0 版(?)以来没有正确更新 tfdt 记录。

当独立于其他创建段时,段必须符合 Dash 标准(MP4Box 在之前的解决方案中这样做,但 FFMpeg 也可以使用 -f dash 作为输出来做到这一点)。选项还必须确保段的边界与 RAP(或 SAP 或 i-frames,我认为)对齐。该命令如下所示:

ffmpeg  -y -ss 390 -to  400 -threads 6 -copyts -start_at_zero -noaccurate_seek -i input.mkv -map 0:1 -c copy -movflags frag_keyframe -single_file_name segment_39.mp4 -global_sidx 1 -min_frag_duration 500 -f dash unused.mpd

那么问题是要确保每个片段都会被 MSE 正确放置在时间轴中。在分段的 MP4 文件中,有三个位置会影响时间轴中的位置:

在 moov 框(视频的一般信息)中,else 框(在 trak、edts 中)将有一个编辑列表。 FFMpeg 将 -ss 与 -copyts 一起使用时,将在视频本身之前创建一个空编辑,持续时间为 -ss(以毫秒为单位) 在 sidx 框(允许定位段的索引)中,earlyst_presentation_time 字段还定义了轨道时基中的偏移量 在每个 moof 框(片段的标头)中,traf 中的 tfdt 框有一个 base_media_decode_time 字段,将每个片段放在时间轴上,也在轨道时基中

FFMpeg 的问题在于它会正确创建前两个,但 tfdt 时间从零开始。由于我没有找到一种方法来做到这一点,所以我编写了那些简单的函数来纠正这个问题。请注意,它会删除第一次编辑,因为它被 Firefox 识别,但不能被 Chrome 识别,因此视频与两者都兼容。

    async function adjustSegmentTimestamps() 
        // console.log('Closing FFMPEG data (code should be 0)', code, signal);
        const file = await open(this.filename, 'r');
        const buffer = await readFile(file);
        await file.close();

        this.outFile = await open(this.filename, 'w', 0o666);

        // Clear first entry in edit list (required for Firefox)
        const moovOffset = this.seekBoxStart(buffer, 0, buffer.length, 'moov');
        if (moovOffset == -1) 
            throw new Error('Cannot find moov box');
        
        const moovSize = buffer.readUInt32BE(moovOffset);
        const trakOffset = this.seekBoxStart(buffer, moovOffset + 8, moovSize - 8, 'trak');
        if (trakOffset == -1) 
            throw new Error('Cannot find trak box');
        
        const trakSize = buffer.readUInt32BE(trakOffset);
        const edtsOffset = this.seekBoxStart(buffer, trakOffset + 8, trakSize - 8, 'edts');
        if (edtsOffset == -1) 
            throw new Error('Cannot find edts box');
        
        const edtsSize = buffer.readUInt32BE(edtsOffset);
        const elstOffset = this.seekBoxStart(buffer, edtsOffset + 8, edtsSize - 8, 'elst');
        if (elstOffset == -1) 
            throw new Error('Cannot find elst box');
        
        const numEntries = buffer.readUInt32BE(elstOffset + 12);
        console.log('Elst entries', numEntries);
        if (numEntries === 2) 
            console.log('Setting 1st elst entry to 0 duration vs. ', buffer.readUInt32BE(elstOffset + 16));
            buffer.writeUInt32BE(0, elstOffset + 16);
        

        // Looking for sidx to find offset
        let sidxOffset = this.seekBoxStart(buffer, 0, buffer.length, 'sidx');
        if (sidxOffset == -1) 
            throw new Error('Cannot find sidx box');
        
        sidxOffset += 8;

        const sidxVersion = buffer.readUInt8(sidxOffset);
        let earliest_presentation_time;
        if (sidxVersion) 
            earliest_presentation_time = buffer.readBigUInt64BE(sidxOffset + 12);
            // buffer.writeBigInt64BE(BigInt(0), sidxOffset + 12);
         else 
            earliest_presentation_time = buffer.readUInt32BE(sidxOffset + 12);
            // buffer.writeUInt32BE(0, sidxOffset + 12);
        

        console.log('Found sidx at ', sidxOffset, earliest_presentation_time);

        // Adjust tfdt in each moof
        let moofOffset = 0;
        while (moofOffset < buffer.length) 
            console.log();
            moofOffset = this.seekBoxStart(buffer, moofOffset, buffer.length - moofOffset, 'moof');
            if (moofOffset == -1) 
                console.log('No more moofs');
                break;
            
            const moofSize = buffer.readUInt32BE(moofOffset);

            if (moofOffset == -1) 
                console.log('Finished with moofs');
                break;
            
            console.log('Next moof at ', moofOffset);

            const trafOffset = this.seekBoxStart(buffer, moofOffset + 8, moofSize - 8, 'traf');
            const trafSize = buffer.readUInt32BE(trafOffset);
            console.log('Traf offset found at', trafOffset);
            if (trafOffset == -1) 
                throw new Error('Traf not found');
            

            const tfdtOffset = this.seekBoxStart(buffer, trafOffset + 8, trafSize - 8, 'tfdt');
            console.log('tfdt offset found at', tfdtOffset);
            if (tfdtOffset == -1) 
                throw new Error('Tfdt not found');
            

            const tfdtVersion = buffer.readUInt8(tfdtOffset + 8);
            let currentBaseMediaDecodeTime;
            if (tfdtVersion) 
                currentBaseMediaDecodeTime = buffer.readBigUInt64BE(tfdtOffset + 12);
                buffer.writeBigInt64BE(currentBaseMediaDecodeTime + earliest_presentation_time, tfdtOffset + 12);
             else 
                currentBaseMediaDecodeTime = buffer.readUInt32BE(tfdtOffset + 12);
                buffer.writeUInt32BE(currentBaseMediaDecodeTime + earliest_presentation_time, tfdtOffset + 12);
            
            console.log('TFDT offset', currentBaseMediaDecodeTime);


            moofOffset += moofSize;
        

        await this.outFile.write(buffer);
        await this.outFile.close();
    

    async function seekBoxStart(buffer: Buffer, start: number, size: number, box: string): number 
        let offset = start;
        while (offset - start < size) 
            const size_ = buffer.readUInt32BE(offset);
            const type_ = buffer.toString('ascii', offset + 4, offset + 8);

            console.log('Found box:', type_);
            if (type_ === box) 
                console.log('Found box at ', box, offset);
                return offset;
            

            offset += size_;
        

        return -1;
    

【讨论】:

【参考方案2】:

听起来您所描述的是直播而不是 VOD - 直播是连续的,通常是实时视频流,而 VOD 通常是在用户请求时提供的视频文件。

在较大的解决方案中,VOD 的常用方式是先对视频进行分段,然后将其按需打包成所需的流媒体协议,此时通常是 HLS 或 DASH。这允许操作员最大限度地减少他们需要维护的不同格式。

新兴的 CMAF 标准通过对 HLS 和 DASH 的段使用相同的格式来帮助支持这一点。如果你搜索“CMAF”,你会看到很多关于历史的解释,官方页面也在这里:https://www.iso.org/standard/71975.html

存在开源工具可帮助您将 MP4 文件直接转换为 DASH - MP4Box 是最常见的工具之一:https://github.com/gpac/gpac/wiki/DASH-Support-in-MP4Box

ffmpeg 还在文档中包含支持 VOD 的信息:https://www.ffmpeg.org/ffmpeg-formats.html#dash-2 包括一个示例:

ffmpeg -re -i <input> -map 0 -map 0 -c:a libfdk_aac -c:v libx264 \
-b:v:0 800k -b:v:1 300k -s:v:1 320x170 -profile:v:1 baseline \
-profile:v:0 main -bf 1 -keyint_min 120 -g 120 -sc_threshold 0 \
-b_strategy 0 -ar:a:1 22050 -use_timeline 1 -use_template 1 \
-window_size 5 -adaptation_sets "id=0,streams=v id=1,streams=a" \
-f dash /path/to/out.mpd

如果它实际上是您正在查看的实时流,那么输入通常不是 MP4 文件,而是某种格式的流,例如 HLS、RTMP、MPEG-TS 等。

采用这种格式的输入并提供实时配置文件 DASH 输出更加复杂。通常使用专用的打包程序来执行此操作。开源 Shaka Packager (https://github.com/google/shaka-player) 将是一个不错的选择,它包含生成 DASH 实时输出的示例:

https://google.github.io/shaka-packager/html/tutorials/live.html

假设您希望在生成视频文件时允许用户观看,那么实现此目的的一种方法是使流看起来像直播,即“VOD 到直播”案例。

您可以在 Ffmpeg 中使用重新流式传输来转码并流式传输到 UDP,然后将其馈送到打包程序中。

ffmpeg 文档包括此注释:

-re (输入) 以原生帧速率读取输入。主要用于模拟抓取设备或实时输入流(例如从文件读取时)。不应与实际抓取设备或实时输入流(可能导致丢包)一起使用。默认情况下,ffmpeg 尝试尽可能快地读取输入。此选项会将输入的读取速度减慢到输入的原始帧速率。它对于实时输出(例如直播)很有用。

这会给你一个看起来像这样的流程:

mp4 文件 -> ffmpeg -> 打包器 -> 实时 DASH 流 -> 客户端

使用打包程序执行此操作意味着您不必担心在新段可用或旧段不可用时更新清单。

Wowza 打包器网站上有一个示例(在撰写本文时),您可以查看和试验它,替换您现在的文件或使用他们的文件 - 输出应该适用于任何可以接受 UDP 输入的打包器流:https://www.wowza.com/docs/how-to-restream-using-ffmpeg-with-wowza-streaming-engine

【讨论】:

我肯定在做 VOD,而不是直播。但是一个更“个人”的变体,不像 Netflix,而是像 Plex,用户提供他们自己喜欢的任何格式和编解码器的媒体文件,以便实时(而不是提前)进行转码和重新打包,因为用户正在观看视频。所以我的问题不是“我如何为 Dash 制作完整的文件集”,因为我已经做到了这一点,但我想知道如何为任意文件转码和打包具有给定开始和持续时间的单个片段。据我所知,Mp4box 和 ffmpeg 只处理整个 Dash 流。 有趣且具有挑战性的用例!我已经用更多注释更新了答案。 感谢您的建议!不幸的是,我不能将视频视为实时流,因为用户必须能够在原生 &lt;video&gt; 元素内看到当前位置并将播放器寻找到不同的位置。这对于实时流是不可能的,我有相同的想法并尝试过,但是实时流不允许客户端缓冲(以捕获周期性的网络丢失)并且不允许观众寻找不同的在视频文件中的位置。我也将此信息添加到我原来的问题中。 我正在做类似的事情。 @Mick 你有没有想过这个? @Diericx,您提出一个新问题并在您的用例中包含尽可能多的细节可能是有意义的。

以上是关于使用 ffmpeg 生成单个 MPEG-Dash 片段的主要内容,如果未能解决你的问题,请参考以下文章

是否可以使用 MPEG-DASH 流式传输多帧率视频?

将 FFMPEG 编码为 MPEG-DASH - 或带有关键帧集群的 WebM - 用于 MediaSource API

只有单个 mp4 文件的 MPEG-DASH 视频流

FFmpeg移植Android编译生成单个库

当从单个图像生成一个视频时,ffmpeg视频连接不起作用

具有安全 S3 URL 的 MPEG-Dash