danmaku2ass/danmaku2ass.py

274 lines
11 KiB
Python
Raw Normal View History

2013-09-15 09:39:05 +08:00
#!/usr/bin/env python3
import argparse
2013-09-15 13:11:58 +08:00
import colorsys
2013-09-20 17:37:24 +08:00
import gettext
2013-09-20 16:28:19 +08:00
import json
2013-09-15 10:55:36 +08:00
import logging
2013-09-15 13:11:58 +08:00
import math
2013-10-01 09:07:54 +08:00
import os
2013-09-15 10:55:36 +08:00
import sys
import xml.dom.minidom
2013-10-01 09:13:32 +08:00
gettext.install('danmaku2ass', os.path.join(os.path.dirname(os.path.abspath(os.path.realpath(sys.argv[0] or 'locale'))), 'locale'))
2013-09-20 17:37:24 +08:00
2013-09-15 13:24:46 +08:00
def ProcessComments(comments, f, width, height, bottomReserved, fontface, fontsize, alpha, lifetime, reduced):
WriteASSHead(f, width, height, fontface, fontsize, alpha)
2013-09-15 13:11:58 +08:00
rows = [[None]*(height-bottomReserved), [None]*(height-bottomReserved), [None]*(height-bottomReserved)]
for i in comments:
row = 0
rowmax = height-bottomReserved-i[7]
while row < rowmax:
freerows = TestFreeRows(rows, i, row, width, height, bottomReserved, lifetime)
if freerows >= i[7]:
MarkCommentRow(rows, i, row)
WriteComment(f, i, row, width, height, bottomReserved, fontsize, lifetime)
break
else:
row += freerows or 1
else:
if not reduced:
row = FindAlternativeRow(rows, i, height, bottomReserved)
MarkCommentRow(rows, i, row)
WriteComment(f, i, row, width, height, bottomReserved, fontsize, lifetime)
def TestFreeRows(rows, c, row, width, height, bottomReserved, lifetime):
res = 0
rowmax = height-bottomReserved-c[7]
while row < rowmax and res < c[7]:
if c[4] in (1, 2):
if rows[c[4]][row] and rows[c[4]][row][0]+lifetime > c[0]:
break
else:
2013-09-19 23:19:53 +08:00
if rows[c[4]][row] and rows[c[4]][row][0]+lifetime*(rows[c[4]][row][8]+c[8])/width > c[0]:
2013-09-15 13:11:58 +08:00
break
row += 1
res += 1
return res
def FindAlternativeRow(rows, c, height, bottomReserved):
res = 0
for row in range(height-bottomReserved-math.ceil(c[7])):
if not rows[c[4]][row]:
return row
2013-09-21 12:04:11 +08:00
elif rows[c[4]][row][0] < rows[c[4]][res][0]:
2013-09-15 13:11:58 +08:00
res = row
return res
def MarkCommentRow(rows, c, row):
try:
for i in range(row, row+math.ceil(c[7])):
rows[c[4]][i] = c
except IndexError:
pass
2013-09-15 13:11:58 +08:00
def WriteComment(f, c, row, width, height, bottomReserved, fontsize, lifetime):
2013-09-16 23:16:16 +08:00
text = c[3].replace('\\', '\\\\').replace('\n', '\\N')
2013-09-15 13:11:58 +08:00
if c[4] == 1:
2013-09-17 13:12:44 +08:00
styles = '{\\an8}{\\pos(%(halfwidth)s, %(row)s)}' % {'halfwidth': round(width/2), 'row': row}
2013-09-15 13:11:58 +08:00
elif c[4] == 2:
2013-09-17 13:08:54 +08:00
styles = '{\\an2}{\\pos(%(halfwidth)s, %(row)s)}' % {'halfwidth': round(width/2), 'row': ConvertType2(row, height, bottomReserved)}
2013-09-15 13:11:58 +08:00
else:
2013-09-17 05:46:01 +08:00
styles = '{\\move(%(width)s, %(row)s, %(neglen)s, %(row)s)}' % {'width': width, 'row': row, 'neglen': -math.ceil(c[8])}
2013-09-17 13:18:06 +08:00
if not (-1 < c[6]-fontsize < 1):
2013-09-15 13:11:58 +08:00
styles += '{\\fs%s}' % round(c[6])
if c[5] != 0xffffff:
2013-10-13 14:56:51 +08:00
styles += '{\\c&H%02X%02X%02x&}' % (c[5] & 0xff, (c[5] >> 8) & 0xff, (c[5] >> 16) & 0xff)
2013-09-15 13:11:58 +08:00
if c[5] == 0x000000:
styles += '{\\3c&HFFFFFF&}'
f.write('Dialogue: 3,%(start)s,%(end)s,Default,,0000,0000,0000,,%(styles)s%(text)s\n' % {'start': ConvertTimestamp(c[0]), 'end': ConvertTimestamp(c[0]+lifetime), 'styles': styles, 'text': text})
2013-09-15 10:55:36 +08:00
2013-09-20 15:00:44 +08:00
def ProbeCommentFormat(f):
f.seek(0)
tmp = f.read(1)
2013-09-20 16:28:19 +08:00
if tmp == '[':
2013-09-20 15:00:44 +08:00
f.seek(0)
return 'Acfun'
elif tmp == '{':
tmp = f.read(17)
f.seek(0)
if tmp == '"root":{"total":"':
return 'sH5V'
else:
return None
2013-09-20 15:00:44 +08:00
elif tmp == '<':
tmp = f.read(39)
f.seek(0)
if tmp == '?xml version="1.0" encoding="UTF-8"?><p':
return 'Niconico'
elif tmp == '?xml version="1.0" encoding="UTF-8"?><i':
return 'Bilibili'
else:
return None
else:
f.seek(0)
return None
NiconicoColorMap = {'red': 0xff0000, 'pink': 0xff8080, 'orange': 0xffc000, 'yellow': 0xffff00, 'green': 0x00ff00, 'cyan': 0x00ffff, 'blue': 0x0000ff, 'purple': 0xc000ff, 'black': 0x000000}
def ReadCommentsNiconico(f, fontsize):
'Output format: [(timeline, timestamp, no, comment, pos, color, size, height, width)]'
dom = xml.dom.minidom.parse(f)
comment_element = dom.getElementsByTagName('chat')
for comment in comment_element:
try:
c = str(comment.childNodes[0].wholeText)
pos = 0
color = 0xffffff
size = fontsize
for mailstyle in str(comment.getAttribute('mail')).split():
if mailstyle == 'ue':
pos = 1
elif mailstyle == 'shita':
pos = 2
elif mailstyle == 'big':
size = fontsize*1.44
elif mailstyle == 'small':
size = fontsize*0.64
elif mailstyle in NiconicoColorMap:
color = NiconicoColorMap[mailstyle]
yield (max(int(comment.getAttribute('vpos')), 0)*0.01, int(comment.getAttribute('date')), int(comment.getAttribute('no')), c, pos, color, size, (c.count('\n')+1)*size, CalculateLength(c)*size)
2013-09-20 15:00:44 +08:00
except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
2013-09-20 17:37:24 +08:00
logging.warning(_('Invalid comment: %s') % comment.toxml())
2013-09-20 15:00:44 +08:00
continue
2013-09-20 16:28:19 +08:00
def ReadCommentsAcfun(f, fontsize):
'Output format: [(timeline, timestamp, no, comment, pos, color, size, height, width)]'
comment_element = json.load(f)
i = 0
for comment in comment_element:
try:
p = str(comment['c']).split(',')
assert len(p) >= 6
assert p[2] in ('1', '2', '4', '5')
c = str(comment['m'])
size = int(p[3])*fontsize/25.0
yield (float(p[0]), int(p[5]), i, c, {'1': 0, '2': 0, '4': 2, '5': 1}[p[2]], int(p[1]), size, (c.count('\n')+1)*size, CalculateLength(c)*size)
i += 1
except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
2013-09-20 17:37:24 +08:00
logging.warning(_('Invalid comment: %r') % comment)
2013-09-20 16:28:19 +08:00
continue
2013-09-15 10:55:36 +08:00
def ReadCommentsBilibili(f, fontsize):
2013-09-20 16:28:19 +08:00
'Output format: [(timeline, timestamp, no, comment, pos, color, size, height, width)]'
2013-09-15 10:55:36 +08:00
dom = xml.dom.minidom.parse(f)
comment_element = dom.getElementsByTagName('d')
i = 0
for comment in comment_element:
try:
p = str(comment.getAttribute('p')).split(',')
assert len(p) >= 8
assert p[1] in ('1', '4', '5')
c = str(comment.childNodes[0].wholeText).replace('/n', '\\n')
2013-09-15 13:11:58 +08:00
size = int(p[2])*fontsize/25.0
2013-09-20 15:00:44 +08:00
yield (float(p[0]), int(p[4]), i, c, {'1': 0, '4': 2, '5': 1}[p[1]], int(p[3]), size, (c.count('\n')+1)*size, CalculateLength(c)*size)
2013-09-15 10:55:36 +08:00
i += 1
except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
2013-09-20 17:37:24 +08:00
logging.warning(_('Invalid comment: %s') % comment.toxml())
2013-09-15 10:55:36 +08:00
continue
2013-09-15 09:39:05 +08:00
2013-10-13 14:56:51 +08:00
def ReadCommentsSH5V(f, fontsize):
'Output format: [(timeline, timestamp, no, comment, pos, color, size, height, width)]'
comment_element = json.load(f)
i = 0
2013-10-13 14:56:51 +08:00
for comment in comment_element["root"]["bgs"]:
try:
c_at = str(comment['at'])
c_type = str(comment['type'])
c_date = str(comment['timestamp'])
c_color = str(comment['color'])
c = str(comment['text'])
size = fontsize
2013-10-13 14:56:51 +08:00
yield (float(c_at), int(c_date), i, c, {'0': 0, '1': 0, '4': 2, '5': 1}[c_type], int(c_color[1:], 16), size, (c.count('\n')+1)*size, CalculateLength(c)*size)
i += 1
except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
logging.warning(_('Invalid comment: %r') % comment)
continue
2013-09-15 13:11:58 +08:00
def ConvertTimestamp(timestamp):
hour, minute = divmod(timestamp, 3600)
minute, second = divmod(minute, 60)
2013-10-13 13:03:09 +08:00
centsecond = round((second-int(second))*100.0)
2013-09-15 13:11:58 +08:00
return '%d:%02d:%02d.%02d' % (int(hour), int(minute), int(second), centsecond)
2013-09-15 13:24:46 +08:00
def WriteASSHead(f, width, height, fontface, fontsize, alpha):
2013-09-15 13:11:58 +08:00
f.write(
2013-09-19 22:49:41 +08:00
'''\ufeff
[Script Info]
2013-09-15 13:11:58 +08:00
ScriptType: v4.00+
Collisions: Normal
PlayResX: %(width)s
PlayResY: %(height)s
[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
2013-09-17 13:08:54 +08:00
Style: Default, %(fontface)s, %(fontsize)s, &H%(alpha)02XFFFFFF, &H%(alpha)02XFFFFFF, &H%(alpha)02X000000, &H%(alpha)02X000000, 0, 0, 0, 0, 100, 100, 0.00, 0.00, 1, 1, 0, 7, 20, 20, 20, 0
2013-09-15 13:11:58 +08:00
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
2013-09-17 13:18:06 +08:00
''' % {'width': width, 'height': height, 'fontface': fontface, 'fontsize': round(fontsize), 'alpha': 255-round(alpha*255)}
2013-09-15 13:11:58 +08:00
)
def NeedWhiteBorder(rgb):
2013-10-13 14:56:51 +08:00
h, l, s = colorsys.rgb_to_hls(((rgb >> 16) & 0xff)/255.0, ((rgb >> 8) & 0xff)/255.0, (rgb & 0xff)/255.0)
2013-09-15 13:11:58 +08:00
return (1/12 < h < 7/12 and l < 1/3) or l < 5/12
def CalculateLength(s):
2013-09-17 05:46:24 +08:00
return max(map(len, s.split('\n')))
2013-09-15 13:11:58 +08:00
def ConvertType2(row, height, bottomReserved):
return height-bottomReserved-row
2013-09-15 09:39:05 +08:00
if __name__ == '__main__':
parser = argparse.ArgumentParser()
2013-09-30 23:27:33 +08:00
parser.add_argument('-o', '--output', metavar=_('OUTPUT'), help=_('Output file'))
parser.add_argument('-s', '--size', metavar=_('WIDTHxHEIGHT'), required=True, help=_('Stage size in pixels'))
parser.add_argument('-fn', '--font', metavar=_('FONT'), help=_('Specify font face'), default=_('(FONT) sans-serif')[7:])
parser.add_argument('-fs', '--fontsize', metavar=_('SIZE'), help=(_('Default font size')), type=float, default=25.0)
parser.add_argument('-a', '--alpha', metavar=_('ALPHA'), help=_('Text opaque'), type=float, default=1.0)
parser.add_argument('-l', '--lifetime', metavar=_('SECONDS'), help=_('Duration of comment display'), type=float, default=5.0)
parser.add_argument('-p', '--protect', metavar=_('HEIGHT'), help=_('Reserve blank on the bottom of the stage'), type=int, default=0)
2013-09-20 17:37:24 +08:00
parser.add_argument('-r', '--reduce', action='store_true', help=_('Reduce the amount of comments if stage is full'))
2013-09-30 23:27:33 +08:00
parser.add_argument('file', metavar=_('FILE'), nargs='+', help=_('Comment file to be processed'))
2013-09-15 09:39:05 +08:00
args = parser.parse_args()
2013-09-15 10:55:36 +08:00
try:
width, height = str(args.size).split('x', 1)
width = int(width)
height = int(height)
except ValueError:
2013-09-20 17:37:24 +08:00
raise ValueError(_('Invalid stage size: %r') % args.size)
2013-09-15 10:55:36 +08:00
comments = []
for i in args.file:
with open(i, 'r', encoding='utf-8') as f:
CommentProcesser = {None: None, 'Niconico': ReadCommentsNiconico, 'Acfun': ReadCommentsAcfun, 'Bilibili': ReadCommentsBilibili, 'sH5V': ReadCommentsSH5V}[ProbeCommentFormat(f)]
2013-09-20 15:00:44 +08:00
if not CommentProcesser:
2013-09-20 17:37:24 +08:00
raise ValueError(_('Unknown comment file format: %s') % i)
2013-09-20 15:00:44 +08:00
for comment in CommentProcesser(f, args.fontsize):
2013-09-15 10:55:36 +08:00
comments.append(comment)
if args.output:
fo = open(args.output, 'w', encoding='utf-8', newline='\r\n')
else:
fo = sys.stdout
comments.sort()
2013-09-15 13:24:46 +08:00
ProcessComments(comments, fo, width, height, args.protect, args.font, args.fontsize, args.alpha, args.lifetime, args.reduce)
2013-09-15 10:55:36 +08:00
if args.output:
fo.close()