使用ffmpeg生成单个MPEG-Dash段

时间:2019-07-04 15:47:13

标签: video ffmpeg mpeg-dash transcoding

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

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

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

输入视频文件可以是任何格式,因此不能假定它在mp4 /破折号兼容的编解码器中。因此,需要使用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段,并且用户应该能够在持续时间栏上查看当前位置并寻找其他位置。因此,将其视为实时流是不可行的。

2 个答案:

答案 0 :(得分:2)

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

实现这一目标的步骤是:

  • 使用 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”保持时间戳不变,因此提取的分段将从 30s 开始,而不是 0。“-c copy”复制原始流,并将被替换为“-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 参数是最重要的,因为它可以在时间轴中正确偏移片段。为了获得正确的值,我使用以下命令(因为关键帧不完全在 10s 标记处,片段的开始可能不是我们期望的位置):

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

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

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

编辑

对于对此感兴趣的任何人,我上面描述的方法存在一些问题,因为自版本 1.0 (?) 以来 MP4Box 没有正确更新 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 框(允许定位片段的索引)中,early_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;
    }

答案 1 :(得分:0)

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

在较大的解决方案中完成VOD的通常方法是先分割视频,然后按需将其打包到所需的流协议中,此时通常为HLS或DASH。这样一来,操作员就可以最小化他们需要维护的不同格式。

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

现有开放源代码工具可帮助您将MP4文件直接转换为DASH-MP4Box是最常见的工具之一:https://gpac.wp.imt.fr/mp4box/dash/

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实时输出的示例:

假设您要允许用户在生成视频文件时观看,那么一种实现方法是使视频流看起来像是实时流,即“ VOD to Live”的情况。

您可以在Ffmpeg中使用重新流传输将代码转码并传输到UDP,然后将其提供给打包程序。

ffmpeg文档包含以下注释:

  

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

这为您提供了如下流程:

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

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

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