danmaku2ass/danmaku2ass.py

648 lines
28 KiB
Python
Raw Normal View History

2013-09-15 09:39:05 +08:00
#!/usr/bin/env python3
import argparse
import calendar
2013-09-20 17:37:24 +08:00
import gettext
2013-11-03 11:59:52 +08:00
import io
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-11-02 20:32:36 +08:00
import random
2013-11-03 11:59:52 +08:00
import re
2013-09-15 10:55:36 +08:00
import sys
import time
2013-09-15 10:55:36 +08:00
import xml.dom.minidom
2013-11-03 18:20:45 +08:00
if sys.version_info < (3,):
raise RuntimeError('at least Python 3.0 is required')
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-11-02 19:58:10 +08:00
def SeekZero(function):
def decorated_function(file_):
file_.seek(0)
try:
return function(file_)
finally:
file_.seek(0)
return decorated_function
2013-09-15 13:11:58 +08:00
2013-09-15 10:55:36 +08:00
def EOFAsNone(function):
def decorated_function(*args, **kwargs):
try:
return function(*args, **kwargs)
except EOFError:
return None
return decorated_function
2013-11-02 19:58:10 +08:00
@SeekZero
@EOFAsNone
2013-09-20 15:00:44 +08:00
def ProbeCommentFormat(f):
tmp = f.read(1)
2013-09-20 16:28:19 +08:00
if tmp == '[':
2013-09-20 15:00:44 +08:00
return 'Acfun'
elif tmp == '{':
tmp = f.read(14)
if tmp == '"status_code":':
return 'Tudou'
elif tmp == '"root":{"total':
return 'sH5V'
2013-09-20 15:00:44 +08:00
elif tmp == '<':
tmp = f.read(1)
if tmp == '?':
tmp = f.read(38)
if tmp == 'xml version="1.0" encoding="UTF-8"?><p':
return 'Niconico'
elif tmp == 'xml version="1.0" encoding="UTF-8"?><i':
return 'Bilibili'
elif tmp == 'xml version="1.0" encoding="utf-8"?><i':
return 'Bilibili' # tucao.cc, with the same file format as Bilibili
elif tmp == 'xml version="1.0" encoding="Utf-8"?>\n<':
return 'Bilibili' # Komica, with the same file format as Bilibili
elif tmp == 'xml version="1.0" encoding="UTF-8"?>\n<':
return 'MioMio'
elif tmp == 'p':
return 'Niconico' # Himawari Douga, with the same file format as Niconico Douga
2013-09-20 15:00:44 +08:00
2013-11-02 20:11:38 +08:00
#
# ReadComments**** protocol
#
# Input:
# f: Input file
# fontsize: Default font size
#
# Output:
# yield a tuple:
# (timeline, timestamp, no, comment, pos, color, size, height, width)
# timeline: The position when the comment is replayed
# timestamp: The UNIX timestamp when the comment is submitted
# no: A sequence of 1, 2, 3, ..., used for sorting
# comment: The content of the comment
# pos: 0 for regular moving comment,
# 1 for bottom centered comment,
2013-11-17 13:52:46 +08:00
# 2 for top centered comment,
# 3 for reversed moving comment
2013-11-02 20:11:38 +08:00
# color: Font color represented in 0xRRGGBB,
# e.g. 0xffffff for white
# size: Font size
# height: The estimated height in pixels
# i.e. (comment.count('\n')+1)*size
# width: The estimated width in pixels
# i.e. CalculateLength(comment)*size
#
# After implementing ReadComments****, make sure to update ProbeCommentFormat
# and CommentFormatMap.
#
2013-09-20 15:00:44 +08:00
def ReadCommentsNiconico(f, fontsize):
2013-11-16 22:48:06 +08:00
NiconicoColorMap = {'red': 0xff0000, 'pink': 0xff8080, 'orange': 0xffcc00, 'yellow': 0xffff00, 'green': 0x00ff00, 'cyan': 0x00ffff, 'blue': 0x0000ff, 'purple': 0xc000ff, 'black': 0x000000, 'niconicowhite': 0xcccc99, 'white2': 0xcccc99, 'truered': 0xcc0033, 'red2': 0xcc0033, 'passionorange': 0xff6600, 'orange2': 0xff6600, 'madyellow': 0x999900, 'yellow2': 0x999900, 'elementalgreen': 0x00cc66, 'green2': 0x00cc66, 'marineblue': 0x33ffcc, 'blue2': 0x33ffcc, 'nobleviolet': 0x6633cc, 'purple2': 0x6633cc}
2013-09-20 15:00:44 +08:00
dom = xml.dom.minidom.parse(f)
comment_element = dom.getElementsByTagName('chat')
for comment in comment_element:
try:
c = str(comment.childNodes[0].wholeText)
2013-11-16 23:14:24 +08:00
if c.startswith('/'):
continue # ignore advanced comments
2013-09-20 15:00:44 +08:00
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):
comment_element = json.load(f)
2013-11-03 18:34:09 +08:00
for i, comment in enumerate(comment_element):
2013-09-20 16:28:19 +08:00
try:
p = str(comment['c']).split(',')
assert len(p) >= 6
assert p[2] in ('1', '2', '4', '5', '7')
2013-09-20 16:28:19 +08:00
c = str(comment['m'])
size = int(p[3])*fontsize/25.0
if p[2] != '7':
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)
else:
c = dict(json.loads(c))
yield (float(p[0]), int(p[5]), i, c, 'acfunpos', int(p[1]), size, 0, 0)
2013-09-20 16:28:19 +08:00
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):
dom = xml.dom.minidom.parse(f)
comment_element = dom.getElementsByTagName('d')
2013-11-03 18:34:09 +08:00
for i, comment in enumerate(comment_element):
2013-09-15 10:55:36 +08:00
try:
p = str(comment.getAttribute('p')).split(',')
assert len(p) >= 5
assert p[1] in ('1', '4', '5', '6', '7')
if p[1] != '7':
c = str(comment.childNodes[0].wholeText).replace('/n', '\n')
size = int(p[2])*fontsize/25.0
2013-11-17 13:52:46 +08:00
yield (float(p[0]), int(p[4]), i, c, {'1': 0, '4': 2, '5': 1, '6': 3}[p[1]], int(p[3]), size, (c.count('\n')+1)*size, CalculateLength(c)*size)
else: # positioned comment
c = str(comment.childNodes[0].wholeText)
yield (float(p[0]), int(p[4]), i, c, 'bilipos', int(p[3]), int(p[2]), 0, 0)
2013-09-15 10:55:36 +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-15 10:55:36 +08:00
continue
2013-09-15 09:39:05 +08:00
def ReadCommentsTudou(f, fontsize):
comment_element = json.load(f)
2013-11-03 18:34:09 +08:00
for i, comment in enumerate(comment_element['comment_list']):
try:
assert comment['pos'] in (3, 4, 6)
c = str(comment['data'])
assert comment['size'] in (0, 1, 2)
size = {0: 0.64, 1: 1, 2: 1.44}[comment['size']]*fontsize
yield (int(comment['replay_time']*0.001), int(comment['commit_time']), i, c, {3: 0, 4: 2, 6: 1}[comment['pos']], int(comment['color']), size, (c.count('\n')+1)*size, CalculateLength(c)*size)
except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
logging.warning(_('Invalid comment: %r') % comment)
continue
def ReadCommentsMioMio(f, fontsize):
NiconicoColorMap = {'red': 0xff0000, 'pink': 0xff8080, 'orange': 0xffc000, 'yellow': 0xffff00, 'green': 0x00ff00, 'cyan': 0x00ffff, 'blue': 0x0000ff, 'purple': 0xc000ff, 'black': 0x000000}
dom = xml.dom.minidom.parse(f)
comment_element = dom.getElementsByTagName('data')
2013-11-03 18:34:09 +08:00
for i, comment in enumerate(comment_element):
try:
message = comment.getElementsByTagName('message')[0]
c = str(message.childNodes[0].wholeText)
pos = 0
size = int(message.getAttribute('fontsize'))*fontsize/25.0
2013-11-03 02:21:54 +08:00
yield (float(comment.getElementsByTagName('playTime')[0].childNodes[0].wholeText), int(calendar.timegm(time.strptime(comment.getElementsByTagName('times')[0].childNodes[0].wholeText, '%Y-%m-%d %H:%M:%S')))-28800, i, c, {'1': 0, '4': 2, '5': 1}[message.getAttribute('mode')], int(message.getAttribute('color')), size, (c.count('\n')+1)*size, CalculateLength(c)*size)
except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
logging.warning(_('Invalid comment: %s') % comment.toxml())
continue
2013-10-13 14:56:51 +08:00
def ReadCommentsSH5V(f, fontsize):
comment_element = json.load(f)
2013-11-03 18:34:09 +08:00
for i, comment in enumerate(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)
except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
logging.warning(_('Invalid comment: %r') % comment)
continue
CommentFormatMap = {None: None, 'Niconico': ReadCommentsNiconico, 'Acfun': ReadCommentsAcfun, 'Bilibili': ReadCommentsBilibili, 'Tudou': ReadCommentsTudou, 'MioMio': ReadCommentsMioMio, 'sH5V': ReadCommentsSH5V}
2013-11-02 20:11:38 +08:00
def WriteCommentBilibiliPositioned(f, c, width, height, styleid):
2013-11-17 14:00:45 +08:00
BiliPlayerSize = (672, 438)
2013-12-07 18:27:17 +08:00
ZoomFactor = GetZoomFactor(BiliPlayerSize, (width, height))
2013-11-17 09:10:12 +08:00
def GetPosition(InputPos, isHeight):
isHeight = int(isHeight) # True -> 1
if isinstance(InputPos, int):
return ZoomFactor[0]*InputPos+ZoomFactor[isHeight+1]
elif isinstance(InputPos, float):
if InputPos > 1:
return ZoomFactor[0]*InputPos+ZoomFactor[isHeight+1]
else:
return BiliPlayerSize[isHeight]*ZoomFactor[0]*InputPos+ZoomFactor[isHeight+1]
else:
try:
InputPos = int(InputPos)
except ValueError:
InputPos = float(InputPos)
return GetPosition(InputPos, isHeight)
2013-11-17 09:10:12 +08:00
try:
comment_args = safe_list(json.loads(c[3]))
2013-11-17 14:59:25 +08:00
text = ASSEscape(str(comment_args[4]).replace('/n', '\n'))
from_x = comment_args.get(0, 0)
from_y = comment_args.get(1, 0)
to_x = comment_args.get(7, from_x)
to_y = comment_args.get(8, from_y)
from_x = round(GetPosition(from_x, False))
from_y = round(GetPosition(from_y, True))
to_x = round(GetPosition(to_x, False))
to_y = round(GetPosition(to_y, True))
alpha = safe_list(str(comment_args.get(2, '1')).split('-'))
from_alpha = float(alpha.get(0, 1))
to_alpha = float(alpha.get(1, from_alpha))
from_alpha = 255-round(from_alpha*255)
to_alpha = 255-round(to_alpha*255)
rotate_z = -int(comment_args.get(5, 0))
2013-11-17 13:48:06 +08:00
rotate_y = -int(comment_args.get(6, 0))
lifetime = float(comment_args.get(3, 4500))
duration = int(comment_args.get(9, lifetime*1000))
delay = int(comment_args.get(10, 0))
fontface = comment_args.get(12)
isborder = comment_args.get(11, 'true')
styles = []
if (from_x, from_y) == (to_x, to_y):
2013-11-16 22:37:58 +08:00
styles.append('\\pos(%s, %s)' % (from_x, from_y))
else:
2013-11-16 22:37:58 +08:00
styles.append('\\move(%s, %s, %s, %s, %s, %s)' % (from_x, from_y, to_x, to_y, delay, delay+duration))
if rotate_z != 0:
2013-11-16 22:37:58 +08:00
styles.append('\\frz%s' % rotate_z)
2013-11-24 12:59:09 +08:00
if rotate_y != 0:
styles.append('\\frx%s' % (rotate_y*math.sin(rotate_z*math.pi/180)))
styles.append('\\fry%s' % (rotate_y*math.cos(rotate_z*math.pi/180)))
elif rotate_y != 0:
2013-11-16 22:37:58 +08:00
styles.append('\\fry%s' % rotate_y)
if fontface:
styles.append('\\fn%s' % ASSEscape(fontface))
2013-11-16 22:37:58 +08:00
styles.append('\\fs%s' % round(c[6]*ZoomFactor[0]))
if c[5] != 0xffffff:
2013-11-16 22:37:58 +08:00
styles.append('\\c&H%02X%02X%02x&' % (c[5] & 0xff, (c[5] >> 8) & 0xff, (c[5] >> 16) & 0xff))
if c[5] == 0x000000:
2013-11-16 22:37:58 +08:00
styles.append('\\3c&HFFFFFF&')
if from_alpha == to_alpha:
2013-11-16 22:37:58 +08:00
styles.append('\\alpha&H%02X' % from_alpha)
elif (from_alpha, to_alpha) == (255, 0):
styles.append('\\fad(%s,0)' % (lifetime*1000))
elif (from_alpha, to_alpha) == (0, 255):
styles.append('\\fad(0, %s)' % (lifetime*1000))
else:
2013-11-24 14:08:22 +08:00
styles.append('\\fade(%(from_alpha)s, %(to_alpha)s, %(to_alpha)s, 0, %(end_time)s, %(end_time)s, %(end_time)s)' % {'from_alpha': from_alpha, 'to_alpha': to_alpha, 'end_time': lifetime*1000})
if isborder == 'false':
2013-11-16 22:37:58 +08:00
styles.append('\\bord0')
2013-12-01 16:56:42 +08:00
f.write('Dialogue: -1,%(start)s,%(end)s,%(styleid)s,,0,0,0,,{%(styles)s}%(text)s\n' % {'start': ConvertTimestamp(c[0]), 'end': ConvertTimestamp(c[0]+lifetime), 'styles': ''.join(styles), 'text': text, 'styleid': styleid})
except (IndexError, ValueError) as e:
try:
logging.warning(_('Invalid comment: %r') % c[3])
except IndexError:
logging.warning(_('Invalid comment: %r') % c)
def WriteCommentAcfunPositioned(f, c, width, height, styleid):
AcfunPlayerSize = (650, 445)
ZoomFactor = GetZoomFactor(AcfunPlayerSize, (width, height))
def GetPosition(InputPos, isHeight):
isHeight = int(isHeight) # True -> 1
return AcfunPlayerSize[isHeight]*ZoomFactor[0]*InputPos*0.001+ZoomFactor[isHeight+1]
try:
comment_args = c[3]
text = str(comment_args['n'])
styles = []
anchor = {0: 7, 1: 8, 2: 9, 3: 4, 4: 5, 5: 6, 6: 1, 7: 2, 8: 3}.get(comment_args.get('c', 0), 7)
if anchor != 7:
styles.append('\\an%s' % anchor)
from_pos = dict(comment_args.get('p', {'x': 0, 'y': 0}))
from_x = round(GetPosition(int(from_pos.get('x', 0)), False))
from_y = round(GetPosition(int(from_pos.get('y', 0)), True))
styles.append('\\pos(%s, %s)' % (from_x, from_y))
styles.append('\\fs%s' % round(c[6]*ZoomFactor[0]))
scale_x = round(float(comment_args.get('e', 1.0))*100)
if scale_x != 100:
styles.append('\\fscx%s' % scale_x)
scale_y = round(float(comment_args.get('f', 1.0))*100)
if scale_y != 100:
styles.append('\\fscy%s' % scale_y)
rotate_z = -float(comment_args.get('r', 0.0))
rotate_y = -float(comment_args.get('k', 0.0))
if not (-1 < rotate_z < 1):
styles.append('\\frz%s' % round(rotate_z))
if not (-1 < rotate_y < 1):
styles.append('\\frx%s' % round(rotate_y*math.sin(rotate_z*math.pi/180)))
styles.append('\\fry%s' % round(rotate_y*math.cos(rotate_z*math.pi/180)))
elif not (-1 < rotate_y < 1):
styles.append('\\fry%s' % round(rotate_y))
font = comment_args.get('w')
if font:
font = dict(font)
fontface = font.get('f')
if fontface:
styles.append('\\fn%s' % ASSEscape(str(fontface)))
fontbold = bool(font.get('b'))
if fontbold:
styles.append('\\b1')
if c[5] != 0xffffff:
styles.append('\\c&H%02X%02X%02x&' % (c[5] & 0xff, (c[5] >> 8) & 0xff, (c[5] >> 16) & 0xff))
if c[5] == 0x000000:
styles.append('\\3c&HFFFFFF&')
from_alpha = 255-round(float(comment_args.get('a', 1.0))*255)
styles.append('\\alpha&H%02X' % from_alpha)
isborder = bool(comment_args.get('b', True))
if not isborder:
styles.append('\\bord0')
appear_time = float(comment_args.get('t', 0.0))
action_time = float(comment_args.get('l', 3.0))
actions = list(comment_args.get('z', []))
to_x, to_y = from_x, from_y
for action in actions:
action = dict(action)
duration = float(action.get('l', 0.0))
if duration <= 0.0:
continue
action_styles = []
if 'x' in action:
to_x = round(GetPosition(int(action['x']), False))
if 'y' in action:
to_y = round(GetPosition(int(action['y']), True))
if ('x' in action) or ('y' in action):
action_styles.append('\\pos(%s, %s)' % (to_x, to_y))
if 'f' in action:
action_styles.append('\\fscx%s' % round(float(action['f'])*100))
if 'g' in action:
action_styles.append('\\fscy%s' % round(float(action['g'])*100))
if action_styles:
styles.append('\\t(%s, %s, %s)' % (round(action_time*1000), round((action_time+duration)*1000), ''.join(action_styles)))
action_time += duration
f.write('Dialogue: -1,%(start)s,%(end)s,%(styleid)s,,0,0,0,,{%(styles)s}%(text)s\n' % {'start': ConvertTimestamp(c[0]+appear_time), 'end': ConvertTimestamp(c[0]+appear_time+action_time), 'styles': ''.join(styles), 'text': text, 'styleid': styleid})
except (IndexError, ValueError) as e:
logging.warning(_('Invalid comment: %r') % c[3])
2013-12-07 18:27:17 +08:00
# Result: (f, dx, dy)
# To convert: NewX = f*x+dx, NewY = f*y+dy
def GetZoomFactor(SourceSize, TargetSize):
try:
if (SourceSize, TargetSize) == GetZoomFactor.Cached_Size:
return GetZoomFactor.Cached_Result
except AttributeError:
pass
GetZoomFactor.Cached_Size = (SourceSize, TargetSize)
try:
SourceAspect = SourceSize[0]/SourceSize[1]
TargetAspect = TargetSize[0]/TargetSize[1]
if TargetAspect < SourceAspect: # narrower
ScaleFactor = TargetSize[0]/SourceSize[0]
GetZoomFactor.Cached_Result = (ScaleFactor, 0, (TargetSize[1]-TargetSize[0]/SourceAspect)/2)
elif TargetAspect > SourceAspect: # wider
ScaleFactor = TargetSize[1]/SourceSize[1]
GetZoomFactor.Cached_Result = (ScaleFactor, (TargetSize[0]-TargetSize[1]*SourceAspect)/2, 0)
else:
GetZoomFactor.Cached_Result = (TargetSize[0]/SourceSize[0], 0, 0)
return GetZoomFactor.Cached_Result
except ZeroDivisionError:
GetZoomFactor.Cached_Result = (1, 0, 0)
return GetZoomFactor.Cached_Result
2013-11-03 18:31:54 +08:00
def ProcessComments(comments, f, width, height, bottomReserved, fontface, fontsize, alpha, lifetime, reduced, progress_callback):
2013-11-02 20:32:36 +08:00
styleid = 'Danmaku2ASS_%04x' % random.randint(0, 0xffff)
WriteASSHead(f, width, height, fontface, fontsize, alpha, styleid)
2013-12-01 16:48:28 +08:00
rows = [[None]*(height-bottomReserved+1) for i in range(4)]
2013-11-03 18:31:54 +08:00
for idx, i in enumerate(comments):
if progress_callback and idx % 1000 == 0:
2013-11-03 18:31:54 +08:00
progress_callback(idx, len(comments))
if isinstance(i[4], int):
row = 0
rowmax = height-bottomReserved-i[7]
2013-12-01 16:48:28 +08:00
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, styleid)
break
else:
row += freerows or 1
2013-11-02 19:58:10 +08:00
else:
if not reduced:
row = FindAlternativeRow(rows, i, height, bottomReserved)
MarkCommentRow(rows, i, row)
WriteComment(f, i, row, width, height, bottomReserved, fontsize, lifetime, styleid)
elif i[4] == 'bilipos':
WriteCommentBilibiliPositioned(f, i, width, height, styleid)
elif i[4] == 'acfunpos':
WriteCommentAcfunPositioned(f, i, width, height, styleid)
2013-11-02 19:58:10 +08:00
else:
logging.warning(_('Invalid comment: %r') % i[3])
2013-11-03 18:31:54 +08:00
if progress_callback:
progress_callback(len(comments), len(comments))
2013-11-02 19:58:10 +08:00
def TestFreeRows(rows, c, row, width, height, bottomReserved, lifetime):
res = 0
2013-12-02 22:38:44 +08:00
rowmax = height-bottomReserved
targetRow = None
2013-11-24 13:59:15 +08:00
if c[4] in (1, 2):
2013-12-02 22:38:44 +08:00
while row < rowmax and res < c[7]:
if targetRow != rows[c[4]][row]:
targetRow = rows[c[4]][row]
if targetRow and targetRow[0]+lifetime > c[0]:
break
row += 1
res += 1
2013-11-24 13:59:15 +08:00
else:
try:
thresholdTime = c[0]-lifetime*(1-width/(c[8]+width))
except ZeroDivisionError:
thresholdTime = c[0]-lifetime
2013-12-02 22:38:44 +08:00
while row < rowmax and res < c[7]:
if targetRow != rows[c[4]][row]:
targetRow = rows[c[4]][row]
try:
if targetRow and (targetRow[0] > thresholdTime or targetRow[0]+targetRow[8]*lifetime/(targetRow[8]+width) > c[0]):
break
except ZeroDivisionError:
pass
row += 1
res += 1
2013-11-02 19:58:10 +08:00
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
elif rows[c[4]][row][0] < rows[c[4]][res][0]:
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
2013-11-02 20:32:36 +08:00
def WriteASSHead(f, width, height, fontface, fontsize, alpha, styleid):
2013-09-15 13:11:58 +08:00
f.write(
2013-09-19 22:49:41 +08:00
'''\ufeff
[Script Info]
2013-11-03 00:29:32 +08:00
; Script generated by Danmaku2ASS
; https://github.com/m13253/danmaku2ass
2013-11-17 14:59:25 +08:00
Script Updated By: Danmaku2ASS (https://github.com/m13253/danmaku2ass)
2013-09-15 13:11:58 +08:00
ScriptType: v4.00+
2013-11-17 14:59:25 +08:00
WrapStyle: 2
2013-09-15 13:11:58 +08: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-11-17 09:09:13 +08:00
Style: %(styleid)s, %(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, %(outline)s, 0, 7, 0, 0, 0, 0
2013-09-15 13:11:58 +08:00
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
2013-11-17 09:09:13 +08:00
''' % {'width': width, 'height': height, 'fontface': fontface, 'fontsize': round(fontsize), 'alpha': 255-round(alpha*255), 'outline': round(fontsize/25), 'styleid': styleid}
2013-09-15 13:11:58 +08:00
)
2013-11-02 20:32:36 +08:00
def WriteComment(f, c, row, width, height, bottomReserved, fontsize, lifetime, styleid):
2013-11-17 14:59:25 +08:00
text = ASSEscape(c[3])
2013-11-16 22:37:58 +08:00
styles = []
2013-11-02 19:58:10 +08:00
if c[4] == 1:
2013-11-16 22:37:58 +08:00
styles.append('\\an8\\pos(%(halfwidth)s, %(row)s)' % {'halfwidth': round(width/2), 'row': row})
2013-11-02 19:58:10 +08:00
elif c[4] == 2:
2013-11-16 22:37:58 +08:00
styles.append('\\an2\\pos(%(halfwidth)s, %(row)s)' % {'halfwidth': round(width/2), 'row': ConvertType2(row, height, bottomReserved)})
2013-11-17 13:52:46 +08:00
elif c[4] == 3:
styles.append('\\move(%(neglen)s, %(row)s, %(width)s, %(row)s)' % {'width': width, 'row': row, 'neglen': -math.ceil(c[8])})
2013-11-02 19:58:10 +08:00
else:
2013-11-16 22:37:58 +08:00
styles.append('\\move(%(width)s, %(row)s, %(neglen)s, %(row)s)' % {'width': width, 'row': row, 'neglen': -math.ceil(c[8])})
2013-11-02 19:58:10 +08:00
if not (-1 < c[6]-fontsize < 1):
2013-11-16 22:37:58 +08:00
styles.append('\\fs%s' % round(c[6]))
2013-11-02 19:58:10 +08:00
if c[5] != 0xffffff:
2013-11-16 22:37:58 +08:00
styles.append('\\c&H%02X%02X%02x&' % (c[5] & 0xff, (c[5] >> 8) & 0xff, (c[5] >> 16) & 0xff))
2013-11-02 19:58:10 +08:00
if c[5] == 0x000000:
2013-11-16 22:37:58 +08:00
styles.append('\\3c&HFFFFFF&')
f.write('Dialogue: 2,%(start)s,%(end)s,%(styleid)s,,0000,0000,0000,,{%(styles)s}%(text)s\n' % {'start': ConvertTimestamp(c[0]), 'end': ConvertTimestamp(c[0]+lifetime), 'styles': ''.join(styles), 'text': text, 'styleid': styleid})
2013-09-15 13:11:58 +08:00
2013-11-17 14:59:25 +08:00
def ASSEscape(s):
return '\\N'.join((i or ' ' for i in str(s).replace('\\', '\\\\').replace('{', '\\{').replace('}', '\\}').split('\n')))
2013-09-15 13:11:58 +08:00
def CalculateLength(s):
2013-11-02 20:11:38 +08:00
return max(map(len, s.split('\n'))) # May not be accurate
2013-09-15 13:11:58 +08:00
2013-11-02 19:58:10 +08:00
def ConvertTimestamp(timestamp):
2013-11-16 22:11:31 +08:00
timestamp = round(timestamp*100.0)
hour, minute = divmod(timestamp, 360000)
minute, second = divmod(minute, 6000)
second, centsecond = divmod(second, 100)
return '%d:%02d:%02d.%02d' % (int(hour), int(minute), int(second), int(centsecond))
2013-11-02 19:58:10 +08:00
2013-09-15 13:11:58 +08:00
def ConvertType2(row, height, bottomReserved):
return height-bottomReserved-row
def ConvertToFile(filename_or_file, *args, **kwargs):
if isinstance(filename_or_file, str):
return open(filename_or_file, *args, **kwargs)
else:
return filename_or_file
2013-11-03 11:59:52 +08:00
def FilterBadChars(f):
s = f.read()
2013-11-03 22:58:05 +08:00
s = re.sub('[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f]', '\ufffd', s)
2013-11-03 11:59:52 +08:00
return io.StringIO(s)
class safe_list(list):
def get(self, index, default=None):
try:
return self[index]
except IndexError:
return default
def export(func):
global __all__
try:
__all__.append(func.__name__)
except NameError:
__all__ = [func.__name__]
return func
@export
2013-11-03 18:31:54 +08:00
def Danmaku2ASS(input_files, output_file, stage_width, stage_height, reserve_blank=0, font_face=_('(FONT) sans-serif')[7:], font_size=25.0, text_opaque=1.0, comment_duration=5.0, is_reduce_comments=False, progress_callback=None):
fo = None
comments = ReadComments(input_files, font_size)
try:
if output_file:
fo = ConvertToFile(output_file, 'w', encoding='utf-8', newline='\r\n')
else:
fo = sys.stdout
2013-11-03 18:31:54 +08:00
ProcessComments(comments, fo, stage_width, stage_height, reserve_blank, font_face, font_size, text_opaque, comment_duration, is_reduce_comments, progress_callback)
finally:
if output_file and fo:
fo.close()
@export
def ReadComments(input_files, font_size=25.0, progress_callback=None):
if isinstance(input_files, str):
input_files = [input_files]
else:
input_files = list(input_files)
comments = []
for idx, i in enumerate(input_files):
if progress_callback:
progress_callback(idx, len(input_files))
with ConvertToFile(i, 'r', encoding='utf-8') as f:
CommentProcessor = GetCommentProcessor(f)
if not CommentProcessor:
raise ValueError(_('Unknown comment file format: %s') % i)
comments.extend(CommentProcessor(FilterBadChars(f), font_size))
if progress_callback:
progress_callback(len(input_files), len(input_files))
comments.sort()
return comments
@export
def GetCommentProcessor(input_file):
return CommentFormatMap[ProbeCommentFormat(input_file)]
2013-11-02 19:58:10 +08:00
def main():
if len(sys.argv) == 1:
sys.argv.append('--help')
2013-09-15 09:39:05 +08:00
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'))
2013-11-24 15:37:59 +08:00
parser.add_argument('-fn', '--font', metavar=_('FONT'), help=_('Specify font face [default: %s]') % _('(FONT) sans-serif')[7:], default=_('(FONT) sans-serif')[7:])
parser.add_argument('-fs', '--fontsize', metavar=_('SIZE'), help=(_('Default font size [default: %s]') % 25), type=float, default=25.0)
2013-09-30 23:27:33 +08:00
parser.add_argument('-a', '--alpha', metavar=_('ALPHA'), help=_('Text opaque'), type=float, default=1.0)
2013-11-24 15:37:59 +08:00
parser.add_argument('-l', '--lifetime', metavar=_('SECONDS'), help=_('Duration of comment display [default: %s]') % 5, type=float, default=5.0)
2013-09-30 23:27:33 +08:00
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)
Danmaku2ASS(args.file, args.output, width, height, args.protect, args.font, args.fontsize, args.alpha, args.lifetime, args.reduce)
2013-11-02 19:58:10 +08:00
if __name__ == '__main__':
main()