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

标签 node.js video ffmpeg http-live-streaming transcode

最近我一直在搞乱 FFMPEG 并通过 Nodejs 流式传输。我的最终目标是通过 HTTP 提供转码的视频流 - 来自任何输入文件类型 - 根据分段的需要实时生成。

我目前正在尝试使用 HLS 来处理这个问题。我使用输入视频的已知持续时间预先生成了一个虚拟 m3u8 list 。它包含一堆指向单个恒定持续时间段的 URL。然后,一旦客户端播放器开始请求各个 URL,我使用请求的路径来确定客户端需要哪个时间范围的视频。然后我对视频进行转码并将该片段流回给他们。

现在解决问题:这种方法最有效,但有一个小的音频错误。目前,对于大多数测试输入文件,我的代码生成的视频 - 虽然可播放 - 在每个片段的开头似乎有一个非常小的(< .25 秒)音频跳过。

我认为这可能是在 ffmpeg 中使用时间分割的问题,其中音频流可能无法在视频的确切帧处准确切片。到目前为止,我一直无法找到解决此问题的方法。

如果有人有任何方向,他们可以指导我 - 甚至是解决这个用例的现有库/服务器 - 我很感激指导。我对视频编码的了解相当有限。

我将在下面包含我相关当前代码的示例,以便其他人可以看到我卡在哪里。您应该能够将其作为 Nodejs Express 服务器运行,然后将任何 HLS 播放器指向 localhost:8080/master 以加载 list 并开始播放。见 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}`);

最佳答案

我和你有同样的问题,正如我在评论中提到的那样,我已经设法解决了这个问题,方法是启动完整的 HLS 转码,而不是手动执行客户端请求的段。我将简化我所做的工作,并分享到我的 github repo 的链接,我已经在其中实现了这个。我和你一样生成 m3u8 list :

        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();
当您对视频进行转码时,这可以正常工作(例如,稍后在 ffmpeg 命令中使用 libx264 作为视频编码器)。如果您使用视频编解码器副本,则片段将与我的测试中的 segmentDuration 不匹配。现在您在这里有一个选择,要么在请求 m3u8 list 时启动 ffmpeg 转码,要么等到请求第一个段。我选择了第二个选项,因为我想支持根据请求的段启动转码。
现在是棘手的部分,当客户端请求一个段时 api/video/${id}/hls/<quality>/segments/<segment_number>.ts在我的情况下,您必须首先检查是否有任何转码已激活。如果转码处于事件状态,您必须检查请求的片段是否已被处理。如果它已被处理,我们可以简单地将请求的段发送回客户端。如果它还没有被处理(例如由于用户搜索操作),我们可以等待它(如果最新处理的段接近请求)或者我们可以停止先前的转码并在新请求的段处重新开始.
我会尽量让这个答案尽可能简单,我用来实现 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();
输出选项在哪里:
    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',
    ];
和输入选项:
        let inputOptions = [
            '-copyts', // Fixes timestamp issues (Keep timestamps as original file)
            '-threads 8',
            `-ss ${this.startSegment * Transcoding.SEGMENT_DURATION}`
        ];
值得注意的参数是-start_number在输出选项中,这基本上告诉 ffmpeg 将哪个编号用于第一段,如果客户端请求例如段 500,我们希望保持简单,如果必须重新开始转码,则从 500 开始编号。然后我们有标准的 HLS 设置(hls_time、hls_playlist_type 和 f)。在输入选项中我使用 -ss寻找请求的转码,因为我们知道我们在生成的 m3u8 list 中告诉客户端每个段长 4 秒,我们可以只寻找 4 * requestedSegment。
您可以在 ffmpeg 的“进度”事件中看到我通过查看时间标记来计算最新处理的段。通过将时间标记转换为秒,然后为转码添加应用的寻道时间,我们可以通过将秒数除以我设置为 4 的片段持续时间来近似计算刚刚完成的片段。
现在除了这些之外,还有更多需要跟踪的内容,您必须保存已启动的 ffmpeg 进程,以便检查段是否已完成以及在请求段时转码是否处于事件状态。如果用户在很远的将来请求段,您还必须停止已经运行的转码,以便您可以使用正确的寻道时间重新启动它。
这种方法的缺点是文件实际上正在被转码并在转码运行时保存到您的文件系统,因此您需要在用户停止请求段时删除文件。
我已经实现了这个,所以它可以处理我提到的事情(长时间搜索,不同的分辨率请求,等到段完成等)。如果你想看看它位于这里:Github Dose ,最有趣的文件是 transcoding class , hlsManger classsegments 的端点.我试着尽可能地解释这一点,所以我希望你能把它作为某种基础或想法来插入前进。

关于node.js - 将视频+音频(通过 ffmpeg)分段和转码为按需 HLS 流的精确方法?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/58898638/

相关文章:

node.js - 如何使用 Spring Cloud 和 Netflix 的 Eureka 注册 Node 应用程序

node.js - apt-get install -y nginx 更新版本

android - 在android中使用URI获取文件大小

c# - 使用 FFMPEG (C#) 从视频中获取特定帧

opencv - 使用OpenCV固定网络摄像头视频中的位置

bash - ffmpeg 脚本 mp4 到 mp3

javascript - 将观察者存储在全局变量中,稍后使用

javascript - 在 node.js 中获取查询字符串

用于递归查找和转换电影的 Bash 脚本

python - 如何在 Windows 上的 Python 3 中运行子进程以转换视频?