b9acd95b15
Add another if-else statement in ProbeCommentFormat(f) to distinguish NicoFox and MioMio. Feel free to change the probing method to improve readibility
842 lines
37 KiB
Python
Executable File
842 lines
37 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
# The original author of this program, Danmaku2ASS, is StarBrilliant.
|
|
# This file is released under General Public License version 3.
|
|
# You should have received a copy of General Public License text alongside with
|
|
# this program. If not, you can obtain it at http://gnu.org/copyleft/gpl.html .
|
|
# This program comes with no warranty, the author will not be resopnsible for
|
|
# any damage or problems caused by this program.
|
|
|
|
# You can obtain a latest copy of Danmaku2ASS at:
|
|
# https://github.com/m13253/danmaku2ass
|
|
# Please update to the latest version before complaining.
|
|
|
|
import argparse
|
|
import calendar
|
|
import gettext
|
|
import io
|
|
import json
|
|
import logging
|
|
import math
|
|
import os
|
|
import random
|
|
import re
|
|
import sys
|
|
import time
|
|
import xml.dom.minidom
|
|
|
|
|
|
if sys.version_info < (3,):
|
|
raise RuntimeError('at least Python 3.0 is required')
|
|
|
|
gettext.install('danmaku2ass', os.path.join(os.path.dirname(os.path.abspath(os.path.realpath(sys.argv[0] or 'locale'))), 'locale'))
|
|
|
|
|
|
def SeekZero(function):
|
|
def decorated_function(file_):
|
|
file_.seek(0)
|
|
try:
|
|
return function(file_)
|
|
finally:
|
|
file_.seek(0)
|
|
return decorated_function
|
|
|
|
|
|
def EOFAsNone(function):
|
|
def decorated_function(*args, **kwargs):
|
|
try:
|
|
return function(*args, **kwargs)
|
|
except EOFError:
|
|
return None
|
|
return decorated_function
|
|
|
|
|
|
@SeekZero
|
|
@EOFAsNone
|
|
def ProbeCommentFormat(f):
|
|
tmp = f.read(1)
|
|
if tmp == '[':
|
|
return 'Acfun'
|
|
# It is unwise to wrap a JSON object in an array!
|
|
# See this: http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx/
|
|
# Do never follow what Acfun developers did!
|
|
elif tmp == '{':
|
|
tmp = f.read(14)
|
|
if tmp == '"status_code":':
|
|
return 'Tudou'
|
|
elif tmp == '"root":{"total':
|
|
return 'sH5V'
|
|
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<':
|
|
tmp = f.read(20)
|
|
if tmp == '!-- BoonSutazioData=':
|
|
return 'Niconico' # Niconico videos downloaded with NicoFox
|
|
else:
|
|
return 'MioMio'
|
|
elif tmp == 'p':
|
|
return 'Niconico' # Himawari Douga, with the same file format as Niconico Douga
|
|
|
|
|
|
#
|
|
# 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,
|
|
# 2 for top centered comment,
|
|
# 3 for reversed moving comment
|
|
# 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.
|
|
#
|
|
|
|
|
|
def ReadCommentsNiconico(f, fontsize):
|
|
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}
|
|
dom = xml.dom.minidom.parse(f)
|
|
comment_element = dom.getElementsByTagName('chat')
|
|
for comment in comment_element:
|
|
try:
|
|
c = str(comment.childNodes[0].wholeText)
|
|
if c.startswith('/'):
|
|
continue # ignore advanced comments
|
|
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)
|
|
except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
|
|
logging.warning(_('Invalid comment: %s') % comment.toxml())
|
|
continue
|
|
|
|
|
|
def ReadCommentsAcfun(f, fontsize):
|
|
comment_element = json.load(f)
|
|
for i, comment in enumerate(comment_element):
|
|
try:
|
|
p = str(comment['c']).split(',')
|
|
assert len(p) >= 6
|
|
assert p[2] in ('1', '2', '4', '5', '7')
|
|
size = int(p[3])*fontsize/25.0
|
|
if p[2] != '7':
|
|
c = str(comment['m']).replace('\\r', '\n').replace('\r', '\n')
|
|
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(comment['m']))
|
|
yield (float(p[0]), int(p[5]), i, c, 'acfunpos', int(p[1]), size, 0, 0)
|
|
except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
|
|
logging.warning(_('Invalid comment: %r') % comment)
|
|
continue
|
|
|
|
|
|
def ReadCommentsBilibili(f, fontsize):
|
|
dom = xml.dom.minidom.parse(f)
|
|
comment_element = dom.getElementsByTagName('d')
|
|
for i, comment in enumerate(comment_element):
|
|
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
|
|
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)
|
|
except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
|
|
logging.warning(_('Invalid comment: %s') % comment.toxml())
|
|
continue
|
|
|
|
|
|
def ReadCommentsTudou(f, fontsize):
|
|
comment_element = json.load(f)
|
|
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')
|
|
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
|
|
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
|
|
|
|
|
|
def ReadCommentsSH5V(f, fontsize):
|
|
comment_element = json.load(f)
|
|
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
|
|
if c_type != '7':
|
|
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)
|
|
else:
|
|
c_x = float(comment['x'])
|
|
c_y = float(comment['y'])
|
|
size = int(comment['size'])
|
|
dur = int(comment['dur'])
|
|
data1 = float(comment['data1'])
|
|
data2 = float(comment['data2'])
|
|
data3 = int(comment['data3'])
|
|
data4 = int(comment['data4'])
|
|
yield (float(c_at), int(c_date), i, c, 'sH5Vpos', int(c_color[1:], 16), size, 0, 0, c_x, c_y, dur, data1, data2, data3, data4)
|
|
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}
|
|
|
|
|
|
def WriteCommentBilibiliPositioned(f, c, width, height, styleid):
|
|
#BiliPlayerSize = (512, 384) # Bilibili player version 2010
|
|
#BiliPlayerSize = (540, 384) # Bilibili player version 2012
|
|
BiliPlayerSize = (672, 438) # Bilibili player version 2014
|
|
ZoomFactor = GetZoomFactor(BiliPlayerSize, (width, height))
|
|
|
|
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)
|
|
|
|
try:
|
|
comment_args = safe_list(json.loads(c[3]))
|
|
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 = GetPosition(from_x, False)
|
|
from_y = GetPosition(from_y, True)
|
|
to_x = GetPosition(to_x, False)
|
|
to_y = 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))
|
|
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')
|
|
from_rotarg = ConvertFlashRotation(rotate_y, rotate_z, from_x, from_y, width, height)
|
|
to_rotarg = ConvertFlashRotation(rotate_y, rotate_z, to_x, to_y, width, height)
|
|
styles = ['\\org(%d, %d)' % (width/2, height/2)]
|
|
if from_rotarg[0:2] == to_rotarg[0:2]:
|
|
styles.append('\\pos(%.0f, %.0f)' % (from_rotarg[0:2]))
|
|
else:
|
|
styles.append('\\move(%.0f, %.0f, %.0f, %.0f, %.0f, %.0f)' % (from_rotarg[0:2]+to_rotarg[0:2]+(delay, delay+duration)))
|
|
styles.append('\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f' % (from_rotarg[2:7]))
|
|
if (from_x, from_y) != (to_x, to_y):
|
|
styles.append('\\t(%d, %d, ' % (delay, delay+duration))
|
|
styles.append('\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f' % (to_rotarg[2:7]))
|
|
styles.append(')')
|
|
if fontface:
|
|
styles.append('\\fn%s' % ASSEscape(fontface))
|
|
styles.append('\\fs%.0f' % (c[6]*ZoomFactor[0]))
|
|
if c[5] != 0xffffff:
|
|
styles.append('\\c&H%s&' % ConvertColor(c[5]))
|
|
if c[5] == 0x000000:
|
|
styles.append('\\3c&HFFFFFF&')
|
|
if from_alpha == to_alpha:
|
|
styles.append('\\alpha&H%02X' % from_alpha)
|
|
elif (from_alpha, to_alpha) == (255, 0):
|
|
styles.append('\\fad(%.0f,0)' % (lifetime*1000))
|
|
elif (from_alpha, to_alpha) == (0, 255):
|
|
styles.append('\\fad(0, %.0f)' % (lifetime*1000))
|
|
else:
|
|
styles.append('\\fade(%(from_alpha)d, %(to_alpha)d, %(to_alpha)d, 0, %(end_time).0f, %(end_time).0f, %(end_time).0f)' % {'from_alpha': from_alpha, 'to_alpha': to_alpha, 'end_time': lifetime*1000})
|
|
if isborder == 'false':
|
|
styles.append('\\bord0')
|
|
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 = (560, 400)
|
|
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]
|
|
|
|
def GetTransformStyles(x=None, y=None, scale_x=None, scale_y=None, rotate_z=None, rotate_y=None, color=None, alpha=None):
|
|
styles = []
|
|
out_x, out_y = x, y
|
|
if rotate_z is not None and rotate_y is not None:
|
|
assert x is not None
|
|
assert y is not None
|
|
rotarg = ConvertFlashRotation(rotate_y, rotate_z, x, y, width, height)
|
|
out_x, out_y = rotarg[0:2]
|
|
if scale_x is None:
|
|
scale_x = 1
|
|
if scale_y is None:
|
|
scale_y = 1
|
|
styles.append('\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f' % (rotarg[2:5]+(rotarg[5]*scale_x, rotarg[6]*scale_y)))
|
|
else:
|
|
if scale_x is not None:
|
|
styles.append('\\fscx%.0f' % (scale_x*100))
|
|
if scale_y is not None:
|
|
styles.append('\\fscy%.0f' % (scale_y*100))
|
|
if color is not None:
|
|
styles.append('\\c&H%s&' % ConvertColor(color))
|
|
if color == 0x000000:
|
|
styles.append('\\3c&HFFFFFF&')
|
|
if alpha is not None:
|
|
alpha = 255-round(alpha*255)
|
|
styles.append('\\alpha&H%02X' % alpha)
|
|
return out_x, out_y, styles
|
|
|
|
def FlushCommentLine(f, text, styles, start_time, end_time, styleid):
|
|
if end_time > start_time:
|
|
f.write('Dialogue: -1,%(start)s,%(end)s,%(styleid)s,,0,0,0,,{%(styles)s}%(text)s\n' % {'start': ConvertTimestamp(start_time), 'end': ConvertTimestamp(end_time), 'styles': ''.join(styles), 'text': text, 'styleid': styleid})
|
|
|
|
try:
|
|
comment_args = c[3]
|
|
text = ASSEscape(str(comment_args['n']).replace('\r', '\n'))
|
|
common_styles = ['\org(%d, %d)' % (width/2, height/2)]
|
|
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:
|
|
common_styles.append('\\an%s' % anchor)
|
|
font = comment_args.get('w')
|
|
if font:
|
|
font = dict(font)
|
|
fontface = font.get('f')
|
|
if fontface:
|
|
common_styles.append('\\fn%s' % ASSEscape(str(fontface)))
|
|
fontbold = bool(font.get('b'))
|
|
if fontbold:
|
|
common_styles.append('\\b1')
|
|
common_styles.append('\\fs%.0f' % (c[6]*ZoomFactor[0]))
|
|
isborder = bool(comment_args.get('b', True))
|
|
if not isborder:
|
|
common_styles.append('\\bord0')
|
|
to_pos = dict(comment_args.get('p', {'x': 0, 'y': 0}))
|
|
to_x = round(GetPosition(int(to_pos.get('x', 0)), False))
|
|
to_y = round(GetPosition(int(to_pos.get('y', 0)), True))
|
|
to_scale_x = float(comment_args.get('e', 1.0))
|
|
to_scale_y = float(comment_args.get('f', 1.0))
|
|
to_rotate_z = float(comment_args.get('r', 0.0))
|
|
to_rotate_y = float(comment_args.get('k', 0.0))
|
|
to_color = c[5]
|
|
to_alpha = float(comment_args.get('a', 1.0))
|
|
from_time = float(comment_args.get('t', 0.0))
|
|
action_time = float(comment_args.get('l', 3.0))
|
|
actions = list(comment_args.get('z', []))
|
|
to_out_x, to_out_y, transform_styles = GetTransformStyles(to_x, to_y, to_scale_x, to_scale_y, to_rotate_z, to_rotate_y, to_color, to_alpha)
|
|
FlushCommentLine(f, text, common_styles+['\\pos(%.0f, %.0f)' % (to_out_x, to_out_y)]+transform_styles, c[0]+from_time, c[0]+from_time+action_time, styleid)
|
|
action_styles = transform_styles
|
|
for action in actions:
|
|
action = dict(action)
|
|
from_x, from_y = to_x, to_y
|
|
from_out_x, from_out_y = to_out_x, to_out_y
|
|
from_scale_x, from_scale_y = to_scale_x, to_scale_y
|
|
from_rotate_z, from_rotate_y = to_rotate_z, to_rotate_y
|
|
from_color, from_alpha = to_color, to_alpha
|
|
transform_styles, action_styles = action_styles, []
|
|
from_time += action_time
|
|
action_time = float(action.get('l', 0.0))
|
|
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 'f' in action:
|
|
to_scale_x = float(action['f'])
|
|
if 'g' in action:
|
|
to_scale_y = float(action['g'])
|
|
if 'c' in action:
|
|
to_color = int(action['c'])
|
|
if 't' in action:
|
|
to_alpha = float(action['t'])
|
|
if 'd' in action:
|
|
to_rotate_z = float(action['d'])
|
|
if 'e' in action:
|
|
to_rotate_y = float(action['e'])
|
|
to_out_x, to_out_y, action_styles = GetTransformStyles(to_x, to_y, from_scale_x, from_scale_y, to_rotate_z, to_rotate_y, from_color, from_alpha)
|
|
if (from_out_x, from_out_y) == (to_out_x, to_out_y):
|
|
pos_style = '\\pos(%.0f, %.0f)' % (to_out_x, to_out_y)
|
|
else:
|
|
pos_style = '\\move(%.0f, %.0f, %.0f, %.0f)' % (from_out_x, from_out_y, to_out_x, to_out_y)
|
|
styles = common_styles+transform_styles
|
|
styles.append(pos_style)
|
|
if action_styles:
|
|
styles.append('\\t(%s)' % (''.join(action_styles)))
|
|
FlushCommentLine(f, text, styles, c[0]+from_time, c[0]+from_time+action_time, styleid)
|
|
except (IndexError, ValueError) as e:
|
|
logging.warning(_('Invalid comment: %r') % c[3])
|
|
|
|
|
|
def WriteCommentSH5VPositioned(f, c, width, height, styleid):
|
|
|
|
def GetTransformStyles(x=None, y=None, fsize=None, rotate_z=None, rotate_y=None, color=None, alpha=None):
|
|
styles = []
|
|
if x is not None and y is not None:
|
|
styles.append('\\pos(%.0f, %.0f)' % (x, y))
|
|
if fsize is not None:
|
|
styles.append('\\fs%.0f' % fsize)
|
|
if rotate_y is not None and rotate_z is not None:
|
|
styles.append('\\frz%.0f' % rotate_z)
|
|
styles.append('\\fry%.0f' % rotate_y)
|
|
if color is not None:
|
|
styles.append('\\c&H%s&' % ConvertColor(color))
|
|
if color == 0x000000:
|
|
styles.append('\\3c&HFFFFFF&')
|
|
if alpha is not None:
|
|
alpha = 255-round(alpha*255)
|
|
styles.append('\\alpha&H%02X' % alpha)
|
|
return styles
|
|
|
|
def FlushCommentLine(f, text, styles, start_time, end_time, styleid):
|
|
if end_time > start_time:
|
|
f.write('Dialogue: -1,%(start)s,%(end)s,%(styleid)s,,0,0,0,,{%(styles)s}%(text)s\n' % {'start': ConvertTimestamp(start_time), 'end': ConvertTimestamp(end_time), 'styles': ''.join(styles), 'text': text, 'styleid': styleid})
|
|
|
|
try:
|
|
text = ASSEscape(str(c[3]))
|
|
to_x = float(c[9])*width
|
|
to_y = float(c[10])*height
|
|
to_rotate_z = -int(c[14])
|
|
to_rotate_y = -int(c[15])
|
|
to_color = c[5]
|
|
to_alpha = float(c[12])
|
|
# Note: Alpha transition hasn't been worked out yet.
|
|
to_size = int(c[6])*math.sqrt(width*height/307200)
|
|
# Note: Because sH5V's data is the absolute size of font,temporarily solve by it at present.[*math.sqrt(width/640*height/480)]
|
|
# But it seems to be working fine...
|
|
from_time = float(c[0])
|
|
action_time = float(c[11])/1000
|
|
transform_styles = GetTransformStyles(to_x, to_y, to_size, to_rotate_z, to_rotate_y, to_color, to_alpha)
|
|
FlushCommentLine(f, text, transform_styles, from_time, from_time+action_time, styleid)
|
|
except (IndexError, ValueError) as e:
|
|
logging.warning(_('Invalid comment: %r') % c[3])
|
|
|
|
|
|
# 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
|
|
|
|
|
|
# Calculation is based on https://github.com/jabbany/CommentCoreLibrary/issues/5#issuecomment-40087282
|
|
# and https://github.com/m13253/danmaku2ass/issues/7#issuecomment-41489422
|
|
# ASS FOV = width*4/3.0
|
|
# But Flash FOV = width/math.tan(100*math.pi/360.0)/2 will be used instead
|
|
# Result: (transX, transY, rotX, rotY, rotZ, scaleX, scaleY)
|
|
def ConvertFlashRotation(rotY, rotZ, X, Y, width, height):
|
|
def WrapAngle(deg):
|
|
return 180-((180-deg) % 360)
|
|
rotY = WrapAngle(rotY)
|
|
rotZ = WrapAngle(rotZ)
|
|
if rotY in (90, -90):
|
|
rotY -= 1
|
|
if rotY == 0 or rotZ == 0:
|
|
outX = 0
|
|
outY = -rotY # Positive value means clockwise in Flash
|
|
outZ = -rotZ
|
|
rotY *= math.pi/180.0
|
|
rotZ *= math.pi/180.0
|
|
else:
|
|
rotY *= math.pi/180.0
|
|
rotZ *= math.pi/180.0
|
|
outY = math.atan2(-math.sin(rotY)*math.cos(rotZ), math.cos(rotY))*180/math.pi
|
|
outZ = math.atan2(-math.cos(rotY)*math.sin(rotZ), math.cos(rotZ))*180/math.pi
|
|
outX = math.asin(math.sin(rotY)*math.sin(rotZ))*180/math.pi
|
|
trX = (X*math.cos(rotZ)+Y*math.sin(rotZ))/math.cos(rotY)+(1-math.cos(rotZ)/math.cos(rotY))*width/2-math.sin(rotZ)/math.cos(rotY)*height/2
|
|
trY = Y*math.cos(rotZ)-X*math.sin(rotZ)+math.sin(rotZ)*width/2+(1-math.cos(rotZ))*height/2
|
|
trZ = (trX-width/2)*math.sin(rotY)
|
|
FOV = width*math.tan(2*math.pi/9.0)/2
|
|
try:
|
|
scaleXY = FOV/(FOV+trZ)
|
|
except ZeroDivisionError:
|
|
logging.error('Rotation makes object behind the camera: trZ == %.0f' % trZ)
|
|
scaleXY = 1
|
|
trX = (trX-width/2)*scaleXY+width/2
|
|
trY = (trY-height/2)*scaleXY+height/2
|
|
if scaleXY < 0:
|
|
scaleXY = -scaleXY
|
|
outX += 180
|
|
outY += 180
|
|
logging.error('Rotation makes object behind the camera: trZ == %.0f < %.0f' % (trZ, FOV))
|
|
return (trX, trY, WrapAngle(outX), WrapAngle(outY), WrapAngle(outZ), scaleXY*100, scaleXY*100)
|
|
|
|
|
|
def ProcessComments(comments, f, width, height, bottomReserved, fontface, fontsize, alpha, duration_marquee, duration_still, reduced, progress_callback):
|
|
styleid = 'Danmaku2ASS_%04x' % random.randint(0, 0xffff)
|
|
WriteASSHead(f, width, height, fontface, fontsize, alpha, styleid)
|
|
rows = [[None]*(height-bottomReserved+1) for i in range(4)]
|
|
for idx, i in enumerate(comments):
|
|
if progress_callback and idx % 1000 == 0:
|
|
progress_callback(idx, len(comments))
|
|
if isinstance(i[4], int):
|
|
row = 0
|
|
rowmax = height-bottomReserved-i[7]
|
|
while row <= rowmax:
|
|
freerows = TestFreeRows(rows, i, row, width, height, bottomReserved, duration_marquee, duration_still)
|
|
if freerows >= i[7]:
|
|
MarkCommentRow(rows, i, row)
|
|
WriteComment(f, i, row, width, height, bottomReserved, fontsize, duration_marquee, duration_still, styleid)
|
|
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, duration_marquee, duration_still, styleid)
|
|
elif i[4] == 'bilipos':
|
|
WriteCommentBilibiliPositioned(f, i, width, height, styleid)
|
|
elif i[4] == 'acfunpos':
|
|
WriteCommentAcfunPositioned(f, i, width, height, styleid)
|
|
elif i[4] == 'sH5Vpos':
|
|
WriteCommentSH5VPositioned(f, i, width, height, styleid)
|
|
else:
|
|
logging.warning(_('Invalid comment: %r') % i[3])
|
|
if progress_callback:
|
|
progress_callback(len(comments), len(comments))
|
|
|
|
|
|
def TestFreeRows(rows, c, row, width, height, bottomReserved, duration_marquee, duration_still):
|
|
res = 0
|
|
rowmax = height-bottomReserved
|
|
targetRow = None
|
|
if c[4] in (1, 2):
|
|
while row < rowmax and res < c[7]:
|
|
if targetRow != rows[c[4]][row]:
|
|
targetRow = rows[c[4]][row]
|
|
if targetRow and targetRow[0]+duration_still > c[0]:
|
|
break
|
|
row += 1
|
|
res += 1
|
|
else:
|
|
try:
|
|
thresholdTime = c[0]-duration_marquee*(1-width/(c[8]+width))
|
|
except ZeroDivisionError:
|
|
thresholdTime = c[0]-duration_marquee
|
|
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]*duration_marquee/(targetRow[8]+width) > c[0]):
|
|
break
|
|
except ZeroDivisionError:
|
|
pass
|
|
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
|
|
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
|
|
|
|
|
|
def WriteASSHead(f, width, height, fontface, fontsize, alpha, styleid):
|
|
f.write(
|
|
'''
|
|
[Script Info]
|
|
; Script generated by Danmaku2ASS
|
|
; https://github.com/m13253/danmaku2ass
|
|
Script Updated By: Danmaku2ASS (https://github.com/m13253/danmaku2ass)
|
|
ScriptType: v4.00+
|
|
PlayResX: %(width)d
|
|
PlayResY: %(height)d
|
|
Aspect Ratio: %(width)d:%(height)d
|
|
Collisions: Normal
|
|
WrapStyle: 2
|
|
ScaledBorderAndShadow: yes
|
|
YCbCr Matrix: TV.601
|
|
|
|
[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: %(styleid)s, %(fontface)s, %(fontsize).0f, &H%(alpha)02XFFFFFF, &H%(alpha)02XFFFFFF, &H%(alpha)02X000000, &H%(alpha)02X000000, 0, 0, 0, 0, 100, 100, 0.00, 0.00, 1, %(outline).0f, 0, 7, 0, 0, 0, 0
|
|
|
|
[Events]
|
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
''' % {'width': width, 'height': height, 'fontface': fontface, 'fontsize': fontsize, 'alpha': 255-round(alpha*255), 'outline': max(fontsize/25.0, 1), 'styleid': styleid}
|
|
)
|
|
|
|
|
|
def WriteComment(f, c, row, width, height, bottomReserved, fontsize, duration_marquee, duration_still, styleid):
|
|
text = ASSEscape(c[3])
|
|
styles = []
|
|
if c[4] == 1:
|
|
styles.append('\\an8\\pos(%(halfwidth)d, %(row)d)' % {'halfwidth': width/2, 'row': row})
|
|
duration = duration_still
|
|
elif c[4] == 2:
|
|
styles.append('\\an2\\pos(%(halfwidth)d, %(row)d)' % {'halfwidth': width/2, 'row': ConvertType2(row, height, bottomReserved)})
|
|
duration = duration_still
|
|
elif c[4] == 3:
|
|
styles.append('\\move(%(neglen)d, %(row)d, %(width)d, %(row)d)' % {'width': width, 'row': row, 'neglen': -math.ceil(c[8])})
|
|
duration = duration_marquee
|
|
else:
|
|
styles.append('\\move(%(width)d, %(row)d, %(neglen)d, %(row)d)' % {'width': width, 'row': row, 'neglen': -math.ceil(c[8])})
|
|
duration = duration_marquee
|
|
if not (-1 < c[6]-fontsize < 1):
|
|
styles.append('\\fs%.0f' % c[6])
|
|
if c[5] != 0xffffff:
|
|
styles.append('\\c&H%s&' % ConvertColor(c[5]))
|
|
if c[5] == 0x000000:
|
|
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]+duration), 'styles': ''.join(styles), 'text': text, 'styleid': styleid})
|
|
|
|
|
|
def ASSEscape(s):
|
|
def ReplaceLeadingSpace(s):
|
|
sstrip = s.strip(' ')
|
|
slen = len(s)
|
|
if slen == len(sstrip):
|
|
return s
|
|
else:
|
|
llen = slen-len(s.lstrip(' '))
|
|
rlen = slen-len(s.rstrip(' '))
|
|
return ''.join(('\u2007'*llen, sstrip, '\u2007'*rlen))
|
|
return '\\N'.join((ReplaceLeadingSpace(i) or ' ' for i in str(s).replace('\\', '\\\\').replace('{', '\\{').replace('}', '\\}').split('\n')))
|
|
|
|
|
|
def CalculateLength(s):
|
|
return max(map(len, s.split('\n'))) # May not be accurate
|
|
|
|
|
|
def ConvertTimestamp(timestamp):
|
|
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))
|
|
|
|
|
|
def ConvertColor(RGB, width=1280, height=576):
|
|
if RGB == 0x000000:
|
|
return '000000'
|
|
elif RGB == 0xffffff:
|
|
return 'FFFFFF'
|
|
R = (RGB >> 16) & 0xff
|
|
G = (RGB >> 8) & 0xff
|
|
B = RGB & 0xff
|
|
if width < 1280 and height < 576:
|
|
return '%02X%02X%02X' % (B, G, R)
|
|
else: # VobSub always uses BT.601 colorspace, convert to BT.709
|
|
ClipByte = lambda x: 255 if x > 255 else 0 if x < 0 else round(x)
|
|
return '%02X%02X%02X' % (
|
|
ClipByte(R*0.00956384088080656+G*0.03217254540203729+B*0.95826361371715607),
|
|
ClipByte(R*-0.10493933142075390+G*1.17231478191855154+B*-0.06737545049779757),
|
|
ClipByte(R*0.91348912373987645+G*0.07858536372532510+B*0.00792551253479842)
|
|
)
|
|
|
|
|
|
def ConvertType2(row, height, bottomReserved):
|
|
return height-bottomReserved-row
|
|
|
|
|
|
def ConvertToFile(filename_or_file, *args, **kwargs):
|
|
if isinstance(filename_or_file, bytes):
|
|
filename_or_file = str(bytes(filename_or_file).decode('utf-8', 'replace'))
|
|
if isinstance(filename_or_file, str):
|
|
return open(filename_or_file, *args, **kwargs)
|
|
else:
|
|
return filename_or_file
|
|
|
|
|
|
def FilterBadChars(f):
|
|
s = f.read()
|
|
s = re.sub('[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f]', '\ufffd', s)
|
|
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
|
|
def Danmaku2ASS(input_files, output_file, stage_width, stage_height, reserve_blank=0, font_face=_('(FONT) sans-serif')[7:], font_size=25.0, text_opacity=1.0, duration_marquee=5.0, duration_still=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-sig', errors='replace', newline='\r\n')
|
|
else:
|
|
fo = sys.stdout
|
|
ProcessComments(comments, fo, stage_width, stage_height, reserve_blank, font_face, font_size, text_opacity, duration_marquee, duration_still, is_reduce_comments, progress_callback)
|
|
finally:
|
|
if output_file and fo != output_file:
|
|
fo.close()
|
|
|
|
|
|
@export
|
|
def ReadComments(input_files, font_size=25.0, progress_callback=None):
|
|
if isinstance(input_files, bytes):
|
|
input_files = str(bytes(input_files).decode('utf-8', 'replace'))
|
|
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', errors='replace') 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)]
|
|
|
|
|
|
def main():
|
|
logging.basicConfig(format='%(levelname)s: %(message)s')
|
|
if len(sys.argv) == 1:
|
|
sys.argv.append('--help')
|
|
parser = argparse.ArgumentParser()
|
|
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: %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)
|
|
parser.add_argument('-a', '--alpha', metavar=_('ALPHA'), help=_('Text opacity'), type=float, default=1.0)
|
|
parser.add_argument('-dm', '--duration-marquee', metavar=_('SECONDS'), help=_('Duration of scrolling comment display [default: %s]') % 5, type=float, default=5.0)
|
|
parser.add_argument('-ds', '--duration-still', metavar=_('SECONDS'), help=_('Duration of still comment display [default: %s]') % 5, 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)
|
|
parser.add_argument('-r', '--reduce', action='store_true', help=_('Reduce the amount of comments if stage is full'))
|
|
parser.add_argument('file', metavar=_('FILE'), nargs='+', help=_('Comment file to be processed'))
|
|
args = parser.parse_args()
|
|
try:
|
|
width, height = str(args.size).split('x', 1)
|
|
width = int(width)
|
|
height = int(height)
|
|
except ValueError:
|
|
raise ValueError(_('Invalid stage size: %r') % args.size)
|
|
Danmaku2ASS(args.file, args.output, width, height, args.protect, args.font, args.fontsize, args.alpha, args.duration_marquee, args.duration_still, args.reduce)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|