bilibili弹幕转ass程序制作思路及过程
b站的弹幕,线下播放还是挺麻烦的,专用的弹幕播放器对其他格式的视频支持不好。我也试着弄个弹幕转字幕的小程序出来。
抓取xml文件的工作就不多说了,很简单的事,只要在播放页面看看源文件就能确定xml文件的地址进行抓取了。
本文主要是讲述xml内的弹幕转字幕的过程。
除去xml文件开头结尾的一些七七八八的东西,弹幕主体是这样的:
<dp="51.593,5,25,16711680,1408852480,0,7fa769b4,576008622">怒求up自己配音!</d> <dp="10.286,1,25,16777215,1408852600,0,a3af4d0d,576011065">颜艺?</d> <dp="12.65,1,25,16777215,1408852761,0,24570b5a,576014281">我的女神!</d> <dp="19.033,1,25,16777215,1408852789,0,cb20d1c7,576014847">前!!!</d> <dp="66.991,1,25,16777215,1408852886,0,a78e484d,576016806">已撸</d>
如果它把弹幕的各种属性分开表示,我就用encoding/xml包来解码,但是丫把弹幕的属性都放在p里面了,所以我使用正则表达式来提取的。
以上表第一条弹幕为例。很明显的,p属性开始的浮点数,与播放时一比对,就能知道,表示的是弹幕应该出现的播放时间。 随后的1和25先不管; 16777215,目测应该是颜色(因为该值表示为十六进制是FFFFFF); 1408852480,在弹幕中是递增的,感觉应该是个unix时间,用这个数(d),求:d/86400/365.2425+1970,结果约为2014.6。看来确实是unix时间。估计是创建弹幕的时间。 0,不知道,抓取了很多视频的弹幕,这个位置都是0,暂且不管它。 7fa769b4,估计是创建者的ID,因为同一xml文件会出现多次,而且看起来是十六进制数,恰好有些hash函数就是返回4字节整数。 576008622,也是递增的,不用猜也知道,这个肯定就是弹幕的ID了。
事后再核对一下,果然,1代表弹幕的类型(从右向左移动啊,出现在下方或者上方啊……),25是字体大小,16777125是字体颜色。
所以,我们就只要捕获每条弹幕的时间、类型、大小、颜色、文本就行了。
正则表达式:
<d\sp="([\d\.]+),([145]),(\d+),(\d+),\d+,\d+,\w+,\d+">([^<>]+?)</d>
捕获弹幕很简单,关键是排布弹幕为字幕的算法。
关于这个算法我就很坑爹的弄了个乱七八糟的算法,采用的是固定移动速度,最小重叠的排布原则。
对游动弹幕,会倾向于选择下面一行的位置,如果会重叠,则选择更下一行(最低行会循环到最上面一行),如果没有不重叠的行,会选择重叠文本最少的行。
对上现隐/下现隐的固定弹幕,会选择最接近上方/下方,且不重叠的行;如果没有不重叠的行,则选择重叠时间最短的行,居中放置字幕。
默认字体微软雅黑,默认大小25,默认白色黑边;默认占满整个屏幕,共计12行;默认屏幕大小640x360。
这么弄,主要是为了让ass字幕的效果更接近原始弹幕的效果。
高级弹幕真的超出我的能力范围了,全部忽略掉。
go源代码如下:
//将bilibili的xml弹幕文件转换为ass字幕文件。
//xml文件中,弹幕的格式如下:
//<dp="32.066,1,25,16777215,1409046965,0,017d3f58,579516441">地板好评</d>
//p的属性为时间、弹幕类型、字体大小、字体颜色、创建时间、?、创建者ID、弹幕ID。
//p的属性中,后4项对ass字幕无用,舍弃。被<d>和</d>包围的是弹幕文本。
//只处理右往左、上现隐、下现隐三种类型的普通弹幕。
packagemain
import(
"fmt"
"io"
"io/ioutil"
"math"
"os"
"regexp"
"sort"
"strconv"
"strings"
)
//ass文件的头部
constheader=`[ScriptInfo]
ScriptType:v4.00+
Collisions:Normal
playResX:640
playResY:360
[V4+Styles]
Format:Name,Fontname,Fontsize,primaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding
Style:Default,MicrosoftYaHei,28,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0.00,0.00,1,1,0,2,10,10,10,0
[Events]
Format:Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text
`
//正则匹配获取弹幕原始信息
varline=regexp.MustCompile(`<d\sp="([\d\.]+),([145]),(\d+),(\d+),\d+,\d+,\w+,\d+">([^<>]+?)</d>`)
//用来保管弹幕的信息
typeDanmustruct{
textstring
timefloat64
kindbyte
sizeint
colorint
}
//使[]Danmu实现sort.Interface接口,以便排序
typeDanmus[]Danmu
func(dDanmus)Len()int{
returnlen(d)
}
func(dDanmus)Less(i,jint)bool{
returnd[i].time<d[j].time
}
func(dDanmus)Swap(i,jint){
d[i],d[j]=d[j],d[i]
}
//将正则匹配到的数据填写入Danmu类型里
funcfill(d*Danmu,s[][]byte){
d.time,_=strconv.ParseFloat(string(s[1]),64)
d.kind=s[2][0]-'0'
d.size,_=strconv.Atoi(string(s[3]))
bgr,_:=strconv.Atoi(string(s[4]))
d.color=((bgr>>16)&255)|(bgr&(255<<8))|((bgr&255)<<16)
d.text=string(s[5])
}
//返回文本的长度,假设ascii字符都是0.5个字长,其余都是1个字长
funclength(sstring)float64{
l:=0.0
for_,r:=ranges{
ifr<127{
l+=0.5
}else{
l+=1
}
}
returnl
}
//生成时间点的ass格式表示:`0:00:00.00`
functimespot(ffloat64)string{
h,f:=math.Modf(f/3600)
m,f:=math.Modf(f*60)
returnfmt.Sprintf("%d:%02d:%05.2f",int(h),int(m),f*60)
}
//读取文件并获取其中的弹幕
funcopen(namestring)([]Danmu,error){
data,err:=ioutil.ReadFile(name)
iferr!=nil{
returnnil,err
}
dan:=line.FindAllSubmatch(data,-1)
ans:=make([]Danmu,len(dan))
fori:=len(dan)-1;i>=0;i--{
fill(&ans[i],dan[i])
}
returnans,nil
}
//将弹幕排布并写入w,采用的简单的固定移速、最小重叠排布算法
funcsave(wio.Writer,dans[]Danmu){
p1:=make([]float64,36)
p2:=make([]float64,36)
p3:=make([]float64,36)
t:=0
max:=func(x[]float64)float64{
i:=x[0]
for_,j:=rangex[1:]{
ifi<j{
i=j
}
}
returni
}
set:=func(x[]float64,ffloat64){
fori,_:=rangex{
x[i]=f
}
}
find:=func(p[]float64,ffloat64,i,dint)int{
i=(i/d+1)*d%36
m,k:=f+10000,0
forj:=0;j<36;j+=d{
t:=(i+j)%36
ifn:=max(p[t:t+d]);n<=f{
k=t
break
}elseifm>n{
k=t
m=n
}
}
returnk
}
for_,dan:=rangedans{
s,l:="",length(dan.text)
ifl==0{
continue
}
switch{
casedan.size<25:
dan.size,l,s=2,l*18,"\\fs18"
casedan.size==25:
dan.size,l=3,l*28
casedan.size>25:
dan.size,l,s=4,l*38,"\\fs38"
}
ifdan.color!=0x00FFFFFF{
s+=fmt.Sprintf("\\c&H%06X",dan.color)
}
switchdan.kind{
case1://右往左
t:=find(p1,dan.time,t,dan.size)
set(p1[t:t+dan.size],dan.time+8)
h:=(t+dan.size)*10-1
s+=fmt.Sprintf("\\move(%d,%d,%d,%d)",640+int(l/2),h,-int(l/2),h)
fmt.Fprintf(w,"Dialogue:1,%s,%s,Default,,0000,0000,0000,,{%s}%s\n",
timespot(dan.time+0),
timespot(dan.time+8),s,dan.text)
case4://下现隐
j:=find(p2,dan.time,35,dan.size)
set(p2[j:j+dan.size],dan.time+4)
s+=fmt.Sprintf("\\pos(%d,%d)",320,(36-j)*10-1)
fmt.Fprintf(w,"Dialogue:2,%s,%s,Default,,0000,0000,0000,,{%s}%s\n",
timespot(dan.time+0),
timespot(dan.time+4),s,dan.text)
case5://上现隐
j:=find(p3,dan.time,35,dan.size)
set(p3[j:j+dan.size],dan.time+4)
s+=fmt.Sprintf("\\pos(%d,%d)",320,(j+dan.size)*10-1)
fmt.Fprintf(w,"Dialogue:3,%s,%s,Default,,0000,0000,0000,,{%s}%s\n",
timespot(dan.time+0),
timespot(dan.time+4),s,dan.text)
}
}
}
//主函数,实现了命令行
funcmain(){
iflen(os.Args)<=1{
os.Exit(0)
}
for_,name:=rangeos.Args[1:]{
dans,err:=open(name)
iferr!=nil{
os.Exit(1)
}
ifn:=strings.LastIndex(name,".");n!=-1{
name=name[:n]
}
name+=".ass"
file,err:=os.Create(name)
iferr!=nil{
os.Exit(2)
}
file.WriteString(header)
sort.Sort(Danmus(dans))
save(file,dans)
file.Close()
}
}
2014.9.29:30am更新:对字体排布进行了修正。
2014.9.29:50am更新:算法修改为固定出现时间,最小重叠排布,最终版本。
over。欢迎各位评论,倒不如各位多多评论啊。