B站云养鱼--贫穷定制

B站云养鱼–贫穷定制

目录

[TOC]

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 硬件限制条件分析

由于穷,本次方案只能在家里现有设备基础上进行设计。

目前可用设备:

  1. 蜗牛星际
  2. AreYouOK 智能摄像机(第三代)
  3. 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

下图是我自己的**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 上。我本以为这是一个黑科技,但万万没想到…

image-20200322110409969

老爷爷地铁看手机

一定是我打开方式错误,我换个方式重新打开一下:

➜  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

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协议有许多变种:

  1. 默认使用TCP端口1935的纯粹(plain)协议。
  2. RTMPS,通过一个 TLS/SSL连接传输RTMP。
  3. RTMPE,使用Adobe自有安全机制加密的RTMP。虽然实现的细节为专>有,但该机制使用行业标准的 密码学原函数。[ 1]
  4. RTMPT,用 HTTP 封装以穿>透防火墙。RTMPT通常在 TCP 端口80和443>上使用明文请求来绕过大多数的公司流量过滤。封装的会话中可能携带纯粹的>RTMP、RTMPS或RTMPE数据包。
  5. RTMFP, 使用 UDP而非TCP的RTMP,取代RTMP Chunk Stream。Adobe Systems>开发了安全的 实时媒体流协议包,可以让最终用>户直接地相互连接(P2P)。

虽然RTMP的主要动机是成为一个播放Flash视频的协议,但它也用于其他一些应用程序,如 Adobe LiveCycle Data Services ES

—— WikiPedia 实时消息协议

4.1.4 以上三者之间的关系

当我们有一段原始视频时,首先我们会将其转成 H.264编码,这会减少很多空间。其次我们会加上音轨,这样就组成了有一个视频通道,一个音频通道的多媒体组合。

我们可以看一下原始的电影胶片,他包含了多条轨道,杜比左右声道及其他音轨就和视频轨道并列存放着,这和 MP4 中的封装是差不多的。

th

对于流媒体传输协议,里面包含的也是 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. 关键决定因素

内容高于技术。无论是脚本、摄像头还是整套方案,最终都是为内容服务的。

IMG_3777


  1. www.pmi.com ↩︎

  2. https://obsproject.com ↩︎

reads

喵?

下一页
上一页