B站云养鱼–贫穷定制
1. 我买了一个缸
过年前散步,正好路过花鸟市场。本来打算买些花草,走着走着就变看鱼了。心血来潮,赶紧研究了下养鱼入门知识,并开了个一个草缸。
新手的缸总是这么枯燥。
缸还在路上的时候我就突然想开个直播,这么有趣的事情应该和大家分享。顺便赚点饲料钱,说不定还能靠直播当上 CEO 迎娶白富美。
如此一来原始需求就定下了,需要做一个 24H 鱼缸直播方案出来。
2. 直播方案比较及限制条件
2.1 方案比较及场景限制
项目是为创造独特产品、服务或成果而进行的临时性工作。1 这个直播需求与真人 UP 主的直播由于工作方式不同,因此会导致环境差异较大。
- 一般直播时,直播主体主播唯一受众为用户。无论是在线聊天、游戏还是云画画,直播的主机即为工作的主机,在一定时间段内,主播会面对主机进行工作,并通过这台主机直播出去,工作内容可以说就是直播内容;
- 本次直播时,直播主体鱼缸有两个受众,一个是直播用户,另一个是现场观众我我我!!!。对鱼缸进行24H 直播时不能让主机在空间中明显可见,这会阻碍现场观众视线;同时当我需要在一台主机上做直播无关工作,甚至开关机时不能影响直播进行。因此需要在隐蔽角落放一个独立主机进行直播。
Usecase:
graph LR
subgraph 工作室
主播 --do--> 工作Process
主播 --被摄-->直播Process
subgraph 主机
工作Process --被摄--> 直播Process
end
end
直播Process --推流--> B站
graph LR
subgraph Home
鱼缸 --被摄--> 直播主机
鱼缸 --观赏--> 我
我--工作-->工作主机
end
直播主机 --推流--> B站
结合家里设备具体差异如下:
一般 UP 主 | 本次直播需求 | |
---|---|---|
时间 | 限时直播 | 24H 直播 |
主机 | 无需独立主机 | 需要独立主机 |
主机可见 | 需操作,可见 | 无需操作,不可见 |
操作系统 | Windows | Linux |
摄像头 | USB 摄像头 | 网络摄像头 |
2.2 硬件限制条件分析
由于穷,本次方案只能在家里现有设备基础上进行设计。
目前可用设备:
- 蜗牛星际
- AreYouOK 智能摄像机(第三代)
- Raspberry Pi Zero W
蜗牛星际是咸鱼上 300 块买来的矿难机,4G 内存,搭载 J1900 CPU,四盘位机箱,真香 😙。被我装上了 manjaro 做NAS 用,用来做下载、时间胶囊、物联网、数据可视化等功能。蜗牛星际被我藏在鞋柜里,满足了本次直播方案主机隐蔽的需求。
AreYouOK 智能摄像机(第三代)是半年前买的,也是在研究直播系统的时候才了解了他的版本。AreYouOK 智能摄像机第二代是绝对的香,可以修改固件增加 rtsp 功能,第三代就不行了,试了很长时间都没法打开 rtsp,同时第三代在图像质量上也比第二代下降了很多。由于不能用 rtsp,我会在下文讲解别的方法。
Raspberry Pi Zero W,做备选方案,这是我买来黑 USB 和 wifi玩的设备,他的优点在于蓝牙 wifi 都在一片上集成了,体积小,可以变成 USB 棒。
下图是我自己的**Raspberry Pi Zero W*,加上了E-ink 屏幕和 USB 转换头。玩法非常多,比如模拟成两个设备,一个优盘一个键盘,就可以在插入PC 后通过蓝牙或 wifi 输入相应指令了;也可以通上电连上 wifi,做中间人攻击;其他的由于体积小,作为嵌入式主控也是不错的选择。
3. 整体方案设计
3.1 整体方案
整体方案采用网络摄像机+NAS 推流的形式展开。OBS 获取 rtsp 流后,再通过 rtmp 丢到 B 站即可,轻松加愉快。
不,事实并不是这样的。生活往往并没有这么容易😢 ,很容易接受现实的吊打。但我们还是先将我们的部署图画出来。
graph LR
subgraph 鞋柜
NAS
end
subgraph 客厅
鱼缸 -- 拍摄 --> 摄像机
摄像机--传输视频-->NAS
end
NAS --推流--> B站
3.2 视频源
视频来源由于贫穷,首先选择现有的AreYouOK 智能摄像机。他是没有 rtsp、onvif 的辣鸡摄像机,但我们在 App 设置里面可以看到它支持 NAS,可以实时将视频保存到 NAS 上。我本以为这是一个黑科技,但万万没想到…
一定是我打开方式错误,我换个方式重新打开一下:
➜ xiaomi_camera_videos tree
.
├── 04cf8cfe7133
│ ├── 2020031908
│ │ ├── 16M12S_1584576972.mp4
│ │ ├── 17M10S_1584577030.mp4
│ │ ├── 18M10S_1584577090.mp4
│ │ ├── 19M10S_1584577150.mp4
│ │ ├── 20M10S_1584577210.mp4
│ │ ├── 21M10S_1584577270.mp4
│ │ ├── 22M10S_1584577330.mp4
...
叮 本项目由于不可抗力终止。。。
。
。
。
。
不 我当然是不会放弃的!不就是分钟级视频文件么。一定能解决!如果一些这么顺利,就没必要做脚本了。
3.2.1 视频播放+OBS
通常主播在直播的时候会用到 OBS2,一款开源直播软件,B 站直播姬就是基于 OBS 制作的。它支持 macOS、linux和windows三个平台,可以在单个窗口添加不同的源合成一个画面,同时支持多场景切换。
OBS 在不同场景的直播中都能发挥很大作用。画面来源支持非常多,rtsp、窗口捕获、桌面捕获、图像、视频、浏览器、音频、文字等等。有兴趣的可以下载一个,并参考 WiKI。
OBS 是无法播放文件夹的,因此考虑采用视频播放器+OBS 的窗口捕获,获取视频流。
最终由于 CPU 100% Good Game。
同时也意识到了蜗牛星际 J1900 CPU 在视频编解码上的无力,无法使用 OBS 这样复杂的直播软件。
3.2.2 ffmpeg 合成视频
ffmpeg 是一个非常棒的软件,他可以将播放列表作为Input 使用。因此本方案最终使用了ffmpeg 作为视频合成及推流工具。
3.3 视频目的地
我们通过 ffmpeg 处理视频源,自然也通过 ffmpeg 来推流。
4. 背景知识
为了后续更好理解整套方案,这边将对一些背景知识做一些简单的介绍
4.1 流媒体
对于媒体编码、封装格式和传输协议本身其实和我们要做的方案关联较小,仅在定位一些问题时可能会用到比较深入的知识。
在这里我们只需要简单的做些了解。
4.1.1 视频编码 H.264
H.264,又称为MPEG-4第10部分,高级视频编码(英语:MPEG-4 Part 10, Advanced Video Coding,缩写为MPEG-4 AVC)是一种面向块,基于运动补偿的视频编码标准 。到2014年,它已经成为高精度视频录制、压缩和发布的最常用格式之一。第一版标准的最终草案于2003年5月完成。
—— WikiPedia H.264
H.264我们首先需要关注他是一种视频编码,他会对原始视频做大量压缩处理来减少视频的体积,其次是他有三种帧:
- I帧:关键帧,采用帧内压缩技术。
- P帧:向前参考帧,在压缩时,只参考前面已经处理的帧。采用帧音压缩技术。
- B帧:双向参考帧,在压缩时,它即参考前而的帧,又参考它后面的帧。采用帧间压缩技术。
缺少某一 I 帧时,画面会变得非常诡异,大家在看D 版电影时也经常会看到各种灵异的现象就是缺帧导致的。
4.1.2 媒体封装格式 MP4
MP4或称**MPEG-4第14部分**(英语:MPEG-4 Part 14)是一种标准的数字多媒体容器格式。MPEG-4第14部分的扩展名为**.mp4**,以存储数字音频及数字视频为主,但也可以存储字幕和静止图像。因其可容纳支持比特流的视频流(如高级视频编码),MP4可以在网络传输时使用流式传输。
—— WikiPedia MP4
在上述 WikiPedia 的描述中,MP4 是一种容器,他会在里面存放音频、视频、字幕或图像,都是以通道来表示一路媒体的。
4.1.3 流媒体协议 RTMP
B 站采用 RTMP 方式推流,这是一种媒体传输协议。
实时消息协议(英语:Real-Time Messaging Protocol,缩写RTMP)也称实时消息传输协议,是最初由Macromedia为通过互联网在Flash播放器与一个服务器之间传输流媒体音频、视频和数据而开发的一个专有协议。Macromedia后被Adobe Systems收购,该协议也已发布了不完整的规范供公众使用。
RTMP协议有许多变种:
- 默认使用TCP端口1935的纯粹(plain)协议。
- RTMPS,通过一个TLS/SSL连接传输RTMP。
- RTMPE,使用Adobe自有安全机制加密的RTMP。虽然实现的细节为专>有,但该机制使用行业标准的密码学原函数。[1]
- RTMPT,用HTTP封装以穿>透防火墙。RTMPT通常在TCP端口80和443>上使用明文请求来绕过大多数的公司流量过滤。封装的会话中可能携带纯粹的>RTMP、RTMPS或RTMPE数据包。
- RTMFP, 使用UDP而非TCP的RTMP,取代RTMP Chunk Stream。Adobe Systems>开发了安全的实时媒体流协议包,可以让最终用>户直接地相互连接(P2P)。
虽然RTMP的主要动机是成为一个播放Flash视频的协议,但它也用于其他一些应用程序,如Adobe LiveCycle Data Services ES。
—— WikiPedia 实时消息协议
4.1.4 以上三者之间的关系
当我们有一段原始视频时,首先我们会将其转成 H.264编码,这会减少很多空间。其次我们会加上音轨,这样就组成了有一个视频通道,一个音频通道的多媒体组合。
我们可以看一下原始的电影胶片,他包含了多条轨道,杜比左右声道及其他音轨就和视频轨道并列存放着,这和 MP4 中的封装是差不多的。
对于流媒体传输协议,里面包含的也是 H.264编码,可以将其看成将影片切成一段段发送。
4.2 ffmpeg
A complete, cross-platform solution to record, convert and stream audio and video.
—— FFmpeg
它有两种使用方式:
- 命令行:直接运行 ffmpeg 程序,并将命令作为运行参数传入即可;
- 库:通过库来使用灵ongn
我们在这个项目中主要采用命令行模式来调用 ffmpeg,这对一个小项目而言非常快速。
5. 功能设计
5.1 视频列表
对于AreYouOK 智能摄像机输出到 NAS 上的文件来看,他是有规律的,每分钟保存一次。
➜ xiaomi_camera_videos tree
.
├── 04cf8cfe7133
│ ├── 2020031908
│ │ ├── 16M12S_1584576972.mp4
│ │ ├── 17M10S_1584577030.mp4
│ │ ├── 18M10S_1584577090.mp4
│ │ ├── 19M10S_1584577150.mp4
│ │ ├── 20M10S_1584577210.mp4
│ │ ├── 21M10S_1584577270.mp4
│ │ ├── 22M10S_1584577330.mp4
...
他的格式为/{年月日时}/{分M秒S_时间戳}.mp4
,根据文件名我们就能找到每个时刻的视频了。方案大致有两种:
- 寻找上一分钟的文件名,并进行播放;
- 寻找前几分钟的文件名,组合后进行播放。
两种方式各有利弊,第一个在设备时间无法同步时会造成找不到视频的现象,第二种方式会产生非常大的延时。
但实际上对于直播用户来说,直播主体并不会和用户进行互动,因此延时不会造成实质的影响。最终我采用的是第二种方式。
生成播放列表函数入参有两个,一个是持续时间,第二个是列表结束时间。函数中首先过滤了文件夹名,然后通过文件名末尾的时间戳比对出需要播放的文件,排序后写入播放列表中。同时需返回播放个数。
def make_video_list(during: int, endtime: datetime.datetime = datetime.datetime.now()):
starttime = endtime - datetime.timedelta(minutes=during)
print("======Make Video List======")
print("PlayList from {st} to {et}".format(st=starttime, et=endtime))
def dir_filter(dir: str) -> bool:
try:
dir_time = int(dir) # strptime(dir, "%Y%m%d%H")
if dir_time >= int(starttime.strftime("%Y%m%d%H")) and dir_time <= int(
endtime.strftime("%Y%m%d%H")
):
return True
else:
return False
except Exception as a:
print("make_video_list dir_filter Error ", a)
return False
# filter play list
file_list = []
for dir in filter(dir_filter, os.listdir(CAMERA_PATH)):
for file in os.listdir(CAMERA_PATH + dir):
time = datetime.datetime.fromtimestamp(int(file[-14:-4]))
print("File : ", dir, file, end="")
if time >= starttime and time <= endtime:
file_list.append(CAMERA_PATH + dir + "/" + file)
print(" Will Play")
print(" ")
# write video playlist
with open(VIDEO_PLAYLIST, "w") as f:
f.writelines(
map(
lambda path: "file '"
+ path.replace(":", "\\:").replace("'", "\\\\\\\\\\\\'")
+ "'\n",
sorted(file_list),
)
)
print("======Make Video List End======")
return len(file_list)
5.2 音乐播放列表
合成视频的同时我们得把声音给删了,不然直播出奇怪的声音是会被封号的!!!
这边通过对音乐文件夹随机排序来生成,这个时间将远大于视频播放的时间,因此我们需要有音乐的时间记录,第二段视频播放时音乐从上次播放的位置插进去。
def make_music_list() -> int:
global g_music_play_time
if g_music_play_time > MUSIC_MAX_TIME:
print("Will Create New Audio PlayList")
g_music_play_time = 1
# remake music play list
music_list = list(
filter(
lambda file: file[-4:] == ".mp3",
map(lambda filename: MUSIC_PATH + filename, os.listdir(MUSIC_PATH)),
)
)
random.shuffle(music_list)
with open(AUDIO_PLAYLIST, "w") as f:
f.writelines(
map(
lambda path: "file '"
+ path.replace(":", "\\:").replace("'", "\\\\\\\\\\\\'")
+ "'\n",
music_list,
)
)
这边由于音乐是从我的播放器里拷贝的,额外过滤了文件类型,只采用 mp3 文件。
同样写入文件中,此处由于音乐名称的复杂性,额外做了 escape。
5.3 ffmpeg 命令生成
有了以上两个函数,基本功能基本完成,就差推流了。
推流采用 ffmpeg,比较复杂,先上代码再慢慢讲解:
# 生成命令行
ffinput = 'ffmpeg -y -re -loglevel warning -f concat -safe 0 -i "{VIDEO_PLAYLIST}" -ss {MUSIC_START_TIME} -f concat -safe 0 -i "{AUDIO_PLAYLIST}" -c copy -shortest '.format(
VIDEO_PLAYLIST=VIDEO_PLAYLIST,
AUDIO_PLAYLIST=AUDIO_PLAYLIST,
MUSIC_START_TIME=get_music_start_time(),
)
ffoutput = '-f flv "{RTMP_ADDR}"'.format(RTMP_ADDR=RTMP_ADDR)
# ffoutput = "re.mp4"
cmd = ffinput + ffoutput
这边采用了播放列表作为源,我们可以看-f concat -safe 0 -i "{VIDEO_PLAYLIST}"
,
- -f: fmt (input/output) 强制定义格式
- concat: 连接,一种 demuxer,将源连接起来
- -safe 0:concat 时不在意文件名
- -i:input
我们将播放列表作为 concat 的输入,最后 concat 作为一个完整输入存在。
音频输入也同样,这边增加了一个 -ss
是seek到这个时间作为开始的意思,音频播放列表时间太长了,为了每次推流没有断层的感觉,会每次累计播放时间,然后通过-ss
指定播放位置。-ss
是需要特殊格式的,因此我们通过函数生成。
def get_music_start_time():
global g_music_play_time
return "{0:02}:{1:02}:{2:02}".format(
int(g_music_play_time // 3600),
int((g_music_play_time % 3600) // 60),
int(g_music_play_time % 60),
)
我们再来看看ffinput
最前一段和最后一段:
- -y : 强制覆盖写入 output;
- -re : 读取input 按实际帧率来,直播需要用指令,不然会以最快速度处理数据;
- -loglevel warning:日志级别
- -c copy:不处理媒体编码,直接从 input 拷贝到 output,减少编解码的性能需求,当然这个同时我们没法对视频进行处理。
- -shortest: 输出时长按最短的轨道来。
ffmpeg 在轨道重复时(例如视频音轨和音乐),会自动采用后输入的轨道。如果需要自定义就需要在指令上进行标示了。
接下来我们看看ffoutput
, 非常简单 -f flv "{RTMP_ADDR}"
制定了输出格式为 flv,并给了相应的 rtmp 地址。
5.4 执行 ffmpeg 命令
ffmpeg 是系统二进制程序,因此我们需要在系统上调用它,通过 Popen在shell 环境下运行这条命令即可,同时等待结束,并接收输入输出。
# 运行
cmd_proc = subprocess.Popen(
cmd,
shell=True,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
cmd_proc.wait()
outs, errs = cmd_proc.communicate(timeout=1)
到这边一次几分钟的推流就完成了,我们循环这个动作就能持续不断的将新录制的视频推出去。
5.5 半夜回放机制
除了白天鱼缸开灯期间的直播,我还期望在未开灯的时候也进行直播。当然我会关闭摄像头,防止在鱼缸背景较亮的情况下出现奇怪的反光,当然,半夜摄像头根本看不到什么,夜晚摄像头红外会被鱼缸玻璃反光形成强烈的光斑,无法观赏。
我们需要在水草灯未工作期间进行直播就只能采用回放了,另外由于过年期间我个人的作息不定,只能通过软件自动检测是否工作了。
这边的机制非常简单,当生成视频播放列表返回 0 时就可以进行回放了。此时我们将时间回退到 8~20 点之间,随机一个时间段,播放时间=当前时间-(当前小时-随机小时数)
,采用这个公式,当天关闭摄像头后可以播放指定时间段随机的时间点,但我们会发现超过半夜十二点后变为了 1 点-16 点
这样诡异的时间,反而变为了播放未来十五个小时后的视频。因此我们需要判断一旦过了半夜,相减的小时数就要多加 24h。
tmp_endtime = play_endtime - datetime.timedelta(
hours=(
play_endtime.hour
- random.randint(*CAMERA_WORKTIME)
+ (24 if play_endtime.hour < 12 else 0)
)
)
这样就能24H 进行播放了,每次播放一小时的视频(看之前的函数,可以传入时长)。
但如果随机 20 次还没视频的话,就会直接播放当前时间前 24H 所有视频了。还是没有视频就 sleep 一小时,当然到了这地步 B 站早就自动关停直播室了。
5.6 直播退出机制
在实际调试过程中会发现很恼火的一点是 Ctrl+C 完全没有反应。由于展示在 shell 上的是 ffmpeg 进程,Ctrl+C只会退出 ffmpeg,而不会退出 python 脚本,此时他会在人类无法反映的时间内重启 ffmpeg。除了关闭 Shell,没什么更快捷的办法了。
另外在 kill 时也会有需要先 kill 脚本,再 kill ffmpeg。
后面很无奈,只能做一个直播退出机制了。将之前简单的 os.System
函数替换为了现在可控的subprocess.Popen
并增加了信号捕获。
def stop(signal, stack):
# TODO: 此处必然有时序问题,暂不处理
nonlocal run
run = False
cmd_proc.kill()
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
收到信号,暂停循环,并杀死 ffmpeg。
5.7 深夜直播暂停事件
过年时每天起来会发现我的直播停了。当然我也懒得看日志,因为这件事太迷了,看日志也得翻好久。
同上,后面很无奈,还是慢慢翻了日志。发现 ffmpeg 在断网后会卡死很长一段时间,需要二十多分钟才会退出。这个时间足够 B 站把我踢下线了。而断网的原因是我在路由器上设定了每天半夜清理缓存。(这竟然会断网,辣鸡 tplink。)
然后就新增了一个进程,用于检测网络是否正常,这个进程是每五秒执行系统命令 ping 一次114,记录flag,并在恢复时 kill一次ffmpeg。
def ping():
nonlocal run
global cmd_proc
pre_state = True
while run:
state = True
outs = subprocess.Popen(
"ping -c 1 -w 1 114.114.114.114",
shell=True,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
encoding="utf8",
).stdout.readlines()
state = 0 == outs[-1].find('rtt')
if not state:
print("Net broken!")
if False == pre_state and True == state:
print("Net resumed, kill cmd")
if None != cmd_proc:
print("Net resumed, kill cmd!!")
cmd_proc.kill()
pre_state = state
sys.stdout.flush()
time.sleep(5)
multiprocessing.Value
ping_subproc = multiprocessing.Process(target=ping)
ping_subproc.start()
至于为什么上面写的是进程…那是 python 解释器的问题,由于GIL (全局解释器锁)的存在,多线程处理时非常低效,开了多线程也和没开一样,甚至更慢。
6. 存在的问题
6.1 摄像机部署问题
摄像机拜访存在很大问题,会遮挡视线。并且作为监控摄像头来拍摄鱼缸是非常糟糕的,首先它存在非常大的镜头畸变,在近距离非常明显,其次广角镜头在拍摄近距离物体时难以对焦,导致画面较为模糊。
6.2 时间戳问题
推流的 RTMP 协议中会增加相对时间戳,此时几分钟的视频播放完毕重启 ffmpeg 播放下一段时会重新生成时间戳,导致用户接收到的数据时间戳突变,容易导致播放问题。
在Bilibili App 上很少存在断流的问题,但在Safari 上很容易断流。
当然应该是可以通过 ffmpeg 的时间戳来解决的,不过是一个巨大的坑。也可以通过增加延迟,播放前一个小时的视频规避这个问题。
6.3 视频重复问题
视频重复在刚开始直播时会容易出现,一开始视频不足 10 分钟,会取前几分钟的,播放完后会重新获取,此时之前几分钟的依然会被获取到,重新播放。
7. 可替代方案
写了这段代码的主要原因在于AreYouOK 智能摄像机不支持 RTSP,一旦 RTSP 问题解决,代码就没有了存在意义。替代方案还是很多的
7.1 PC+USB 摄像头
购买一台冥王峡谷+罗技摄像头即可解决隐蔽性和以上所有问题。强大的 CPU 还能支持视频编解码,直接使用 OBS 玩更多直播内容。预计成本 10000.
7.2 网络摄像头支持 RTSP
有一个支持 RTSP 的网络摄像头,可以将 python 脚本简化为 shell 脚本一键运行。
7.3 手机
买一个二手手机 Bilibili App 进行直播,预计费用 300~500。最方便的办法,但扩展性不高。
8. 关键决定因素
内容高于技术。无论是脚本、摄像头还是整套方案,最终都是为内容服务的。