给数字人生成加上界面,基于ER-NeRF/RAD-NeRF/AD-NeRF,Gradio框架构建WEBUI,使用HLS流媒体,实现边推理边播放——之二:将ndarray内存序列图直接转成ts格式视频

前言

  • 前文说到,我们的目标是要实现服务器一边推理一边播放视频的效果,并且知道服务器现在的推理状况
  • 前文链接:http://t.csdnimg.cn/Gy6JU
  • 服务器日志实时在webui中输出前文已经讲解,这里讲解如何边推边播
  • 要实现边推边播,最重要的问题是要选择一种视频播放协议,使浏览器能直接播放没有完整生成的视频文件(不要直播推流那一套,毕竟服务器资源有限),其次要解决视频文件直接从内存中转存的问题
  • 根据我们的要求,hls协议就能满足我们的要求:基于http协议,不需要单独再架设流媒体服务器,使用m3u8索引文件对视频文件进行概括,边播边加载,不会全部加载所有文件…

效果

在这里插入图片描述

实现

首先,我们需要根据hls协议,生成索引m3u8文件。
当前的状态是我们只有一个音频文件,视频文件是完全由机器推理生成的。
所以我们只能根据音频文件,自己拼凑一个m3u8文件:
默认我们每个ts文件是5s钟,那么根据音频文件的长度,就可以拼凑成一个完整的索引文件:

def create_m3u8_by_totalTime(totalTime: int, save_path_name: str):
    '''根据总时长,按每5s一段,创建一个m3u8文件,返回每个ts文件的名字队列
        :param totalTime 总时长,ms
        :param save_path_name m3u8文件要存储的路径及名称,以.m3u8为后缀
        :returns 返回每个ts的名字的队列对象及最后一个ts的时长(ms)
    '''
    dir = os.path.dirname(save_path_name)
    if not os.path.exists(dir):
        os.makedirs(dir)
    segment = int(totalTime / 5000) if totalTime % 5000 == 0 else int(totalTime / 5000) + 1
    tsQueue = queue.Queue(segment)
    with open(save_path_name, 'w') as m3u8:
        m3u8.write('#EXTM3U\n')
        m3u8.write('#EXT-X-VERSION:3\n')
        m3u8.write('#EXT-X-MEDIA-SEQUENCE:0\n')  # 当播放打开M3U8时,以这个标签的值作为参考,播放对应的序列号的切片
        m3u8.write('#EXT-X-ALLOW-CACHE:YES\n')
        m3u8.write('#EXT-X-TARGETDURATION:6\n')  # ts播放的最大时长,s
        lastTime = -1
        for i in range(segment):
            if i + 1 == segment:
                lastTime = totalTime % 5000
            m3u8.write(f'#EXTINF:{5.0 if lastTime < 0 else lastTime / 1000},\n')  # ts时长,注意有个逗号
            m3u8.write(f'{i}.ts\n')
            tsQueue.put(f'{i}.ts')
        m3u8.write('#EXT-X-ENDLIST')
    return tsQueue, lastTime

然后,我们需要将NeRF推理生成的图像帧转存为视频ts文件。
已知我们的视频都是25帧/秒,那5s的视频就应该是125帧,所以我们就按服务器每生成125帧图像的时候,就生成一次ts文件:

def create_ts_with_5sec(save_path:str,frame_ndarray:list,ts_index:int):
    '''创建一个5s时长的ts文件'''
    tmp_file = os.path.join(save_path,f'_tmp_quiet_{ts_index}.ts')
    height,width,c = frame_ndarray[0].shape
    print(f'======>视频width:{width},height:{height}')
    #图像写入ts文件
    process = (
        ffmpeg.input('pipe:', format='rawvideo', pix_fmt='rgb24', s='{}x{}'.format(width, height),framerate=25)
        .output(tmp_file, vcodec='libx264', r=25,output_ts_offset=ts_index * 5,hls_time=5,hls_segment_type='mpegts') #r:帧率,output_ts_offset:ts文件序列
        .global_args('-y')  # 覆盖同名文件
        .run_async(pipe_stdin=True)
    )
    for frame in frame_ndarray:
        process.stdin.write(frame.astype(np.uint8).tobytes())
        time.sleep(0.01)
    process.stdin.close()
    process.wait()

通过ffmpeg-python包的api,指定输入为一个管道pipe,指定输出为mpegts,帧率为25,注意output_ts_offset要为一个正序增长的数。
通过process.stdin.write(frame.astype(np.uint8).tobytes())将内存中的每张图像都写入ffmepg管道中。
但这仅仅是写入了视频,还需要加上声音。
加声音这里有两种方式,一种是图像转视频输出的时候,不写入磁盘,直接就写入一个输出管道中,然后将管道中的数据再进行合并声音操作。
另一种是output的时候就存为一个临时文件,合并声音的时候再读取文件合并,最后输出一个正式文件。
目前第一种方案暂时未研究成功,这里记录第二种方案:
注意:这里音频读取的范围段是一个固定5s时长的范围

def seconds_to_time(seconds):
    hours = seconds // 3600
    minutes = (seconds % 3600) // 60
    seconds = seconds % 60
    return f"{hours:02d}:{minutes:02d}:{seconds:02d}"

tmp_ts = ffmpeg.input(tmp_file)
    audio = ffmpeg.input(audio_path_name, ss=seconds_to_time(ts_index * 5),t='5') #顺序读取5s的音频时长内容
    joined = ffmpeg.concat(tmp_ts.video, audio, v=1, a=1).node
    out = ffmpeg.output(joined[0], joined[1],tmp_file.replace('_tmp_quiet_',''),vcodec='libx264', r=25,output_ts_offset=ts_index * 5,hls_time=5,hls_segment_type='mpegts').global_args('-y')  # 覆盖同名文件
    out.run()
    print('=====>ts生成完成!')

最终生成完成!
通过日志输出的方式,通知客户端m3u8文件的位置,让客户端加载m3u8文件,进行视频播放。

另外,数字人的图像推理,大多集中使用GPU,此处图像转存为ts格式视频文件,使用CPU,那这两个步骤可以并行执行。
思路就是通过一个队列,推理了一帧图像之后,就将图像的ndarray数组存入队列中,然后开启一个读取队列值的子线程,在子线程中取出图像数组,执行上面的生成ts文件的操作,就可以达到边推理边生成视频文件的效果。
相关代码我已放到gitee,有问题私信。

下一篇文章我们讲一下gradio怎么实时获取m3u8索引文件并实时播放