简体   繁体   English

将视频+音频(通过 ffmpeg)分段和转码为按需 HLS stream 的精确方法?

[英]Precise method of segmenting & transcoding video+audio (via ffmpeg), into an on-demand HLS stream?

recently I've been messing around with FFMPEG and streams through Nodejs.最近我一直在搞乱 FFMPEG 并通过 Nodejs 流式传输。 My ultimate goal is to serve a transcoded video stream - from any input filetype - via HTTP, generated in real-time as it's needed in segments.我的最终目标是通过 HTTP 提供转码的视频 stream - 来自任何输入文件类型 - 根据分段的需要实时生成。

I'm currently attempting to handle this using HLS.我目前正在尝试使用 HLS 来处理这个问题。 I pre-generate a dummy m3u8 manifest using the known duration of the input video.我使用输入视频的已知持续时间预先生成了一个虚拟 m3u8 清单。 It contains a bunch of URLs that point to individual constant-duration segments.它包含一堆指向单个恒定持续时间段的 URL。 Then, once the client player starts requesting the individual URLs, I use the requested path to determine which time range of video the client needs.然后,一旦客户端播放器开始请求各个 URL,我使用请求的路径来确定客户端需要哪个时间范围的视频。 Then I transcode the video and stream that segment back to them.然后我将视频和 stream 转码回给他们。

Now for the problem: This approach mostly works, but has a small audio bug.现在解决问题:这种方法大多有效,但有一个小的音频错误。 Currently, with most test input files, my code produces a video that - while playable - seems to have a very small (<.25 second) audio skip at the start of each segment.目前,对于大多数测试输入文件,我的代码生成的视频 - 虽然可播放 - 在每个片段的开头似乎有一个非常小的(<.25 秒)音频跳过。

I think this may be an issue with splitting using time in ffmpeg, where possibly the audio stream cannot be accurately sliced at the exact frame the video is.认为这可能是在 ffmpeg 中使用时间分割的问题,其中音频 stream 可能无法在视频的确切帧处准确切片。 So far, I've been unable to figure out a solution to this problem.到目前为止,我一直无法找到解决此问题的方法。

If anybody has any direction they can steer me - or even a prexisting library/server that solves this use-case - I appreciate the guidance.如果有人有任何方向,他们可以指导我 - 甚至是解决这个用例的现有库/服务器 - 我很感激指导。 My knowledge of video encoding is fairly limited.我对视频编码的了解相当有限。

I'll include an example of my relevant current code below, so others can see where I'm stuck.我将在下面包含我相关当前代码的示例,以便其他人可以看到我卡在哪里。 You should be able to run this as a Nodejs Express server, then point any HLS player at localhost:8080/master to load the manifest and begin playback.您应该能够将其作为 Nodejs Express 服务器运行,然后将任何 HLS 播放器指向 localhost:8080/master 以加载清单并开始播放。 See the transcode.get('/segment/:seg.ts' line at the end, for the relevant transcoding bit.请参阅最后的transcode.get('/segment/:seg.ts'行,了解相关的转码位。

'use strict';
const express = require('express');
const ffmpeg = require('fluent-ffmpeg');
let PORT = 8080;
let HOST = 'localhost';
const transcode = express();


/*
 * This file demonstrates an Express-based server, which transcodes & streams a video file.
 * All transcoding is handled in memory, in chunks, as needed by the player.
 *
 * It works by generating a fake manifest file for an HLS stream, at the endpoint "/m3u8".
 * This manifest contains links to each "segment" video clip, which browser-side HLS players will load as-needed.
 *
 * The "/segment/:seg.ts" endpoint is the request destination for each clip,
 * and uses FFMpeg to generate each segment on-the-fly, based off which segment is requested.
 */


const pathToMovie = 'C:\\input-file.mp4';  // The input file to stream as HLS.
const segmentDur = 5; //  Controls the duration (in seconds) that the file will be chopped into.


const getMetadata = async(file) => {
    return new Promise( resolve => {
        ffmpeg.ffprobe(file, function(err, metadata) {
            console.log(metadata);
            resolve(metadata);
        });
    });
};



// Generate a "master" m3u8 file, which the player should point to:
transcode.get('/master', async(req, res) => {
    res.set({"Content-Disposition":"attachment; filename=\"m3u8.m3u8\""});
    res.send(`#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=150000
/m3u8?num=1
#EXT-X-STREAM-INF:BANDWIDTH=240000
/m3u8?num=2`)
});

// Generate an m3u8 file to emulate a premade video manifest. Guesses segments based off duration.
transcode.get('/m3u8', async(req, res) => {
    let met = await getMetadata(pathToMovie);
    let duration = met.format.duration;

    let out = '#EXTM3U\n' +
        '#EXT-X-VERSION:3\n' +
        `#EXT-X-TARGETDURATION:${segmentDur}\n` +
        '#EXT-X-MEDIA-SEQUENCE:0\n' +
        '#EXT-X-PLAYLIST-TYPE:VOD\n';

    let splits = Math.max(duration / segmentDur);
    for(let i=0; i< splits; i++){
        out += `#EXTINF:${segmentDur},\n/segment/${i}.ts\n`;
    }
    out+='#EXT-X-ENDLIST\n';

    res.set({"Content-Disposition":"attachment; filename=\"m3u8.m3u8\""});
    res.send(out);
});

// Transcode the input video file into segments, using the given segment number as time offset:
transcode.get('/segment/:seg.ts', async(req, res) => {
    const segment = req.params.seg;
    const time = segment * segmentDur;

    let proc = new ffmpeg({source: pathToMovie})
        .seekInput(time)
        .duration(segmentDur)
        .outputOptions('-preset faster')
        .outputOptions('-g 50')
        .outputOptions('-profile:v main')
        .withAudioCodec('aac')
        .outputOptions('-ar 48000')
        .withAudioBitrate('155k')
        .withVideoBitrate('1000k')
        .outputOptions('-c:v h264')
        .outputOptions(`-output_ts_offset ${time}`)
        .format('mpegts')
        .on('error', function(err, st, ste) {
            console.log('an error happened:', err, st, ste);
        }).on('progress', function(progress) {
            console.log(progress);
        })
        .pipe(res, {end: true});
});

transcode.listen(PORT, HOST);
console.log(`Running on http://${HOST}:${PORT}`);

I had the same problem as you, and I've managed to fix this issue as i mentioned in the comment by starting the complete HLS transcoding instead of doing manually the segment requested by the client.我和你有同样的问题,正如我在评论中提到的那样,我已经设法解决了这个问题,方法是启动完整的 HLS 转码,而不是手动执行客户端请求的段。 I'm going to simplify what I've done and also share the link to my github repo where I've implemented this.我将简化我所做的工作,并分享我的 github repo 的链接,我已经在其中实现了这个。 I did the same as you for generating the m3u8 manifest:我和你一样生成 m3u8 清单:

        const segmentDur = 4; // Segment duration in seconds
        const splits = Math.max(duration / segmentDur); // duration = duration of the video in seconds
        let out = '#EXTM3U\n' +
            '#EXT-X-VERSION:3\n' +
            `#EXT-X-TARGETDURATION:${segmentDur}\n` +
            '#EXT-X-MEDIA-SEQUENCE:0\n' +
            '#EXT-X-PLAYLIST-TYPE:VOD\n';

        for (let i = 0; i < splits; i++) {
            out += `#EXTINF:${segmentDur}, nodesc\n/api/video/${id}/hls/${quality}/segments/${i}.ts?segments=${splits}&group=${group}&audioStream=${audioStream}&type=${type}\n`;
        }
        out += '#EXT-X-ENDLIST\n';
        res.send(out);
        resolve();

This works fine when you transcode the video (ie use for example libx264 as video encoder in the ffmpeg command later on).当您对视频进行转码时,这可以正常工作(例如,稍后在 ffmpeg 命令中使用 libx264 作为视频编码器)。 If you use videocodec copy the segments won't match the segmentDuration from my testing.如果您使用视频编解码器副本,则片段将与我的测试中的 segmentDuration 不匹配。 Now you have a choice here, either you start the ffmpeg transcoding at this point when the m3u8 manifest is requested, or you wait until the first segment is requested.现在您可以在这里选择,或者在请求 m3u8 清单时启动 ffmpeg 转码,或者等到请求第一个段。 I went with the second option since I want to support starting the transcoding based on which segment is requested.我选择了第二个选项,因为我想支持根据请求的段启动转码。

Now comes the tricky part, when the client requests a segment api/video/${id}/hls/<quality>/segments/<segment_number>.ts in my case you have to first check if any transcoding is already active.现在是棘手的部分,在我的情况下,当客户端请求段api/video/${id}/hls/<quality>/segments/<segment_number>.ts时,您必须首先检查是否有任何转码已经处于活动状态。 If a transcoding is active, you have to check if the requested segment has been processed or not.如果转码处于活动状态,您必须检查请求的片段是否已被处理。 If it has been processed we can simply send the requested segment back to the client.如果它已被处理,我们可以简单地将请求的段发送回客户端。 If it hasn't been processed yet (for example because of a user seek action) we can either wait for it (if the latest processed segment is close to the requested) or we can stop the previous transcoding and restart at the newly requested segment.如果尚未处理(例如由于用户搜索操作),我们可以等待它(如果最新处理的段接近请求),或者我们可以停止先前的转码并在新请求的段处重新开始.

I'm gonna try to keep this answer as simple as I can, the ffmpeg command I use to achieve the HLS transcoding looks like this:我会尽量让这个答案尽可能简单,我用来实现 HLS 转码的 ffmpeg 命令如下所示:

         this.ffmpegProc = ffmpeg(this.filePath)
        .withVideoCodec(this.getVideoCodec())
        .withAudioCodec(audioCodec)
        .inputOptions(inputOptions)
        .outputOptions(outputOptions)
        .on('end', () => {
            this.finished = true;
        })
        .on('progress', progress => {
            const seconds = this.addSeekTimeToSeconds(this.timestampToSeconds(progress.timemark));
            const latestSegment = Math.max(Math.floor(seconds / Transcoding.SEGMENT_DURATION) - 1); // - 1 because the first segment is 0
            this.latestSegment = latestSegment;
        })
        .on('start', (commandLine) => {
            logger.DEBUG(`[HLS] Spawned Ffmpeg (startSegment: ${this.startSegment}) with command: ${commandLine}`);
            resolve();
        })
        .on('error', (err, stdout, stderr) => {
            if (err.message != 'Output stream closed' && err.message != 'ffmpeg was killed with signal SIGKILL') {
                logger.ERROR(`Cannot process video: ${err.message}`);
                logger.ERROR(`ffmpeg stderr: ${stderr}`);
            }
        })
        .output(this.output)
        this.ffmpegProc.run();

Where output options are:其中 output 选项为:

    return [
        '-copyts', // Fixes timestamp issues (Keep timestamps as original file)
        '-pix_fmt yuv420p',
        '-map 0',
        '-map -v',
        '-map 0:V',
        '-g 52',
        `-crf ${this.CRF_SETTING}`,
        '-sn',
        '-deadline realtime',
        '-preset:v ultrafast',
        '-f hls',
        `-hls_time ${Transcoding.SEGMENT_DURATION}`,
        '-force_key_frames expr:gte(t,n_forced*2)',
        '-hls_playlist_type vod',
        `-start_number ${this.startSegment}`,
        '-strict -2',
        '-level 4.1', // Fixes chromecast issues
        '-ac 2', // Set two audio channels. Fixes audio issues for chromecast
        '-b:v 1024k',
        '-b:a 192k',
    ];

And input options:和输入选项:

        let inputOptions = [
            '-copyts', // Fixes timestamp issues (Keep timestamps as original file)
            '-threads 8',
            `-ss ${this.startSegment * Transcoding.SEGMENT_DURATION}`
        ];

Parameters worth noting is the -start_number in the output options, this basically tells ffmpeg which number to use for the first segment, if the client requests for example segment 500 we want to keep it simple and start the numbering at 500 if we have to restart the transcoding.值得注意的参数是-start_number选项中的 -start_number ,这基本上告诉 ffmpeg 第一个段使用哪个编号,如果客户端请求例如段 500,我们希望保持简单,如果必须重新启动,则从 500 开始编号转码。 Then we have the standard HLS settings (hls_time, hls_playlist_type and f).然后我们有标准的 HLS 设置(hls_time、hls_playlist_type 和 f)。 In the inputoptions I use -ss to seek to the requested transcoding, since we know we told the client in the generated m3u8 manifest that each segment was 4 seconds long, we can just seek to 4 * requestedSegment.在输入选项中,我使用-ss来查找请求的转码,因为我们知道我们在生成的 m3u8 清单中告诉客户端每个段长 4 秒,我们可以只查找 4 * requestedSegment。

You can see in the 'progress' event from ffmpeg I calculate the latest processed segment by looking at the timemark.您可以在 ffmpeg 的“进度”事件中看到我通过查看时间标记来计算最新处理的段。 By converting the timemark to seconds, then adding the applied seek-time for the transcoding we can calculate approximately which segment was just finished by dividing the amount of seconds with the segment duration which I've set to 4.通过将时间标记转换为秒,然后为转码添加应用的寻道时间,我们可以通过将秒数除以我设置为 4 的片段持续时间来近似计算刚刚完成的片段。

Now there is a lot more to keep track of than just this, you have to save the ffmpeg processes that you've started so you can check if a segment is finished or not and if a transcoding is active when the segment is requested.现在,除了这些,还有更多需要跟踪的内容,您必须保存已启动的 ffmpeg 进程,以便检查段是否已完成以及在请求段时转码是否处于活动状态。 You also have to stop already running transcodings if the user requests a segment far in the future so you can restart it with the correct seek time.如果用户在很远的将来请求段,您还必须停止已经运行的转码,以便您可以使用正确的寻道时间重新启动它。

The downside to this approach is that the file is actually being transcoded and saved to your file system while the transcoding is running, so you need to remove the files when the user stops requesting segments.这种方法的缺点是文件实际上正在被转码并在转码运行时保存到您的文件系统,因此您需要在用户停止请求段时删除文件。

I've implemented this so it handles the things I've mentioned (long seeks, different resolution requests, waiting until segment is finished etc).我已经实现了这个,所以它可以处理我提到的事情(长时间搜索,不同的分辨率请求,等到段完成等)。 If you want to have a look at it it's located here: Github Dose , most interesting files are the transcoding class , hlsManger class and the endpoint for the segments .如果您想查看它,它位于此处: Github Dose ,最有趣的文件是转码 classhlsManger class的端点I tried explaining this as good as I can so I hope you can use this as some sort of base or idea on how to move forward.我试着尽可能地解释这一点,所以我希望你能把它作为某种基础或想法来推动前进。

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM