modified: makefile
[GalaxyCodeBases.git] / tools / Bilibili / Biligrab / danmaku2ass3.py
blob3aaabc27c462ea9842bac961d745e6cae65c8cd0
1 #!/usr/bin/env python3
3 # The original author of this program, Danmaku2ASS, is StarBrilliant.
4 # This file is released under General Public License version 3.
5 # You should have received a copy of General Public License text alongside with
6 # this program. If not, you can obtain it at http://gnu.org/copyleft/gpl.html .
7 # This program comes with no warranty, the author will not be resopnsible for
8 # any damage or problems caused by this program.
10 # You can obtain a latest copy of Danmaku2ASS at:
11 # https://github.com/m13253/danmaku2ass
12 # Please update to the latest version before complaining.
14 import argparse
15 import calendar
16 import gettext
17 import io
18 import json
19 import logging
20 import math
21 import os
22 import random
23 import re
24 import sys
25 import time
26 import xml.dom.minidom
29 if sys.version_info < (3,):
30 raise RuntimeError('at least Python 3.0 is required')
32 gettext.install('danmaku2ass', os.path.join(os.path.dirname(os.path.abspath(os.path.realpath(sys.argv[0] or 'locale'))), 'locale'))
35 def SeekZero(function):
36 def decorated_function(file_):
37 file_.seek(0)
38 try:
39 return function(file_)
40 finally:
41 file_.seek(0)
42 return decorated_function
45 def EOFAsNone(function):
46 def decorated_function(*args, **kwargs):
47 try:
48 return function(*args, **kwargs)
49 except EOFError:
50 return None
51 return decorated_function
54 @SeekZero
55 @EOFAsNone
56 def ProbeCommentFormat(f):
57 tmp = f.read(1)
58 if tmp == '[':
59 return 'Acfun'
60 # It is unwise to wrap a JSON object in an array!
61 # See this: http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx/
62 # Do never follow what Acfun developers did!
63 elif tmp == '{':
64 tmp = f.read(14)
65 if tmp == '"status_code":':
66 return 'Tudou'
67 elif tmp == '"root":{"total':
68 return 'sH5V'
69 elif tmp == '<':
70 tmp = f.read(1)
71 if tmp == '?':
72 tmp = f.read(38)
73 if tmp == 'xml version="1.0" encoding="UTF-8"?><p':
74 return 'Niconico'
75 elif tmp == 'xml version="1.0" encoding="UTF-8"?><i':
76 return 'Bilibili'
77 elif tmp == 'xml version="1.0" encoding="utf-8"?><i':
78 return 'Bilibili' # tucao.cc, with the same file format as Bilibili
79 elif tmp == 'xml version="1.0" encoding="Utf-8"?>\n<':
80 return 'Bilibili' # Komica, with the same file format as Bilibili
81 elif tmp == 'xml version="1.0" encoding="UTF-8"?>\n<':
82 return 'MioMio'
83 elif tmp == 'p':
84 return 'Niconico' # Himawari Douga, with the same file format as Niconico Douga
88 # ReadComments**** protocol
90 # Input:
91 # f: Input file
92 # fontsize: Default font size
94 # Output:
95 # yield a tuple:
96 # (timeline, timestamp, no, comment, pos, color, size, height, width)
97 # timeline: The position when the comment is replayed
98 # timestamp: The UNIX timestamp when the comment is submitted
99 # no: A sequence of 1, 2, 3, ..., used for sorting
100 # comment: The content of the comment
101 # pos: 0 for regular moving comment,
102 # 1 for bottom centered comment,
103 # 2 for top centered comment,
104 # 3 for reversed moving comment
105 # color: Font color represented in 0xRRGGBB,
106 # e.g. 0xffffff for white
107 # size: Font size
108 # height: The estimated height in pixels
109 # i.e. (comment.count('\n')+1)*size
110 # width: The estimated width in pixels
111 # i.e. CalculateLength(comment)*size
113 # After implementing ReadComments****, make sure to update ProbeCommentFormat
114 # and CommentFormatMap.
118 def ReadCommentsNiconico(f, fontsize):
119 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}
120 dom = xml.dom.minidom.parse(f)
121 comment_element = dom.getElementsByTagName('chat')
122 for comment in comment_element:
123 try:
124 c = str(comment.childNodes[0].wholeText)
125 if c.startswith('/'):
126 continue # ignore advanced comments
127 pos = 0
128 color = 0xffffff
129 size = fontsize
130 for mailstyle in str(comment.getAttribute('mail')).split():
131 if mailstyle == 'ue':
132 pos = 1
133 elif mailstyle == 'shita':
134 pos = 2
135 elif mailstyle == 'big':
136 size = fontsize*1.44
137 elif mailstyle == 'small':
138 size = fontsize*0.64
139 elif mailstyle in NiconicoColorMap:
140 color = NiconicoColorMap[mailstyle]
141 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)
142 except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
143 logging.warning(_('Invalid comment: %s') % comment.toxml())
144 continue
147 def ReadCommentsAcfun(f, fontsize):
148 comment_element = json.load(f)
149 for i, comment in enumerate(comment_element):
150 try:
151 p = str(comment['c']).split(',')
152 assert len(p) >= 6
153 assert p[2] in ('1', '2', '4', '5', '7')
154 size = int(p[3])*fontsize/25.0
155 if p[2] != '7':
156 c = str(comment['m']).replace('\\r', '\n').replace('\r', '\n')
157 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)
158 else:
159 c = dict(json.loads(comment['m']))
160 yield (float(p[0]), int(p[5]), i, c, 'acfunpos', int(p[1]), size, 0, 0)
161 except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
162 logging.warning(_('Invalid comment: %r') % comment)
163 continue
166 def ReadCommentsBilibili(f, fontsize):
167 dom = xml.dom.minidom.parse(f)
168 comment_element = dom.getElementsByTagName('d')
169 for i, comment in enumerate(comment_element):
170 try:
171 p = str(comment.getAttribute('p')).split(',')
172 assert len(p) >= 5
173 assert p[1] in ('1', '4', '5', '6', '7')
174 if p[1] != '7':
175 c = str(comment.childNodes[0].wholeText).replace('/n', '\n')
176 size = int(p[2])*fontsize/25.0
177 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)
178 else: # positioned comment
179 c = str(comment.childNodes[0].wholeText)
180 yield (float(p[0]), int(p[4]), i, c, 'bilipos', int(p[3]), int(p[2]), 0, 0)
181 except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
182 logging.warning(_('Invalid comment: %s') % comment.toxml())
183 continue
186 def ReadCommentsTudou(f, fontsize):
187 comment_element = json.load(f)
188 for i, comment in enumerate(comment_element['comment_list']):
189 try:
190 assert comment['pos'] in (3, 4, 6)
191 c = str(comment['data'])
192 assert comment['size'] in (0, 1, 2)
193 size = {0: 0.64, 1: 1, 2: 1.44}[comment['size']]*fontsize
194 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)
195 except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
196 logging.warning(_('Invalid comment: %r') % comment)
197 continue
200 def ReadCommentsMioMio(f, fontsize):
201 NiconicoColorMap = {'red': 0xff0000, 'pink': 0xff8080, 'orange': 0xffc000, 'yellow': 0xffff00, 'green': 0x00ff00, 'cyan': 0x00ffff, 'blue': 0x0000ff, 'purple': 0xc000ff, 'black': 0x000000}
202 dom = xml.dom.minidom.parse(f)
203 comment_element = dom.getElementsByTagName('data')
204 for i, comment in enumerate(comment_element):
205 try:
206 message = comment.getElementsByTagName('message')[0]
207 c = str(message.childNodes[0].wholeText)
208 pos = 0
209 size = int(message.getAttribute('fontsize'))*fontsize/25.0
210 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)
211 except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
212 logging.warning(_('Invalid comment: %s') % comment.toxml())
213 continue
216 def ReadCommentsSH5V(f, fontsize):
217 comment_element = json.load(f)
218 for i, comment in enumerate(comment_element["root"]["bgs"]):
219 try:
220 c_at = str(comment['at'])
221 c_type = str(comment['type'])
222 c_date = str(comment['timestamp'])
223 c_color = str(comment['color'])
224 c = str(comment['text'])
225 size = fontsize
226 if c_type != '7':
227 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)
228 else:
229 c_x = float(comment['x'])
230 c_y = float(comment['y'])
231 size = int(comment['size'])
232 dur = int(comment['dur'])
233 data1 = float(comment['data1'])
234 data2 = float(comment['data2'])
235 data3 = int(comment['data3'])
236 data4 = int(comment['data4'])
237 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)
238 except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
239 logging.warning(_('Invalid comment: %r') % comment)
240 continue
243 CommentFormatMap = {None: None, 'Niconico': ReadCommentsNiconico, 'Acfun': ReadCommentsAcfun, 'Bilibili': ReadCommentsBilibili, 'Tudou': ReadCommentsTudou, 'MioMio': ReadCommentsMioMio, 'sH5V': ReadCommentsSH5V}
246 def WriteCommentBilibiliPositioned(f, c, width, height, styleid):
247 #BiliPlayerSize = (512, 384) # Bilibili player version 2010
248 #BiliPlayerSize = (540, 384) # Bilibili player version 2012
249 BiliPlayerSize = (672, 438) # Bilibili player version 2014
250 ZoomFactor = GetZoomFactor(BiliPlayerSize, (width, height))
252 def GetPosition(InputPos, isHeight):
253 isHeight = int(isHeight) # True -> 1
254 if isinstance(InputPos, int):
255 return ZoomFactor[0]*InputPos+ZoomFactor[isHeight+1]
256 elif isinstance(InputPos, float):
257 if InputPos > 1:
258 return ZoomFactor[0]*InputPos+ZoomFactor[isHeight+1]
259 else:
260 return BiliPlayerSize[isHeight]*ZoomFactor[0]*InputPos+ZoomFactor[isHeight+1]
261 else:
262 try:
263 InputPos = int(InputPos)
264 except ValueError:
265 InputPos = float(InputPos)
266 return GetPosition(InputPos, isHeight)
268 try:
269 comment_args = safe_list(json.loads(c[3]))
270 text = ASSEscape(str(comment_args[4]).replace('/n', '\n'))
271 from_x = comment_args.get(0, 0)
272 from_y = comment_args.get(1, 0)
273 to_x = comment_args.get(7, from_x)
274 to_y = comment_args.get(8, from_y)
275 from_x = GetPosition(from_x, False)
276 from_y = GetPosition(from_y, True)
277 to_x = GetPosition(to_x, False)
278 to_y = GetPosition(to_y, True)
279 alpha = safe_list(str(comment_args.get(2, '1')).split('-'))
280 from_alpha = float(alpha.get(0, 1))
281 to_alpha = float(alpha.get(1, from_alpha))
282 from_alpha = 255-round(from_alpha*255)
283 to_alpha = 255-round(to_alpha*255)
284 rotate_z = int(comment_args.get(5, 0))
285 rotate_y = int(comment_args.get(6, 0))
286 lifetime = float(comment_args.get(3, 4500))
287 duration = int(comment_args.get(9, lifetime*1000))
288 delay = int(comment_args.get(10, 0))
289 fontface = comment_args.get(12)
290 isborder = comment_args.get(11, 'true')
291 from_rotarg = ConvertFlashRotation(rotate_y, rotate_z, from_x, from_y, width, height)
292 to_rotarg = ConvertFlashRotation(rotate_y, rotate_z, to_x, to_y, width, height)
293 styles = ['\\org(%d, %d)' % (width/2, height/2)]
294 if from_rotarg[0:2] == to_rotarg[0:2]:
295 styles.append('\\pos(%.0f, %.0f)' % (from_rotarg[0:2]))
296 else:
297 styles.append('\\move(%.0f, %.0f, %.0f, %.0f, %.0f, %.0f)' % (from_rotarg[0:2]+to_rotarg[0:2]+(delay, delay+duration)))
298 styles.append('\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f' % (from_rotarg[2:7]))
299 if (from_x, from_y) != (to_x, to_y):
300 styles.append('\\t(%d, %d, ' % (delay, delay+duration))
301 styles.append('\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f' % (to_rotarg[2:7]))
302 styles.append(')')
303 if fontface:
304 styles.append('\\fn%s' % ASSEscape(fontface))
305 styles.append('\\fs%.0f' % (c[6]*ZoomFactor[0]))
306 if c[5] != 0xffffff:
307 styles.append('\\c&H%s&' % ConvertColor(c[5]))
308 if c[5] == 0x000000:
309 styles.append('\\3c&HFFFFFF&')
310 if from_alpha == to_alpha:
311 styles.append('\\alpha&H%02X' % from_alpha)
312 elif (from_alpha, to_alpha) == (255, 0):
313 styles.append('\\fad(%.0f,0)' % (lifetime*1000))
314 elif (from_alpha, to_alpha) == (0, 255):
315 styles.append('\\fad(0, %.0f)' % (lifetime*1000))
316 else:
317 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})
318 if isborder == 'false':
319 styles.append('\\bord0')
320 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})
321 except (IndexError, ValueError) as e:
322 try:
323 logging.warning(_('Invalid comment: %r') % c[3])
324 except IndexError:
325 logging.warning(_('Invalid comment: %r') % c)
328 def WriteCommentAcfunPositioned(f, c, width, height, styleid):
329 AcfunPlayerSize = (560, 400)
330 ZoomFactor = GetZoomFactor(AcfunPlayerSize, (width, height))
332 def GetPosition(InputPos, isHeight):
333 isHeight = int(isHeight) # True -> 1
334 return AcfunPlayerSize[isHeight]*ZoomFactor[0]*InputPos*0.001+ZoomFactor[isHeight+1]
336 def GetTransformStyles(x=None, y=None, scale_x=None, scale_y=None, rotate_z=None, rotate_y=None, color=None, alpha=None):
337 styles = []
338 out_x, out_y = x, y
339 if rotate_z is not None and rotate_y is not None:
340 assert x is not None
341 assert y is not None
342 rotarg = ConvertFlashRotation(rotate_y, rotate_z, x, y, width, height)
343 out_x, out_y = rotarg[0:2]
344 if scale_x is None:
345 scale_x = 1
346 if scale_y is None:
347 scale_y = 1
348 styles.append('\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f' % (rotarg[2:5]+(rotarg[5]*scale_x, rotarg[6]*scale_y)))
349 else:
350 if scale_x is not None:
351 styles.append('\\fscx%.0f' % (scale_x*100))
352 if scale_y is not None:
353 styles.append('\\fscy%.0f' % (scale_y*100))
354 if color is not None:
355 styles.append('\\c&H%s&' % ConvertColor(color))
356 if color == 0x000000:
357 styles.append('\\3c&HFFFFFF&')
358 if alpha is not None:
359 alpha = 255-round(alpha*255)
360 styles.append('\\alpha&H%02X' % alpha)
361 return out_x, out_y, styles
363 def FlushCommentLine(f, text, styles, start_time, end_time, styleid):
364 if end_time > start_time:
365 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})
367 try:
368 comment_args = c[3]
369 text = ASSEscape(str(comment_args['n']).replace('\r', '\n'))
370 common_styles = ['\org(%d, %d)' % (width/2, height/2)]
371 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)
372 if anchor != 7:
373 common_styles.append('\\an%s' % anchor)
374 font = comment_args.get('w')
375 if font:
376 font = dict(font)
377 fontface = font.get('f')
378 if fontface:
379 common_styles.append('\\fn%s' % ASSEscape(str(fontface)))
380 fontbold = bool(font.get('b'))
381 if fontbold:
382 common_styles.append('\\b1')
383 common_styles.append('\\fs%.0f' % (c[6]*ZoomFactor[0]))
384 isborder = bool(comment_args.get('b', True))
385 if not isborder:
386 common_styles.append('\\bord0')
387 to_pos = dict(comment_args.get('p', {'x': 0, 'y': 0}))
388 to_x = round(GetPosition(int(to_pos.get('x', 0)), False))
389 to_y = round(GetPosition(int(to_pos.get('y', 0)), True))
390 to_scale_x = float(comment_args.get('e', 1.0))
391 to_scale_y = float(comment_args.get('f', 1.0))
392 to_rotate_z = float(comment_args.get('r', 0.0))
393 to_rotate_y = float(comment_args.get('k', 0.0))
394 to_color = c[5]
395 to_alpha = float(comment_args.get('a', 1.0))
396 from_time = float(comment_args.get('t', 0.0))
397 action_time = float(comment_args.get('l', 3.0))
398 actions = list(comment_args.get('z', []))
399 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)
400 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)
401 action_styles = transform_styles
402 for action in actions:
403 action = dict(action)
404 from_x, from_y = to_x, to_y
405 from_out_x, from_out_y = to_out_x, to_out_y
406 from_scale_x, from_scale_y = to_scale_x, to_scale_y
407 from_rotate_z, from_rotate_y = to_rotate_z, to_rotate_y
408 from_color, from_alpha = to_color, to_alpha
409 transform_styles, action_styles = action_styles, []
410 from_time += action_time
411 action_time = float(action.get('l', 0.0))
412 if 'x' in action:
413 to_x = round(GetPosition(int(action['x']), False))
414 if 'y' in action:
415 to_y = round(GetPosition(int(action['y']), True))
416 if 'f' in action:
417 to_scale_x = float(action['f'])
418 if 'g' in action:
419 to_scale_y = float(action['g'])
420 if 'c' in action:
421 to_color = int(action['c'])
422 if 't' in action:
423 to_alpha = float(action['t'])
424 if 'd' in action:
425 to_rotate_z = float(action['d'])
426 if 'e' in action:
427 to_rotate_y = float(action['e'])
428 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)
429 if (from_out_x, from_out_y) == (to_out_x, to_out_y):
430 pos_style = '\\pos(%.0f, %.0f)' % (to_out_x, to_out_y)
431 else:
432 pos_style = '\\move(%.0f, %.0f, %.0f, %.0f)' % (from_out_x, from_out_y, to_out_x, to_out_y)
433 styles = common_styles+transform_styles
434 styles.append(pos_style)
435 if action_styles:
436 styles.append('\\t(%s)' % (''.join(action_styles)))
437 FlushCommentLine(f, text, styles, c[0]+from_time, c[0]+from_time+action_time, styleid)
438 except (IndexError, ValueError) as e:
439 logging.warning(_('Invalid comment: %r') % c[3])
442 def WriteCommentSH5VPositioned(f, c, width, height, styleid):
444 def GetTransformStyles(x=None, y=None, fsize=None, rotate_z=None, rotate_y=None, color=None, alpha=None):
445 styles = []
446 if x is not None and y is not None:
447 styles.append('\\pos(%.0f, %.0f)' % (x, y))
448 if fsize is not None:
449 styles.append('\\fs%.0f' % fsize)
450 if rotate_y is not None and rotate_z is not None:
451 styles.append('\\frz%.0f' % rotate_z)
452 styles.append('\\fry%.0f' % rotate_y)
453 if color is not None:
454 styles.append('\\c&H%s&' % ConvertColor(color))
455 if color == 0x000000:
456 styles.append('\\3c&HFFFFFF&')
457 if alpha is not None:
458 alpha = 255-round(alpha*255)
459 styles.append('\\alpha&H%02X' % alpha)
460 return styles
462 def FlushCommentLine(f, text, styles, start_time, end_time, styleid):
463 if end_time > start_time:
464 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})
466 try:
467 text = ASSEscape(str(c[3]))
468 to_x = float(c[9])*width
469 to_y = float(c[10])*height
470 to_rotate_z = -int(c[14])
471 to_rotate_y = -int(c[15])
472 to_color = c[5]
473 to_alpha = float(c[12])
474 # Note: Alpha transition hasn't been worked out yet.
475 to_size = int(c[6])*math.sqrt(width*height/307200)
476 # Note: Because sH5V's data is the absolute size of font,temporarily solve by it at present.[*math.sqrt(width/640*height/480)]
477 # But it seems to be working fine...
478 from_time = float(c[0])
479 action_time = float(c[11])/1000
480 transform_styles = GetTransformStyles(to_x, to_y, to_size, to_rotate_z, to_rotate_y, to_color, to_alpha)
481 FlushCommentLine(f, text, transform_styles, from_time, from_time+action_time, styleid)
482 except (IndexError, ValueError) as e:
483 logging.warning(_('Invalid comment: %r') % c[3])
486 # Result: (f, dx, dy)
487 # To convert: NewX = f*x+dx, NewY = f*y+dy
488 def GetZoomFactor(SourceSize, TargetSize):
489 try:
490 if (SourceSize, TargetSize) == GetZoomFactor.Cached_Size:
491 return GetZoomFactor.Cached_Result
492 except AttributeError:
493 pass
494 GetZoomFactor.Cached_Size = (SourceSize, TargetSize)
495 try:
496 SourceAspect = SourceSize[0]/SourceSize[1]
497 TargetAspect = TargetSize[0]/TargetSize[1]
498 if TargetAspect < SourceAspect: # narrower
499 ScaleFactor = TargetSize[0]/SourceSize[0]
500 GetZoomFactor.Cached_Result = (ScaleFactor, 0, (TargetSize[1]-TargetSize[0]/SourceAspect)/2)
501 elif TargetAspect > SourceAspect: # wider
502 ScaleFactor = TargetSize[1]/SourceSize[1]
503 GetZoomFactor.Cached_Result = (ScaleFactor, (TargetSize[0]-TargetSize[1]*SourceAspect)/2, 0)
504 else:
505 GetZoomFactor.Cached_Result = (TargetSize[0]/SourceSize[0], 0, 0)
506 return GetZoomFactor.Cached_Result
507 except ZeroDivisionError:
508 GetZoomFactor.Cached_Result = (1, 0, 0)
509 return GetZoomFactor.Cached_Result
512 # Calculation is based on https://github.com/jabbany/CommentCoreLibrary/issues/5#issuecomment-40087282
513 # and https://github.com/m13253/danmaku2ass/issues/7#issuecomment-41489422
514 # ASS FOV = width*4/3.0
515 # But Flash FOV = width/math.tan(100*math.pi/360.0)/2 will be used instead
516 # Result: (transX, transY, rotX, rotY, rotZ, scaleX, scaleY)
517 def ConvertFlashRotation(rotY, rotZ, X, Y, width, height):
518 def WrapAngle(deg):
519 return 180-((180-deg) % 360)
520 rotY = WrapAngle(rotY)
521 rotZ = WrapAngle(rotZ)
522 if rotY in (90, -90):
523 rotY -= 1
524 if rotY == 0 or rotZ == 0:
525 outX = 0
526 outY = -rotY # Positive value means clockwise in Flash
527 outZ = -rotZ
528 rotY *= math.pi/180.0
529 rotZ *= math.pi/180.0
530 else:
531 rotY *= math.pi/180.0
532 rotZ *= math.pi/180.0
533 outY = math.atan2(-math.sin(rotY)*math.cos(rotZ), math.cos(rotY))*180/math.pi
534 outZ = math.atan2(-math.cos(rotY)*math.sin(rotZ), math.cos(rotZ))*180/math.pi
535 outX = math.asin(math.sin(rotY)*math.sin(rotZ))*180/math.pi
536 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
537 trY = Y*math.cos(rotZ)-X*math.sin(rotZ)+math.sin(rotZ)*width/2+(1-math.cos(rotZ))*height/2
538 trZ = (trX-width/2)*math.sin(rotY)
539 FOV = width*math.tan(2*math.pi/9.0)/2
540 try:
541 scaleXY = FOV/(FOV+trZ)
542 except ZeroDivisionError:
543 logging.error('Rotation makes object behind the camera: trZ == %.0f' % trZ)
544 scaleXY = 1
545 trX = (trX-width/2)*scaleXY+width/2
546 trY = (trY-height/2)*scaleXY+height/2
547 if scaleXY < 0:
548 scaleXY = -scaleXY
549 outX += 180
550 outY += 180
551 logging.error('Rotation makes object behind the camera: trZ == %.0f < %.0f' % (trZ, FOV))
552 return (trX, trY, WrapAngle(outX), WrapAngle(outY), WrapAngle(outZ), scaleXY*100, scaleXY*100)
555 def ProcessComments(comments, f, width, height, bottomReserved, fontface, fontsize, alpha, duration_marquee, duration_still, reduced, progress_callback):
556 styleid = 'Danmaku2ASS_%04x' % random.randint(0, 0xffff)
557 WriteASSHead(f, width, height, fontface, fontsize, alpha, styleid)
558 rows = [[None]*(height-bottomReserved+1) for i in range(4)]
559 for idx, i in enumerate(comments):
560 if progress_callback and idx % 1000 == 0:
561 progress_callback(idx, len(comments))
562 if isinstance(i[4], int):
563 row = 0
564 rowmax = height-bottomReserved-i[7]
565 while row <= rowmax:
566 freerows = TestFreeRows(rows, i, row, width, height, bottomReserved, duration_marquee, duration_still)
567 if freerows >= i[7]:
568 MarkCommentRow(rows, i, row)
569 WriteComment(f, i, row, width, height, bottomReserved, fontsize, duration_marquee, duration_still, styleid)
570 break
571 else:
572 row += freerows or 1
573 else:
574 if not reduced:
575 row = FindAlternativeRow(rows, i, height, bottomReserved)
576 MarkCommentRow(rows, i, row)
577 WriteComment(f, i, row, width, height, bottomReserved, fontsize, duration_marquee, duration_still, styleid)
578 elif i[4] == 'bilipos':
579 WriteCommentBilibiliPositioned(f, i, width, height, styleid)
580 elif i[4] == 'acfunpos':
581 WriteCommentAcfunPositioned(f, i, width, height, styleid)
582 elif i[4] == 'sH5Vpos':
583 WriteCommentSH5VPositioned(f, i, width, height, styleid)
584 else:
585 logging.warning(_('Invalid comment: %r') % i[3])
586 if progress_callback:
587 progress_callback(len(comments), len(comments))
590 def TestFreeRows(rows, c, row, width, height, bottomReserved, duration_marquee, duration_still):
591 res = 0
592 rowmax = height-bottomReserved
593 targetRow = None
594 if c[4] in (1, 2):
595 while row < rowmax and res < c[7]:
596 if targetRow != rows[c[4]][row]:
597 targetRow = rows[c[4]][row]
598 if targetRow and targetRow[0]+duration_still > c[0]:
599 break
600 row += 1
601 res += 1
602 else:
603 try:
604 thresholdTime = c[0]-duration_marquee*(1-width/(c[8]+width))
605 except ZeroDivisionError:
606 thresholdTime = c[0]-duration_marquee
607 while row < rowmax and res < c[7]:
608 if targetRow != rows[c[4]][row]:
609 targetRow = rows[c[4]][row]
610 try:
611 if targetRow and (targetRow[0] > thresholdTime or targetRow[0]+targetRow[8]*duration_marquee/(targetRow[8]+width) > c[0]):
612 break
613 except ZeroDivisionError:
614 pass
615 row += 1
616 res += 1
617 return res
620 def FindAlternativeRow(rows, c, height, bottomReserved):
621 res = 0
622 for row in range(height-bottomReserved-math.ceil(c[7])):
623 if not rows[c[4]][row]:
624 return row
625 elif rows[c[4]][row][0] < rows[c[4]][res][0]:
626 res = row
627 return res
630 def MarkCommentRow(rows, c, row):
631 try:
632 for i in range(row, row+math.ceil(c[7])):
633 rows[c[4]][i] = c
634 except IndexError:
635 pass
638 def WriteASSHead(f, width, height, fontface, fontsize, alpha, styleid):
639 f.write(
641 [Script Info]
642 ; Script generated by Danmaku2ASS
643 ; https://github.com/m13253/danmaku2ass
644 Script Updated By: Danmaku2ASS (https://github.com/m13253/danmaku2ass)
645 ScriptType: v4.00+
646 PlayResX: %(width)d
647 PlayResY: %(height)d
648 Aspect Ratio: %(width)d:%(height)d
649 Collisions: Normal
650 WrapStyle: 2
651 ScaledBorderAndShadow: yes
652 YCbCr Matrix: TV.601
654 [V4+ Styles]
655 Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
656 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
658 [Events]
659 Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
660 ''' % {'width': width, 'height': height, 'fontface': fontface, 'fontsize': fontsize, 'alpha': 255-round(alpha*255), 'outline': max(fontsize/25.0, 1), 'styleid': styleid}
664 def WriteComment(f, c, row, width, height, bottomReserved, fontsize, duration_marquee, duration_still, styleid):
665 text = ASSEscape(c[3])
666 styles = []
667 if c[4] == 1:
668 styles.append('\\an8\\pos(%(halfwidth)d, %(row)d)' % {'halfwidth': width/2, 'row': row})
669 duration = duration_still
670 elif c[4] == 2:
671 styles.append('\\an2\\pos(%(halfwidth)d, %(row)d)' % {'halfwidth': width/2, 'row': ConvertType2(row, height, bottomReserved)})
672 duration = duration_still
673 elif c[4] == 3:
674 styles.append('\\move(%(neglen)d, %(row)d, %(width)d, %(row)d)' % {'width': width, 'row': row, 'neglen': -math.ceil(c[8])})
675 duration = duration_marquee
676 else:
677 styles.append('\\move(%(width)d, %(row)d, %(neglen)d, %(row)d)' % {'width': width, 'row': row, 'neglen': -math.ceil(c[8])})
678 duration = duration_marquee
679 if not (-1 < c[6]-fontsize < 1):
680 styles.append('\\fs%.0f' % c[6])
681 if c[5] != 0xffffff:
682 styles.append('\\c&H%s&' % ConvertColor(c[5]))
683 if c[5] == 0x000000:
684 styles.append('\\3c&HFFFFFF&')
685 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})
688 def ASSEscape(s):
689 def ReplaceLeadingSpace(s):
690 sstrip = s.strip(' ')
691 slen = len(s)
692 if slen == len(sstrip):
693 return s
694 else:
695 llen = slen-len(s.lstrip(' '))
696 rlen = slen-len(s.rstrip(' '))
697 return ''.join(('\u2007'*llen, sstrip, '\u2007'*rlen))
698 return '\\N'.join((ReplaceLeadingSpace(i) or ' ' for i in str(s).replace('\\', '\\\\').replace('{', '\\{').replace('}', '\\}').split('\n')))
701 def CalculateLength(s):
702 return max(map(len, s.split('\n'))) # May not be accurate
705 def ConvertTimestamp(timestamp):
706 timestamp = round(timestamp*100.0)
707 hour, minute = divmod(timestamp, 360000)
708 minute, second = divmod(minute, 6000)
709 second, centsecond = divmod(second, 100)
710 return '%d:%02d:%02d.%02d' % (int(hour), int(minute), int(second), int(centsecond))
713 def ConvertColor(RGB, width=1280, height=576):
714 if RGB == 0x000000:
715 return '000000'
716 elif RGB == 0xffffff:
717 return 'FFFFFF'
718 R = (RGB >> 16) & 0xff
719 G = (RGB >> 8) & 0xff
720 B = RGB & 0xff
721 if width < 1280 and height < 576:
722 return '%02X%02X%02X' % (B, G, R)
723 else: # VobSub always uses BT.601 colorspace, convert to BT.709
724 ClipByte = lambda x: 255 if x > 255 else 0 if x < 0 else round(x)
725 return '%02X%02X%02X' % (
726 ClipByte(R*0.00956384088080656+G*0.03217254540203729+B*0.95826361371715607),
727 ClipByte(R*-0.10493933142075390+G*1.17231478191855154+B*-0.06737545049779757),
728 ClipByte(R*0.91348912373987645+G*0.07858536372532510+B*0.00792551253479842)
732 def ConvertType2(row, height, bottomReserved):
733 return height-bottomReserved-row
736 def ConvertToFile(filename_or_file, *args, **kwargs):
737 if isinstance(filename_or_file, bytes):
738 filename_or_file = str(bytes(filename_or_file).decode('utf-8', 'replace'))
739 if isinstance(filename_or_file, str):
740 return open(filename_or_file, *args, **kwargs)
741 else:
742 return filename_or_file
745 def FilterBadChars(f):
746 s = f.read()
747 s = re.sub('[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f]', '\ufffd', s)
748 return io.StringIO(s)
751 class safe_list(list):
752 def get(self, index, default=None):
753 try:
754 return self[index]
755 except IndexError:
756 return default
759 def export(func):
760 global __all__
761 try:
762 __all__.append(func.__name__)
763 except NameError:
764 __all__ = [func.__name__]
765 return func
768 @export
769 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):
770 fo = None
771 comments = ReadComments(input_files, font_size)
772 try:
773 if output_file:
774 fo = ConvertToFile(output_file, 'w', encoding='utf-8-sig', errors='replace', newline='\r\n')
775 else:
776 fo = sys.stdout
777 ProcessComments(comments, fo, stage_width, stage_height, reserve_blank, font_face, font_size, text_opacity, duration_marquee, duration_still, is_reduce_comments, progress_callback)
778 finally:
779 if output_file and fo != output_file:
780 fo.close()
783 @export
784 def ReadComments(input_files, font_size=25.0, progress_callback=None):
785 if isinstance(input_files, bytes):
786 input_files = str(bytes(input_files).decode('utf-8', 'replace'))
787 if isinstance(input_files, str):
788 input_files = [input_files]
789 else:
790 input_files = list(input_files)
791 comments = []
792 for idx, i in enumerate(input_files):
793 if progress_callback:
794 progress_callback(idx, len(input_files))
795 with ConvertToFile(i, 'r', encoding='utf-8', errors='replace') as f:
796 CommentProcessor = GetCommentProcessor(f)
797 if not CommentProcessor:
798 raise ValueError(_('Unknown comment file format: %s') % i)
799 comments.extend(CommentProcessor(FilterBadChars(f), font_size))
800 if progress_callback:
801 progress_callback(len(input_files), len(input_files))
802 comments.sort()
803 return comments
806 @export
807 def GetCommentProcessor(input_file):
808 return CommentFormatMap[ProbeCommentFormat(input_file)]
811 def main():
812 logging.basicConfig(format='%(levelname)s: %(message)s')
813 if len(sys.argv) == 1:
814 sys.argv.append('--help')
815 parser = argparse.ArgumentParser()
816 parser.add_argument('-o', '--output', metavar=_('OUTPUT'), help=_('Output file'))
817 parser.add_argument('-s', '--size', metavar=_('WIDTHxHEIGHT'), required=True, help=_('Stage size in pixels'))
818 parser.add_argument('-fn', '--font', metavar=_('FONT'), help=_('Specify font face [default: %s]') % _('(FONT) sans-serif')[7:], default=_('(FONT) sans-serif')[7:])
819 parser.add_argument('-fs', '--fontsize', metavar=_('SIZE'), help=(_('Default font size [default: %s]') % 25), type=float, default=25.0)
820 parser.add_argument('-a', '--alpha', metavar=_('ALPHA'), help=_('Text opacity'), type=float, default=1.0)
821 parser.add_argument('-dm', '--duration-marquee', metavar=_('SECONDS'), help=_('Duration of scrolling comment display [default: %s]') % 5, type=float, default=5.0)
822 parser.add_argument('-ds', '--duration-still', metavar=_('SECONDS'), help=_('Duration of still comment display [default: %s]') % 5, type=float, default=5.0)
823 parser.add_argument('-p', '--protect', metavar=_('HEIGHT'), help=_('Reserve blank on the bottom of the stage'), type=int, default=0)
824 parser.add_argument('-r', '--reduce', action='store_true', help=_('Reduce the amount of comments if stage is full'))
825 parser.add_argument('file', metavar=_('FILE'), nargs='+', help=_('Comment file to be processed'))
826 args = parser.parse_args()
827 try:
828 width, height = str(args.size).split('x', 1)
829 width = int(width)
830 height = int(height)
831 except ValueError:
832 raise ValueError(_('Invalid stage size: %r') % args.size)
833 Danmaku2ASS(args.file, args.output, width, height, args.protect, args.font, args.fontsize, args.alpha, args.duration_marquee, args.duration_still, args.reduce)
836 if __name__ == '__main__':
837 main()