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