技术宅的结界

 找回密码
 立即注册→加入我们

QQ登录

只需一步,快速开始

搜索
热搜: 下载 VB C 实现 编写
查看: 584|回复: 5
收起左侧

【C语言】使用控制台播放视频:FontVideo的设计过程

[复制链接]

1093

主题

2664

帖子

7万

积分

用户组: 管理员

一只技术宅

UID
1
精华
238
威望
606 点
宅币
22201 个
贡献
46077 次
宅之契约
0 份
在线时间
2130 小时
注册时间
2014-1-26
发表于 2022-2-3 21:16:32 | 显示全部楼层 |阅读模式

欢迎访问技术宅的结界,请注册或者登录吧。

您需要 登录 才可以下载或查看,没有账号?立即注册→加入我们

x

使用控制台播放视频:FontVideo的设计过程

这是我的一个长期以来一直想实现的项目。最初我使用DirectShow实现了一次,但效果并不理想

后来我想结合 GLSL Shader 与 FFmpeg 的 API 重新实现了一次。虽说这次的效果依然不是十分理想,但已经达到预期。

这个项目开源。
https://github.com/0xAA55/FontVideo

我在 Bilibili 上发布了若干视频,但由于视频存在一个码率限制的问题,它会糊。所以还是本地播放的效果更合理。

虽说使用控制台播放视频的这一目标,广义上来说并不一定需要以输出文字的方式解决,比如,有一位朋友急切地希望能教会我通过调用API的方式直接把视频图像输出到控制台窗口的HDC上,他觉得我之所以使用文本来表现视频是因为我可能不懂 Windows API,并且他觉得这样才能正常播放视频。但是这就太无聊了,因为先不说我早在14年前就做到了让控制台窗口显示任意内容了,如果只是这样的话,我只需要调用控制台相关的若干API就可以实现这样的绘图,而且这会破坏原本的工程源码能够兼容包括非Windows平台的优势,比如我想在嵌入式环境下能够通过串口输出图像或者视频,那就没有控制台窗口API可用了。

前期准备

我想实现的是在控制台界面通过使用不同颜色、形状的文字来实现视频画面的合成渲染,并且在有条件的情况下输出音频。

那么首先我需要一套多媒体文件的解码工具。我选择了 FFmpeg,并且直接从FFmpeg 的官网下载了 BtbN 提供的编译

音频的输出,我选择 libsoundio 。懒得弄 CMAKE 了,我直接把它里面我要的那几个 .c .h 文件弄过来用,自己手写了config.h,然后丢进 VS2019 里,做了个工程,把除了WASAPI以外的模块都排除掉。

对控制台的输出,我觉得使用控制台相关的 API 去控制当前输出颜色,然后直接 printf 或者 fwritestdout 就行了。

在创建 VS2019 工程的时候,我思考了一下,似乎可以试试使用 OpenMP,顺带练习一下它的用法。于是我设置工程启用了OpenMP

核心算法

我计划把FFmpeg解码出来的视频帧的内容,按照文字的尺寸拆分为一个个小块,使用卷积乘法的方式,去和所有候选的字符本身进行卷积乘法,求出分数,然后取分数最高的字符用于表达“轮廓”或者“形状”,从而筛选出形状上最像那个图块的字符。再从原图块求出其平均颜色,再从控制台上有限的16色去做颜色相近度的比较,来取得字符的颜色。

我有一个东北的朋友说“这不就是OCR嘛。”虽然实际上并不是OCR(实际上的OCR需要检测文本的大小和分布,然后使用对应的字体和字号进行文本匹配),但核心本质似乎差不多。

字符我选择ASCII字符。手头上有现成的字符位图文件可以拿来用。

这个过程中,视情况,很多个步骤都可以进行预处理,避免重复的计算影响总体的性能。

此外,关乎画质方面,卷积乘法使用的两个向量其实可以进行一些预处理,比如标准化,或者对原图块进行标准化,或者以别的方式进行微调。这需要程序跑起来后再目力观察渲染的情况,去推测有没有什么明显的优化手段。

程序设计之必经之路:踩坑与填坑

缺什么东西就自己造,遇到什么问题就自己去解决。作为一个合格的技术宅,须要知道自己的成长建立在踩坑与填坑的过程之中。

我下载的最新的 FFmpeg 太新了

最初在下载 FFmpeg 的时候,我当时以为直接下载最新的二进制就行了,懒得自己配置解码器并编译了。结果在编写的过程中发生了出乎我意料的事情:最新的二进制太新了,连一部分 API 都改了,官网的一些旧 Examples 甚至都编译不起来。我顿时在想:FFmpeg 是如何进行版本管理的?不过,毕竟我下载的是 BtbN 提供的 bleeding edge 的每晚自动编译版,这些新 API 应该会在下一个大版本升级中被加入,旧 API 被移除。

后面我一边观察 FFmpeg 的源码,一边照着 FFmpeg 的头文件上的提示进行尝试,成功编写出了一个视频解码器的例子。为了方便以后使用,我把这个例子做了个包装,自己设计了自己方便使用的 API 来进行多媒体文件的解码。

libsoundio 不能顺利参与链接,因为缺符号

最初考虑使用 libsoundio 的时候,是因为我想偷懒,我希望能使用一个统一的接口来播放音频,这里面其中一个原因是因为 Linux/Unix 的音频输出标准有很多套,有多种API和驱动实现,根据 Linux/Unix 发行版的不同和运行平台的不同,无法确定目标机器具体使用的是哪一套,要做跨平台就得都实现一遍,而 libsoundio 则帮我把这些都实现了出来,并统一了接口。另一个原因是因为我不太想去用微软的 waveOutXxxx 和 WASAPI,其中前者已经过时,虽说不影响我现在的要求,但是我要是使用了它,我会挨骂。而另一个音频接口 WASAPI 则是因为它使用 COM 接口,而用C语言写 COM 应用十分费劲,而C++写COM的话,味道太冲。

在编译时,链接器报CLSID_MMDeviceEnumeratorIID_IMMDeviceEnumeratorIID_IMMNotificationClient等若干符号未找到。我去,这COM味真冲。偷懒失败。观察了 libsoundio 的源码后,发现其中的 wasapi.c 在以C++方式编译的时候,应该是自己会定义这几个CLSID和IID的,但C方式编译的话大概要从外部引用什么lib。我直接把它代码里的这些IID值复制到自己的代码里,导出这些符号给链接器链接,然后成功解决问题。

这显然不是最优雅的解决方式,不如说是十分暴力的方式,但总之我给 libsoundio 又包了一层自己的 API 以便于以后使用。这大抵是最管用的方式,而且可以不用去找那个 lib 文件了。

VS2019 缺乏若干 C11 头文件

谷歌了一番后,我找到了zenny-chen的simple-stdatomic-for-VS-Clang,然后创建了一个项目来使用。

结果发现这里面的原子加法、原子减法竟然没有原子性。这是在之后测试出来的。只好自己修改代码,保证至少对atomic_long类型的变量的操作具有原子性。

因为不是直接 fork 过来的,而且因为偷懒,没有做成子模块(如果要做的话,那我还得完善更多东西比如thrd_current()的实现,它这一个整体才算 consistent)于是并没有给zenny提交pull-request。希望他看到以后如果感兴趣的话可以交流一下心得。

尝试在 Debian 里编译,发现 Debian 的 FFmpeg 使用的依然是旧 API

再次遇到 FFmpeg 的 API 问题的时候,我感到我的头嗡嗡地懵。

只好写了一堆 #ifdef 条件编译,根据工程配置来选择使用新的或者旧的 API。

不过,我在 Debian 里装的这个 FFmpeg 的库,可以成功用于编译官网的旧 Examples

FFmpeg 的解码的速度比我想的低很多很多

我原先预想的情况是:
假设我只想做一个视频播放器,我用 FFmpeg 的 libavformat 和 libavcodec 解码出视频帧,然后直接把视频帧的内容 BitBlt 到窗口上,没有任何额外处理,解码的速度必定超过视频本身的播放速度,也就是说,它至少可以按照原视频的帧数来播放视频,我如果不设置播放速率的限制,它的效果应该像快进一样。并且,视频文件里的音视频帧应当相对均匀穿插。

我实际遇到的情况是:
假设我只想做一个视频播放器,我用 FFmpeg 的 libavformat 和 libavcodec 解码出视频帧,然后直接把视频帧的内容 BitBlt 到窗口上,没有任何额外处理,解码的速度有时候是低于视频本身的播放速度的。也就是说,就连实现最基本的“按需解码后立即播放”的逻辑,竟然都会出现视频掉帧的情况。并且,音视频帧的分布,有时候十分不均匀,一个mp4多媒体文件,很多时候会出现集中大量存储音频,集中大量存储视频的情况,尤其是视频开头的部分。最离谱的情况下,它可能连续解出了几十秒的视频帧后,才开始解出音频帧。

所以我需要设计一套缓存系统,在正式播放前,把音频帧和视频帧按照一定的量预先解出,缓存起来,然后在正式播放的时候,将缓存的音视频帧按照播放的时间戳来输出。

并且考虑到视频图像转文本的过程本身需要消耗相当多的算力,我决定尝试使用 OpenMP 将解码的过程和视频图像转文本的过程拆分为不同的任务进行多线程处理。

解码出来的视频帧并不都可以获取到正确的时间戳

这个问题主要在我尝试理解 FFmpeg 的 API 的时候,从其中的注释文本里看得出来。旧 API 里提供了一个函数可以获取“最确信时间戳”,而新API移除了这个函数。所以我使用AVFrame结构体里的“最确信时间戳”的变量。

经过一些测试,确实有的视频获取不到正确的时间戳,按照 FFmpeg 的文档、使用其API和宏来计算时间戳的话,可能会出现以离谱的速度进行快放的现像。但我如果使用 FFplay 去播放这样的视频,则它可以按照正确的速率进行播放。这十分奇怪,因为在 FFmpeg 的API给出的“最确信时间戳”并不正确的情况下,FFplay 却能正确播放。也许是因为 FFplay 使用了其中解码器自带的播放功能罢。

那我选择无视这一问题,因为我其实也很难找出几个不能正确定位时间戳的视频文件,我能找到的也就是几个祖传上古低清VCD动画片转录后得到的mp4文件,分辨率才320x240。

多线程处理的过程中,关键的任务一直得不到执行的机会

我使用 OpenMP 的 #pragma omp parallel for 语法来把我的任务以多线程的形式分发给线程池进行处理。而任务主要是三个,一个是将视频解码出帧的任务,一个是把视频帧转换为控制台文本和颜色值的任务,还有一个是把转换得到的控制台文本和颜色值输出到控制台的任务。

在设计这三个任务的执行过程的时候,我假定视频解码出帧的任务和输出控制台的任务常驻执行,而其它任务则根据 OpenMP 提供的线程池的数量并发执行。我以为如果我在常驻执行的任务里使用自旋锁来等待其它任务的执行,那么当其它任务被执行后,我就可以进入自旋锁对执行后的结果进行处理,以此保证我的任务得到常驻。

然而在我调试时我观察到的实际情况则是:一旦我进入了自旋锁的等待过程,我的这个任务对应的线程就进入了暂停状态,然后 OpenMP 便开始调度处理器去执行别的任务,只要新任务一直处于忙碌的状态,旧的任务就得不到执行的机会,我那个本应常驻的任务就会一直处于冻结状态,而非常驻的任务则进入了死锁状态,除非CPU线程数超出了任务总数。

随后,我放弃了常驻任务的逻辑。重新设计为:不使用常驻任务的逻辑,每个任务只做一件事,比如视频解码出帧的任务只解码一帧、视频输出的任务只输出一帧等。然后在线程池里,优先判断是否需要解码视频、是否需要输出控制台,然后才判断是否需要把解码出来的视频转换为控制台文本和颜色值。

经过这样调整后,我解决了死锁问题,实现了多线程的解码、转换、输出过程。

输出控制台的速度太低,画面一旦变得复杂就开始掉帧

在最初考虑输出控制台的时候,为了跨平台,我把 Windows 那套和 Linux/Unix 那套,使用自己的函数包了一层,做一个统一的接口来实现以下的几个功能:

  • 设置当前的输出颜色
  • 以当前颜色输出字符串
  • 设置输出位置,即光标坐标

其中,在 Windows 上,我使用 SetConsoleTextAttribute() 来设置当前的控制台输出颜色,使用 fputs() 将字符串输出到控制台,使用 SetConsoleCursorPosition() 设置光标位置。
在 Linux/Unix 上,我通过输出转义字符串来设置输出颜色和光标位置,并且我做了一个 buffer 在最终输出全帧前,所有的输出都是先输出到 buffer 里,然后再把 buffer 用 fputs() 一次输出。

在 Windows 上,如果一个视频帧画面足够混乱、颜色足够丰富或者熵足够高,这些API的调用就会变得十分频繁,然后就会导致画面输出的效率急剧降低。多年前的林纳斯·脱袜子就提到过要合理使用缓存。

于是我在 Windows 上,改用 WriteConsoleOutputW() 来进行一次性的输出。并在我一个熟悉嵌入式的东北朋友的帮助下,了解到 Windows 的控制台可以设置终端模式支持虚拟终端,使其能像 Linux 的终端那样接受转义字符。经过测试,Windows 11 默认就是支持虚拟终端的模式,而 Windows 7 则需要调用 API 去开启这个功能,并且不一定能开启成功。那我就使用两套方案:如果开启成功,走 Linux/Unix 那一套;否则走 WriteConsoleOutputW() 这一套。

通过不断地填坑,我做出了最初的版本后,感到不满足

各种问题都解决后,虽说能用百来个ASCII字符组合图像画面输出视频了,但我觉得我其实应该试试使用 GB2312 的全部汉字(约六千余)。只使用ASCII的话,不够好玩。虽说目前可以得到相对流畅的画面,但其实整个工程还有优化的空间。

我想试试创建离屏的 OpenGL 上下文,使用 GPU 进行加速的处理,看看这样的话能不能做到流畅支持 GB2312 全部汉字参与的视频渲染了。

创建 OpenGL 上下文的过程无论是在 Windows 还是在 Linux 都离不开创建隐藏的窗体和使用 FBO 的过程

只有使用这种方式才能创建出真正有硬件 GPU 支持的 OpenGL 上下文,并能使用高级的 OpenGL 接口,比如现代化的 OpenGL 3.3。
Windows 上十分好办。甚至都不用给这个离屏窗口处理消息,因为它根本就不会产生什么消息。

我以为 X Client 的编写就像纯C写 Win32 App 一样简单,结果一万个 Segmentation Fault 把我整清醒了

我终于明白为什么都说 Windows 的图形界面 API 成熟了。因为你照着文档说的去设计 Windows 程序,是不会有问题的。但 Xorg 那一套就完全不一样,官方的例子都能出现 Segmentation Fault,并且你以为函数失败了它给你返回一个 false 或者 0,但其实不。有的函数失败了直接崩。

于是我选择了 GLFW,让它帮我完成窗口的创建和管理那一套。幸运的是,使用 GLFW 创建隐藏窗口并获取离屏 OpenGL 上下文的过程很顺利。

我知道 OpenGL 的接口效率不行,因此想多线程使用 OpenGL 的接口,结果把显卡驱动整崩了

我使用多个线程同时设置一个 OpenGL 上下文来用,结果不行,当一个线程抢了 OpenGL 上下文后,原先拿着 OpenGL 上下文的线程,其 OpenGL 上下文就自动变为 NULL 了。

因为每次调试的时候,显卡驱动都要崩一下,然后整个屏幕黑屏,再重新亮起,提示“Windows 已经重置显卡驱动”。所以调试的过程十分艰难。

最终我干脆给每个工作线程都创建一个它专属的 OpenGL 上下文,然后各用各的上下文,不要争抢。

成功实现 GPU 加速计算、筛选 GB2312 文本,然后我发现好像没加速多少,不如说速度上和 CPU 的实现持平了

这个倒是在意料之内。我对纹理采样的次数越多,总的计算性能就越低,硬件上大体上是遇到了 GRAM IO 的性能瓶颈。

但这个是不太能避免的问题,本身就有这么大的采样次数的需求。我调整了处理的批次和复杂度,感觉改善不大。

最终我让 CPU 和 GPU 一起算。成功做到两者利用率拉满的同时,控制台能流畅输出图像画面。

在使用 OBS 录屏的时候,一开始渲染得好好的,后来突然严重卡顿

调试的时候看到 CPU 使用率和 GPU 使用率都在降低,但实际的性能则非常低,连调试器都不怎么理我了。我怀疑内存满了。

打开任务管理器一看,内存不仅满了,显存也满了。显卡驱动忙着用内存去交换显存里的内容,而操作系统则忙着用内存里的内容去交换pagefile.sys里的内容。

但实际上可以看到 OBS 并没有占用多少内存,反倒是我的程序占用了 70 GB 的 RAM 和 6 GB 的 GRAM。我打赌我大概明白发生什么了,顿时脸上的表情变得像“首”字一样。

好家伙。只能重启机器。

后面经过调试,发现确实和我想的一样,由于mp4文件里视频帧和音频帧的分布太过于不均匀,这些被大量占用的 RAM 和 GRAM 的内容都是缓存的视频帧,以及处理好了的控制台文本,待播放的内容。

我在代码里设置了对缓存空间大小的限制,使其顶多能使用 1 GB 的 RAM 来缓存图像。问题成功解决,最大的内存占用不会超过 1 GB 了。

因为忘了设计跳帧的逻辑,所以一旦在某一两帧的解码和处理上耽搁了整体的速度,就会导致音视频不同轨

设计了跳帧逻辑后,我解决了这个问题。任何时候发生卡顿都不会影响我的播放器的音视频同步。

说起来,PC版腾讯QQ自带的“腾讯视频”播放器虽说同样也是 FFmpeg 套壳,但它既没有多线程的解码和播放过程(或者就是有、但是实现非常拉跨,性能还比不过我的 FontVideo),又没有跳帧逻辑,而且在性能较低的 i5 机器上会出现大量的内存占用,我打赌它的作者根本就没怎么调试过它,大概该作者以为所有人的PC性能都和他一样,是 128 GB RAM、8 核心 16 线程的 i9 罢。俺寻思随便网上抄个靠谱一点的开源播放器源码都比“腾讯视频”播放器强啊。

设计了跳帧逻辑后,使用 OBS 录屏的时候它疯狂跳帧!

说是“疯狂跳帧”,实际上是它一帧也没有输出。经过调试后,发现是几乎所有的帧都被跳了。

遇到这个问题后,我给它设置了一个输出帧图像序列的功能,使它除了前台的播放视频的功能以外,还能有一个后台的输出视频的功能。这样我也不需要用 OBS 录屏了。

但是我懒得写 FFmpeg 编码器了。于是我生成 bmp 序列和音轨,然后用 FFmpeg 命令行来生成 mp4 视频。

这样就解决了问题。

在 WSL/Linux 下,由于 GLX 的关系,GPU 计算过程效率奇低

当我编译为 Linux ELF 后,我发现不开 GPU 加速反而能进行更流畅优质的渲染过程。

经过一系列调试和测速,我可以判断出在流式传输视频帧、取回 FrameBuffer 的过程中,在 WSL/Linux(Debian物理机) 环境下这个传输的过程比在 Windows 下慢五倍。

原来 Linux 对游戏这么不友好啊。虽说 WSLg 用的是 Wayland,然后 Xorg 这一块用的是 XWayland,但是这个性能也同样慢五倍。而我如果使用 MobaXTerm 的 X Server 的话,则会更慢。

大概算是大功告成了

我的 FontVideo 最终既可以进行流畅的音视频转控制台文本输出,又可以用作一个编码器、将视频转换为使用文字显示的视频画面进行图像的表现。

可能会有人问:“干嘛用 OpenGL 做 GPU 运算啊?你应该用CUDA” 或者 “在遇到离屏渲染计算的时候你不如用 OpenCL” 或者 “你这渲染效果不咋地啊”

对于诸如此类的提问,我表示很高兴你能关注我的这个项目,但如果你觉得应该这样做,请你先自己做出来试试,看会不会真的就比我用 OpenGL 的方案更好。如果你在尝试的过程中遇到了问题,也可以分享出来方便大家学习。

总结

  • FFmpeg 的 API 比较难用,并且可能会在小版本的变动上增减API引起兼容性的问题,最好确定了版本再用。
  • FFmpeg 的解码过程需要消耗算力,有时候比视频的播放速度还慢,因此需要设计缓存系统和跳帧逻辑,并且需要注意缓存系统的资源占用情况。
  • 避免在 OpenMP 里,使用自己的锁逻辑、等待逻辑或者信号逻辑。
  • 跨平台往往都是伪需求。

评分

参与人数 1威望 +10 宅币 +50 贡献 +10 收起 理由
PASSOK + 10 + 50 + 10 赞!

查看全部评分

回复

使用道具 举报

0

主题

13

帖子

4760

积分

用户组: 技术宅的结界VIP成员

UID
2021
精华
0
威望
2 点
宅币
4743 个
贡献
0 次
宅之契约
0 份
在线时间
10 小时
注册时间
2016-10-21
发表于 2022-2-4 18:05:38 | 显示全部楼层
我在女神下面给你鼓掌,卧槽发不鸟鼓掌表情

话说,你倒是发张图鸭,卧槽发不鸟笑哭表情

4

主题

38

帖子

625

积分

用户组: 大·技术宅

UID
7138
精华
0
威望
17 点
宅币
538 个
贡献
15 次
宅之契约
0 份
在线时间
60 小时
注册时间
2021-5-11
发表于 2022-2-9 16:28:00 | 显示全部楼层
跨平台往往都是伪需求。此话也不尽然,只是有时候没有轮子,还不如另起炉灶来的轻便.

1093

主题

2664

帖子

7万

积分

用户组: 管理员

一只技术宅

UID
1
精华
238
威望
606 点
宅币
22201 个
贡献
46077 次
宅之契约
0 份
在线时间
2130 小时
注册时间
2014-1-26
 楼主| 发表于 2022-2-13 06:02:58 | 显示全部楼层
WeaponJang 发表于 2022-2-9 16:28
跨平台往往都是伪需求。此话也不尽然,只是有时候没有轮子,还不如另起炉灶来的轻便. ...

确实,很多时候使用已有的轮子本身就意味着要承担其具有隐藏低级BUG的风险,比如zstd这玩意儿。

4

主题

38

帖子

625

积分

用户组: 大·技术宅

UID
7138
精华
0
威望
17 点
宅币
538 个
贡献
15 次
宅之契约
0 份
在线时间
60 小时
注册时间
2021-5-11
发表于 2022-2-21 23:01:18 | 显示全部楼层
0xAA55 发表于 2022-2-13 06:02
确实,很多时候使用已有的轮子本身就意味着要承担其具有隐藏低级BUG的风险,比如zstd这玩意儿。 ...

为了跨平台而付出的艰辛可能不如重新选型来的快捷高效.比如,VFB如果要实现一套技术栈跨平台,那么工程量是巨大的,Windows版的使用体验也会大打折扣.会导致每个系统都照顾到了,但是都没照顾好

1

主题

115

帖子

130

积分

用户组: 小·技术宅

UID
7535
精华
0
威望
0 点
宅币
15 个
贡献
0 次
宅之契约
0 份
在线时间
9 小时
注册时间
2021-10-16
发表于 2022-5-13 15:28:36 | 显示全部楼层

非常感谢~~支持~~~

本版积分规则

QQ|申请友链||Archiver|手机版|小黑屋|技术宅的结界 ( 滇ICP备16008837号 )|网站地图

GMT+8, 2022-7-6 00:13 , Processed in 0.043303 second(s), 35 queries , Gzip On.

Powered by Discuz! X3.4

Copyright © 2001-2021, Tencent Cloud.

快速回复 返回顶部 返回列表