bili录播姬输出的录播文件是flv格式,附带弹幕文件是xml格式。对于多数播放器而言,可接受的外挂字幕格式一般都是ass或srt。此外,如果想用iPhone来看录播,就需要把字幕和视频合在一起,因为视频播放器只会从在线字幕网站搜索字幕,无法加载ass字幕文件。综上所述,需要实现的目标是:1,将xml弹幕转为ass弹幕;2,如果出于某些原因,没有弹幕文件,那么就将flv转为mp4,否则把flv和ass合并为mkv。
之前写了一个简单的python来调用系统的ffmpeg和danmakufactory来转换,用systemd timer来定时触发。这种方法好是好,但是做了个assumption:主播不会在凌晨四点还在播;万一主播通宵了就不太好使了。当初设置成固定时间触发,是因为没办法知道录播什么时候结束。但是录播姬提供了一个功能(webhook),可以用来作为触发器。经过一些研究,可以得到以下的知识:
- 录播姬的event我们只需要关心文件关闭即可。即使因为网络波动,一个录播断成了几段,作为转换器的我们不care这件事,我们只关注文件关闭——文件已关闭,这个文件不会再有新数据写入,所以我们做转换得到的输出文件内容跟这段录播是一样的,不会出现以后需要重新访问、重新转换的情况。
- 在使用ffmpeg把ass字幕作为一条stream塞进mkv里面的时候,如果不把字体文件也attach进去,会产生报错——对于缺少这个字体的系统,在执行ffmpeg做转换的时候就已经会提示;否则,在播放的时候也会出问题。
转换函数
首先把执行转换用的函数准备好:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import os
import sys
def extractfilename(fullpath):
return os.path.basename(fullpath)
def silentremove(filename):
try:
os.remove(filename)
except Exception as e:
print(str(e))
def transform(videofilepath):
# 录播姬工作目录
SRC_ROOT="/mnt/sharedisk/bilibili_rec/"
# 字体文件
FONT=SRC_ROOT+"font/Microsoft-YaHei.ttf"
# 输出目录
DST_VIDEO_DIR='/mnt/sharedisk/B站录播/视频/'
DST_AUDIO_DIR='/mnt/sharedisk/B站录播/音频/'
# 调用DanmakuFactory转换xml到ass的shell命令的模板
DANMUFORMATSTR='DanmakuFactory -o ass "{}" -i xml "{}" -r "1920x1080" -S "52"'
# 调用ffmpeg把flv+ass转成mkv的shell命令模板
FLVTOMKV = 'ffmpeg -i \'{}\' -i \'{}\' -attach \'{}\' -c:v copy -c:a copy -map 0:0 -map 0:1 -map 1:0 -metadata:s:t mimetype=application/x-truetype-font -y \'{}\''
# 调用ffmpeg把flv转成mp4的shell命令模板
FLVTOMP4 = 'ffmpeg -i \'{}\' -y -vcodec copy -acodec copy \'{}\''
# 调用ffmpeg把flv只提取音频轨道转成m4a的shell命令模板
FLVTOM4A = 'ffmpeg -i \'{}\' -map 0:a -c copy \'{}\''
# 从传入的videofilepath中去掉.flv后缀,生成后续的一系列源文件名
src_common_name = videofilepath.removesuffix(".flv")
# 从传入的videofilepath中去掉.flv后缀,生成后续的一系列输出文件名
dst_common_name = extractfilename(videofilepath).removesuffix(".flv")
# 这两条有所不同,因为源文件们可以直接使用录播姬传进来的带目录的文件名
# 录播姬传进来的是 录播间名/录播文件.flv,毕竟datafield的tag是relativepath
# 但是输出文件名们都是用另外的输出目录,所以要把这个前导目录给去掉,只留文件名部份
# 弹幕源文件
xml_file = os.path.join(SRC_ROOT, src_common_name+".xml")
# 弹幕输出文件
ass_file = os.path.join(SRC_ROOT, src_common_name+".ass")
# 录播源文件
flv_file = os.path.join(SRC_ROOT, videofilepath)
# mp4输出文件
mp4_file = os.path.join(DST_VIDEO_DIR, dst_common_name+".mp4")
# mkv输出文件
mkv_file = os.path.join(DST_VIDEO_DIR, dst_common_name+".mkv")
# m4a输出文件
m4a_file = os.path.join(DST_AUDIO_DIR,dst_common_name+".m4a")
# 防呆
if not os.path.exists(flv_file):
return "File not exists."
# 如果有弹幕文件,先把ass给删了,重新生成ass
if os.path.exists(xml_file):
silentremove(ass_file)
os.system(DANMUFORMATSTR.format(ass_file,xml_file))
# 同理,删掉所有曾经的输出文件
silentremove(mkv_file)
silentremove(mp4_file)
silentremove(m4a_file)
# 有ass就转成mkv
if os.path.exists(ass_file):
os.system(FLVTOMKV.format(flv_file,ass_file,FONT,mkv_file))
print("Successfully convert flv to mkv, subtitle inserted.")
# 没ass就转成mp4
else:
os.system(FLVTOMP4.format(flv_file,mp4_file))
print("Successfully convert flv to mp4, no subtitle found.")
# 生成音频
os.system(FLVTOM4A.format(flv_file,m4a_file))
# 收拾手尾
silentremove(ass_file)
FONT变量写的是之后内嵌到mkv文件里的字体文件。我们用的是在mkv容器中新增一个轨道放置ass字幕的方法来写入弹幕,对于播放器而言就需要在播放中渲染字幕的时候找到字体文件才能渲染,因此我们就需要把字体文件也嵌入mkv中。
录播姬默认设置的弹幕样式的字体是微软雅黑,所以搞到一个微软雅黑的字体文件,放在录播姬工作目录下的font文件夹里,再用FONT变量写明路径,用于之后ffmpeg-attach
进容器里。
为什么用mkv?因为mp4嵌入字幕需要用video filter,慢到烂,最关键的是因为是re-encode,所以画面会降级(非常明显)。我宁愿另外装app来播mkv,也不要等几个钟来重新渲染。mkv虽然没办法用系统自带app播放,但是其实解码耗电是一样的,因为mkv和mp4都是容器格式,真正的视频格式还是h.264,iPhone和Mac都能硬解。
为什么代码那么烂?谁会把python写得好看,又不是C++
POST Server
随后是处理POST的server部份。首先根据弹幕姬的Webhook文档,写个curl命令用来测试结果对不对。我们只关注FileClosed,文档给的的示例POST数据是:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"EventType": "FileClosed",
"EventTimestamp": "2021-05-14T17:52:54.9461101+08:00",
"EventId": "98f85267-e08c-4f15-ad9a-1fc463d42b0b",
"EventData": {
"RelativePath": "23058-3号直播间/录制-23058-20210514-175250-哔哩哔哩音悦台.flv",
"FileSize": 816412,
"Duration": 4.992,
"FileOpenTime": "2021-05-14T17:52:50.5246401+08:00",
"FileCloseTime": "2021-05-14T17:52:54.9461101+08:00",
"SessionId": "7c7f3672-70ce-405a-aa12-886702ced6e5",
"RoomId": 23058,
"ShortId": 3,
"Name": "3号直播间",
"Title": "哔哩哔哩音悦台",
"AreaNameParent": "生活",
"AreaNameChild": "影音馆",
"Recording":true, // 录播姬 2.0.0 新增
"Streaming":true, // 录播姬 2.0.0 新增
"DanmakuConnected":true // 录播姬 2.0.0 新增
}
}
那么转成curl命令就是:
1
2
3
curl -v -X POST -H "Content-Type: application/json" \
-d '{"EventType": "FileClosed", "EventTimestamp": "2021-05-14T17:52:54.9461101+08:00", "EventId": "98f85267-e08c-4f15-ad9a-1fc463d42b0b", "EventData": { "RelativePath": "23058-3号直播间/录制-23058-20210514-175250-哔哩哔哩音悦台.flv", "FileSize": 816412, "Duration": 4.992, "FileOpenTime": "2021-05-14T17:52:50.5246401+08:00", "FileCloseTime": "2021-05-14T17:52:54.9461101+08:00", "SessionId": "7c7f3672-70ce-405a-aa12-886702ced6e5", "RoomId": 23058, "ShortId": 3, "Name": "3号直播间", "Title": "哔哩哔哩音悦台", "AreaNameParent": "生活", "AreaNameChild": "影音馆", "Recording":true, "Streaming":true, "DanmakuConnected":true}}' \
http://127.0.0.1:25665
随后简单写个python服务器。根据Webhook文档,我们收到post之后需要发一个200回去。这里是服务器功能的部份,真正能用的需要把前面那个transform函数粘在这里。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from http.server import BaseHTTPRequestHandler, HTTPServer
import simplejson
import sys
class handler(BaseHTTPRequestHandler):
def _set_headers(self):
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
def do_HEAD(self):
self._set_headers()
def do_POST(self):
self._set_headers()
self.data_string = self.rfile.read(int(self.headers['Content-Length']))
self.send_response(200)
self.end_headers()
data = simplejson.loads(self.data_string)
if data['EventType'] == 'FileClosed':
videofilename = data['EventData']['RelativePath']
try:
transform(videofilename)
except Exception as e:
print(str(e))
with HTTPServer(('127.0.0.1', 25665), handler) as server:
server.serve_forever()
把transform函数和服务器部份写在一起就是:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#!/usr/bin/env python3
from http.server import BaseHTTPRequestHandler, HTTPServer
import simplejson
import os
import sys
def extractfilename(fullpath):
return os.path.basename(fullpath)
def silentremove(filename):
try:
os.remove(filename)
except Exception as e:
print(str(e))
def transform(videofilepath):
SRC_ROOT="/mnt/sharedisk/bilibili_rec/"
FONT=SRC_ROOT+"font/Microsoft-YaHei.ttf"
DST_VIDEO_DIR='/mnt/sharedisk/B站录播/视频/'
DST_AUDIO_DIR='/mnt/sharedisk/B站录播/音频/'
DANMUFORMATSTR='DanmakuFactory -o ass "{}" -i xml "{}" -r "1920x1080" -S "52" --msgboxduration 30'
FLVTOMKV = 'ffmpeg -i \'{}\' -i \'{}\' -attach \'{}\' -c:v copy -c:a copy -map 0:0 -map 0:1 -map 1:0 -metadata:s:t mimetype=application/x-truetype-font -y \'{}\''
FLVTOMP4 = 'ffmpeg -i \'{}\' -y -vcodec copy -acodec copy \'{}\''
FLVTOM4A = 'ffmpeg -i \'{}\' -map 0:a -c copy \'{}\''
src_common_name = videofilepath.removesuffix(".flv")
dst_common_name = extractfilename(videofilepath).removesuffix(".flv")
xml_file = os.path.join(SRC_ROOT, src_common_name+".xml")
ass_file = os.path.join(SRC_ROOT, src_common_name+".ass")
flv_file = os.path.join(SRC_ROOT, videofilepath)
mp4_file = os.path.join(DST_VIDEO_DIR, dst_common_name+".mp4")
mkv_file = os.path.join(DST_VIDEO_DIR, dst_common_name+".mkv")
m4a_file = os.path.join(DST_AUDIO_DIR,dst_common_name+".m4a")
if not os.path.exists(flv_file):
return "File not exists."
if os.path.exists(xml_file):
silentremove(ass_file)
os.system(DANMUFORMATSTR.format(ass_file,xml_file))
silentremove(mkv_file)
silentremove(mp4_file)
silentremove(m4a_file)
if os.path.exists(ass_file):
os.system(FLVTOMKV.format(flv_file,ass_file,FONT,mkv_file))
print("Successfully convert flv to mkv, subtitle inserted.")
else:
os.system(FLVTOMP4.format(flv_file,mp4_file))
print("Successfully convert flv to mp4, no subtitle found.")
os.system(FLVTOM4A.format(flv_file,m4a_file))
silentremove(ass_file)
class handler(BaseHTTPRequestHandler):
def _set_headers(self):
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
def do_HEAD(self):
self._set_headers()
def do_POST(self):
self._set_headers()
self.data_string = self.rfile.read(int(self.headers['Content-Length']))
self.send_response(200)
self.end_headers()
data = simplejson.loads(self.data_string)
if data['EventType'] == 'FileClosed':
videofilename = data['EventData']['RelativePath']
try:
transform(videofilename)
except Exception as e:
print(str(e))
with HTTPServer(('127.0.0.1', 25665), handler) as server:
server.serve_forever()
保存成postserver.py
放到录播姬所在的linux上加上运行权限(+x
),系统里装好ffmpeg和DanmakuFactory,pip安装simplejson,然后写个systemd unit做成服务:
1
2
3
4
5
6
7
8
9
10
11
12
13
# /etc/systemd/system/bililivepost.service
[Unit]
Description=Auto translate bililive flv to mkv or mp4
After=network.target nss-lookup.target
[Service]
User=lubo
Group=lubo
NoNewPrivileges=true
ExecStart=/opt/bililive_postrecord_processor/postserver.py
Restart=always
[Install]
WantedBy=multi-user.target
注意User要和录播姬运行用的User一样才行。systemctl enable --now bililivepost
运行起来。
配置一下录播姬的webhook2,写上http://127.0.0.1:25665
就可以了。
参考
xml弹幕转ass字幕文件:DanmakuFactory
flv转mkv嵌入ass字幕:How to load external or custom fonts in subtitle file
webhook请求内容:Webhook
webhook测试用网站:webhook.site
curl发起post请求:Curl POST JSON 请求
基于python的POST服务器:Reading JSON from SimpleHTTPServer Post data