Spaces:
Sleeping
Sleeping
#!/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. | |
# pylint: skip-file | |
# type: ignore | |
import io | |
import os | |
import re | |
import sys | |
import json | |
import math | |
import time | |
import random | |
import gettext | |
import logging | |
import argparse | |
import calendar | |
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 | |
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.strip().startswith('"result'): | |
return "Tudou2" | |
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="2.0" encoding="UTF-8"?><i': | |
return "Bilibili2" | |
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) | |
# after load acfun comment json file as python list, flatten the list | |
# comment_element = [c for sublist in comment_element for c in sublist] | |
comment_elements = json.load(f) | |
comment_element = comment_elements[2] | |
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", "8") | |
if comment.childNodes.length > 0: | |
if p[1] in ("1", "4", "5", "6"): | |
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, | |
) | |
elif p[1] == "7": # 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, | |
) | |
elif p[1] == "8": | |
pass # ignore scripted comment | |
except (AssertionError, AttributeError, IndexError, TypeError, ValueError): | |
logging.warning(_("Invalid comment: %s") % comment.toxml()) | |
continue | |
def ReadCommentsBilibili2(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) >= 7 | |
assert p[3] in ("1", "4", "5", "6", "7", "8") | |
if comment.childNodes.length > 0: | |
time = float(p[2]) / 1000.0 | |
if p[3] in ("1", "4", "5", "6"): | |
c = str(comment.childNodes[0].wholeText).replace("/n", "\n") | |
size = int(p[4]) * fontsize / 25.0 | |
yield ( | |
time, | |
int(p[6]), | |
i, | |
c, | |
{"1": 0, "4": 2, "5": 1, "6": 3}[p[3]], | |
int(p[5]), | |
size, | |
(c.count("\n") + 1) * size, | |
CalculateLength(c) * size, | |
) | |
elif p[3] == "7": # positioned comment | |
c = str(comment.childNodes[0].wholeText) | |
yield (time, int(p[6]), i, c, "bilipos", int(p[5]), int(p[4]), 0, 0) | |
elif p[3] == "8": | |
pass # ignore scripted comment | |
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 ReadCommentsTudou2(f, fontsize): | |
comment_element = json.load(f) | |
for i, comment in enumerate(comment_element["result"]): | |
try: | |
c = str(comment["content"]) | |
prop = json.loads(str(comment["propertis"]) or "{}") | |
size = int(prop.get("size", 1)) | |
assert size in (0, 1, 2) | |
size = {0: 0.64, 1: 1, 2: 1.44}[size] * fontsize | |
pos = int(prop.get("pos", 3)) | |
assert pos in (0, 3, 4, 6) | |
yield ( | |
int(comment["playat"] * 0.001), | |
int(comment["createtime"] * 0.001), | |
i, | |
c, | |
{0: 0, 3: 0, 4: 2, 6: 1}[pos], | |
int(prop.get("color", 0xFFFFFF)), | |
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 | |
CommentFormatMap = { | |
"Niconico": ReadCommentsNiconico, | |
"Acfun": ReadCommentsAcfun, | |
"Bilibili": ReadCommentsBilibili, | |
"Bilibili2": ReadCommentsBilibili2, | |
"Tudou": ReadCommentsTudou, | |
"Tudou2": ReadCommentsTudou2, | |
"MioMio": ReadCommentsMioMio, | |
} | |
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]) | |
# 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, | |
filters_regex, | |
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): | |
skip = False | |
for filter_regex in filters_regex: | |
if filter_regex and filter_regex.search(i[3]): | |
skip = True | |
break | |
if skip: | |
continue | |
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) | |
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 | |
def Danmaku2ASS( | |
input_files, | |
input_format, | |
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, | |
comment_filter=None, | |
comment_filters_file=None, | |
is_reduce_comments=False, | |
progress_callback=None, | |
): | |
comment_filters = [comment_filter] | |
if comment_filters_file: | |
with open(comment_filters_file, "r") as f: | |
d = f.readlines() | |
comment_filters.extend([i.strip() for i in d]) | |
filters_regex = [] | |
for comment_filter in comment_filters: | |
try: | |
if comment_filter: | |
filters_regex.append(re.compile(comment_filter)) | |
except: | |
raise ValueError(_("Invalid regular expression: %s") % comment_filter) | |
fo = None | |
comments = ReadComments(input_files, input_format, 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, | |
filters_regex, | |
is_reduce_comments, | |
progress_callback, | |
) | |
finally: | |
if output_file and fo != output_file: | |
fo.close() | |
def ReadComments(input_files, input_format, 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: | |
s = f.read() | |
str_io = io.StringIO(s) | |
if input_format == "autodetect": | |
CommentProcessor = GetCommentProcessor(str_io) | |
if not CommentProcessor: | |
raise ValueError(_("Failed to detect comment file format: %s") % i) | |
else: | |
CommentProcessor = CommentFormatMap.get(input_format) | |
if not CommentProcessor: | |
raise ValueError( | |
_("Unknown comment file format: %s") % input_format | |
) | |
comments.extend(CommentProcessor(FilterBadChars(str_io), font_size)) | |
if progress_callback: | |
progress_callback(len(input_files), len(input_files)) | |
comments.sort() | |
return comments | |
def GetCommentProcessor(input_file): | |
return CommentFormatMap.get(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( | |
"-f", | |
"--format", | |
metavar=_("FORMAT"), | |
help=_("Format of input file (autodetect|%s) [default: autodetect]") | |
% "|".join(i for i in CommentFormatMap), | |
default="autodetect", | |
) | |
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( | |
"-fl", "--filter", help=_("Regular expression to filter comments") | |
) | |
parser.add_argument( | |
"-flf", | |
"--filter-file", | |
help=_("Regular expressions from file (one line one regex) to filter comments"), | |
) | |
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.format, | |
args.output, | |
width, | |
height, | |
args.protect, | |
args.font, | |
args.fontsize, | |
args.alpha, | |
args.duration_marquee, | |
args.duration_still, | |
args.filter, | |
args.filter_file, | |
args.reduce, | |
) | |
if __name__ == "__main__": | |
main() | |