modified: myjupyterlab.sh
[GalaxyCodeBases.git] / tools / Bilibili / danmaku2ass / danmaku2ass.py
blob7a14a9b41dbdd5fc50b692ef5b1e519de41a8fae
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.strip().startswith('"result'):
68 return 'Tudou2'
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 tmp = f.read(20)
83 if tmp == '!-- BoonSutazioData=':
84 return 'Niconico' # Niconico videos downloaded with NicoFox
85 else:
86 return 'MioMio'
87 elif tmp == 'p':
88 return 'Niconico' # Himawari Douga, with the same file format as Niconico Douga
92 # ReadComments**** protocol
94 # Input:
95 # f: Input file
96 # fontsize: Default font size
98 # Output:
99 # yield a tuple:
100 # (timeline, timestamp, no, comment, pos, color, size, height, width)
101 # timeline: The position when the comment is replayed
102 # timestamp: The UNIX timestamp when the comment is submitted
103 # no: A sequence of 1, 2, 3, ..., used for sorting
104 # comment: The content of the comment
105 # pos: 0 for regular moving comment,
106 # 1 for bottom centered comment,
107 # 2 for top centered comment,
108 # 3 for reversed moving comment
109 # color: Font color represented in 0xRRGGBB,
110 # e.g. 0xffffff for white
111 # size: Font size
112 # height: The estimated height in pixels
113 # i.e. (comment.count('\n')+1)*size
114 # width: The estimated width in pixels
115 # i.e. CalculateLength(comment)*size
117 # After implementing ReadComments****, make sure to update ProbeCommentFormat
118 # and CommentFormatMap.
122 def ReadCommentsNiconico(f, fontsize):
123 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}
124 dom = xml.dom.minidom.parse(f)
125 comment_element = dom.getElementsByTagName('chat')
126 for comment in comment_element:
127 try:
128 c = str(comment.childNodes[0].wholeText)
129 if c.startswith('/'):
130 continue # ignore advanced comments
131 pos = 0
132 color = 0xffffff
133 size = fontsize
134 for mailstyle in str(comment.getAttribute('mail')).split():
135 if mailstyle == 'ue':
136 pos = 1
137 elif mailstyle == 'shita':
138 pos = 2
139 elif mailstyle == 'big':
140 size = fontsize*1.44
141 elif mailstyle == 'small':
142 size = fontsize*0.64
143 elif mailstyle in NiconicoColorMap:
144 color = NiconicoColorMap[mailstyle]
145 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)
146 except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
147 logging.warning(_('Invalid comment: %s') % comment.toxml())
148 continue
151 def ReadCommentsAcfun(f, fontsize):
152 #comment_element = json.load(f)
153 # after load acfun comment json file as python list, flatten the list
154 #comment_element = [c for sublist in comment_element for c in sublist]
155 comment_elements = json.load(f)
156 comment_element = comment_elements[2]
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.parse(f)
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', '8')
182 if comment.childNodes.length > 0:
183 if p[1] in ('1', '4', '5', '6'):
184 c = str(comment.childNodes[0].wholeText).replace('/n', '\n')
185 size = int(p[2])*fontsize/25.0
186 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)
187 elif p[1] == '7': # positioned comment
188 c = str(comment.childNodes[0].wholeText)
189 yield (float(p[0]), int(p[4]), i, c, 'bilipos', int(p[3]), int(p[2]), 0, 0)
190 elif p[1] == '8':
191 pass # ignore scripted comment
192 except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
193 logging.warning(_('Invalid comment: %s') % comment.toxml())
194 continue
197 def ReadCommentsTudou(f, fontsize):
198 comment_element = json.load(f)
199 for i, comment in enumerate(comment_element['comment_list']):
200 try:
201 assert comment['pos'] in (3, 4, 6)
202 c = str(comment['data'])
203 assert comment['size'] in (0, 1, 2)
204 size = {0: 0.64, 1: 1, 2: 1.44}[comment['size']]*fontsize
205 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)
206 except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
207 logging.warning(_('Invalid comment: %r') % comment)
208 continue
211 def ReadCommentsTudou2(f, fontsize):
212 comment_element = json.load(f)
213 for i, comment in enumerate(comment_element['result']):
214 try:
215 c = str(comment['content'])
216 prop = json.loads(str(comment['propertis']) or '{}')
217 size = int(prop.get('size', 1))
218 assert size in (0, 1, 2)
219 size = {0: 0.64, 1: 1, 2: 1.44}[size] * fontsize
220 pos = int(prop.get('pos', 3))
221 assert pos in (0, 3, 4, 6)
222 yield (
223 int(comment['playat'] * 0.001), int(comment['createtime'] * 0.001), i, c,
224 {0: 0, 3: 0, 4: 2, 6: 1}[pos],
225 int(prop.get('color', 0xffffff)), size, (c.count('\n') + 1) * size, CalculateLength(c) * size)
226 except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
227 logging.warning(_('Invalid comment: %r') % comment)
228 continue
231 def ReadCommentsMioMio(f, fontsize):
232 NiconicoColorMap = {'red': 0xff0000, 'pink': 0xff8080, 'orange': 0xffc000, 'yellow': 0xffff00, 'green': 0x00ff00, 'cyan': 0x00ffff, 'blue': 0x0000ff, 'purple': 0xc000ff, 'black': 0x000000}
233 dom = xml.dom.minidom.parse(f)
234 comment_element = dom.getElementsByTagName('data')
235 for i, comment in enumerate(comment_element):
236 try:
237 message = comment.getElementsByTagName('message')[0]
238 c = str(message.childNodes[0].wholeText)
239 pos = 0
240 size = int(message.getAttribute('fontsize'))*fontsize/25.0
241 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)
242 except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
243 logging.warning(_('Invalid comment: %s') % comment.toxml())
244 continue
247 CommentFormatMap = {'Niconico': ReadCommentsNiconico, 'Acfun': ReadCommentsAcfun, 'Bilibili': ReadCommentsBilibili, 'Tudou': ReadCommentsTudou, 'Tudou2': ReadCommentsTudou2, 'MioMio': ReadCommentsMioMio}
250 def WriteCommentBilibiliPositioned(f, c, width, height, styleid):
251 #BiliPlayerSize = (512, 384) # Bilibili player version 2010
252 #BiliPlayerSize = (540, 384) # Bilibili player version 2012
253 BiliPlayerSize = (672, 438) # Bilibili player version 2014
254 ZoomFactor = GetZoomFactor(BiliPlayerSize, (width, height))
256 def GetPosition(InputPos, isHeight):
257 isHeight = int(isHeight) # True -> 1
258 if isinstance(InputPos, int):
259 return ZoomFactor[0]*InputPos+ZoomFactor[isHeight+1]
260 elif isinstance(InputPos, float):
261 if InputPos > 1:
262 return ZoomFactor[0]*InputPos+ZoomFactor[isHeight+1]
263 else:
264 return BiliPlayerSize[isHeight]*ZoomFactor[0]*InputPos+ZoomFactor[isHeight+1]
265 else:
266 try:
267 InputPos = int(InputPos)
268 except ValueError:
269 InputPos = float(InputPos)
270 return GetPosition(InputPos, isHeight)
272 try:
273 comment_args = safe_list(json.loads(c[3]))
274 text = ASSEscape(str(comment_args[4]).replace('/n', '\n'))
275 from_x = comment_args.get(0, 0)
276 from_y = comment_args.get(1, 0)
277 to_x = comment_args.get(7, from_x)
278 to_y = comment_args.get(8, from_y)
279 from_x = GetPosition(from_x, False)
280 from_y = GetPosition(from_y, True)
281 to_x = GetPosition(to_x, False)
282 to_y = GetPosition(to_y, True)
283 alpha = safe_list(str(comment_args.get(2, '1')).split('-'))
284 from_alpha = float(alpha.get(0, 1))
285 to_alpha = float(alpha.get(1, from_alpha))
286 from_alpha = 255-round(from_alpha*255)
287 to_alpha = 255-round(to_alpha*255)
288 rotate_z = int(comment_args.get(5, 0))
289 rotate_y = int(comment_args.get(6, 0))
290 lifetime = float(comment_args.get(3, 4500))
291 duration = int(comment_args.get(9, lifetime*1000))
292 delay = int(comment_args.get(10, 0))
293 fontface = comment_args.get(12)
294 isborder = comment_args.get(11, 'true')
295 from_rotarg = ConvertFlashRotation(rotate_y, rotate_z, from_x, from_y, width, height)
296 to_rotarg = ConvertFlashRotation(rotate_y, rotate_z, to_x, to_y, width, height)
297 styles = ['\\org(%d, %d)' % (width/2, height/2)]
298 if from_rotarg[0:2] == to_rotarg[0:2]:
299 styles.append('\\pos(%.0f, %.0f)' % (from_rotarg[0:2]))
300 else:
301 styles.append('\\move(%.0f, %.0f, %.0f, %.0f, %.0f, %.0f)' % (from_rotarg[0:2]+to_rotarg[0:2]+(delay, delay+duration)))
302 styles.append('\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f' % (from_rotarg[2:7]))
303 if (from_x, from_y) != (to_x, to_y):
304 styles.append('\\t(%d, %d, ' % (delay, delay+duration))
305 styles.append('\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f' % (to_rotarg[2:7]))
306 styles.append(')')
307 if fontface:
308 styles.append('\\fn%s' % ASSEscape(fontface))
309 styles.append('\\fs%.0f' % (c[6]*ZoomFactor[0]))
310 if c[5] != 0xffffff:
311 styles.append('\\c&H%s&' % ConvertColor(c[5]))
312 if c[5] == 0x000000:
313 styles.append('\\3c&HFFFFFF&')
314 if from_alpha == to_alpha:
315 styles.append('\\alpha&H%02X' % from_alpha)
316 elif (from_alpha, to_alpha) == (255, 0):
317 styles.append('\\fad(%.0f,0)' % (lifetime*1000))
318 elif (from_alpha, to_alpha) == (0, 255):
319 styles.append('\\fad(0, %.0f)' % (lifetime*1000))
320 else:
321 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})
322 if isborder == 'false':
323 styles.append('\\bord0')
324 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})
325 except (IndexError, ValueError) as e:
326 try:
327 logging.warning(_('Invalid comment: %r') % c[3])
328 except IndexError:
329 logging.warning(_('Invalid comment: %r') % c)
332 def WriteCommentAcfunPositioned(f, c, width, height, styleid):
333 AcfunPlayerSize = (560, 400)
334 ZoomFactor = GetZoomFactor(AcfunPlayerSize, (width, height))
336 def GetPosition(InputPos, isHeight):
337 isHeight = int(isHeight) # True -> 1
338 return AcfunPlayerSize[isHeight]*ZoomFactor[0]*InputPos*0.001+ZoomFactor[isHeight+1]
340 def GetTransformStyles(x=None, y=None, scale_x=None, scale_y=None, rotate_z=None, rotate_y=None, color=None, alpha=None):
341 styles = []
342 out_x, out_y = x, y
343 if rotate_z is not None and rotate_y is not None:
344 assert x is not None
345 assert y is not None
346 rotarg = ConvertFlashRotation(rotate_y, rotate_z, x, y, width, height)
347 out_x, out_y = rotarg[0:2]
348 if scale_x is None:
349 scale_x = 1
350 if scale_y is None:
351 scale_y = 1
352 styles.append('\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f' % (rotarg[2:5]+(rotarg[5]*scale_x, rotarg[6]*scale_y)))
353 else:
354 if scale_x is not None:
355 styles.append('\\fscx%.0f' % (scale_x*100))
356 if scale_y is not None:
357 styles.append('\\fscy%.0f' % (scale_y*100))
358 if color is not None:
359 styles.append('\\c&H%s&' % ConvertColor(color))
360 if color == 0x000000:
361 styles.append('\\3c&HFFFFFF&')
362 if alpha is not None:
363 alpha = 255-round(alpha*255)
364 styles.append('\\alpha&H%02X' % alpha)
365 return out_x, out_y, styles
367 def FlushCommentLine(f, text, styles, start_time, end_time, styleid):
368 if end_time > start_time:
369 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})
371 try:
372 comment_args = c[3]
373 text = ASSEscape(str(comment_args['n']).replace('\r', '\n'))
374 common_styles = ['\org(%d, %d)' % (width/2, height/2)]
375 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)
376 if anchor != 7:
377 common_styles.append('\\an%s' % anchor)
378 font = comment_args.get('w')
379 if font:
380 font = dict(font)
381 fontface = font.get('f')
382 if fontface:
383 common_styles.append('\\fn%s' % ASSEscape(str(fontface)))
384 fontbold = bool(font.get('b'))
385 if fontbold:
386 common_styles.append('\\b1')
387 common_styles.append('\\fs%.0f' % (c[6]*ZoomFactor[0]))
388 isborder = bool(comment_args.get('b', True))
389 if not isborder:
390 common_styles.append('\\bord0')
391 to_pos = dict(comment_args.get('p', {'x': 0, 'y': 0}))
392 to_x = round(GetPosition(int(to_pos.get('x', 0)), False))
393 to_y = round(GetPosition(int(to_pos.get('y', 0)), True))
394 to_scale_x = float(comment_args.get('e', 1.0))
395 to_scale_y = float(comment_args.get('f', 1.0))
396 to_rotate_z = float(comment_args.get('r', 0.0))
397 to_rotate_y = float(comment_args.get('k', 0.0))
398 to_color = c[5]
399 to_alpha = float(comment_args.get('a', 1.0))
400 from_time = float(comment_args.get('t', 0.0))
401 action_time = float(comment_args.get('l', 3.0))
402 actions = list(comment_args.get('z', []))
403 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)
404 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)
405 action_styles = transform_styles
406 for action in actions:
407 action = dict(action)
408 from_x, from_y = to_x, to_y
409 from_out_x, from_out_y = to_out_x, to_out_y
410 from_scale_x, from_scale_y = to_scale_x, to_scale_y
411 from_rotate_z, from_rotate_y = to_rotate_z, to_rotate_y
412 from_color, from_alpha = to_color, to_alpha
413 transform_styles, action_styles = action_styles, []
414 from_time += action_time
415 action_time = float(action.get('l', 0.0))
416 if 'x' in action:
417 to_x = round(GetPosition(int(action['x']), False))
418 if 'y' in action:
419 to_y = round(GetPosition(int(action['y']), True))
420 if 'f' in action:
421 to_scale_x = float(action['f'])
422 if 'g' in action:
423 to_scale_y = float(action['g'])
424 if 'c' in action:
425 to_color = int(action['c'])
426 if 't' in action:
427 to_alpha = float(action['t'])
428 if 'd' in action:
429 to_rotate_z = float(action['d'])
430 if 'e' in action:
431 to_rotate_y = float(action['e'])
432 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)
433 if (from_out_x, from_out_y) == (to_out_x, to_out_y):
434 pos_style = '\\pos(%.0f, %.0f)' % (to_out_x, to_out_y)
435 else:
436 pos_style = '\\move(%.0f, %.0f, %.0f, %.0f)' % (from_out_x, from_out_y, to_out_x, to_out_y)
437 styles = common_styles+transform_styles
438 styles.append(pos_style)
439 if action_styles:
440 styles.append('\\t(%s)' % (''.join(action_styles)))
441 FlushCommentLine(f, text, styles, c[0]+from_time, c[0]+from_time+action_time, styleid)
442 except (IndexError, ValueError) as e:
443 logging.warning(_('Invalid comment: %r') % c[3])
446 # Result: (f, dx, dy)
447 # To convert: NewX = f*x+dx, NewY = f*y+dy
448 def GetZoomFactor(SourceSize, TargetSize):
449 try:
450 if (SourceSize, TargetSize) == GetZoomFactor.Cached_Size:
451 return GetZoomFactor.Cached_Result
452 except AttributeError:
453 pass
454 GetZoomFactor.Cached_Size = (SourceSize, TargetSize)
455 try:
456 SourceAspect = SourceSize[0]/SourceSize[1]
457 TargetAspect = TargetSize[0]/TargetSize[1]
458 if TargetAspect < SourceAspect: # narrower
459 ScaleFactor = TargetSize[0]/SourceSize[0]
460 GetZoomFactor.Cached_Result = (ScaleFactor, 0, (TargetSize[1]-TargetSize[0]/SourceAspect)/2)
461 elif TargetAspect > SourceAspect: # wider
462 ScaleFactor = TargetSize[1]/SourceSize[1]
463 GetZoomFactor.Cached_Result = (ScaleFactor, (TargetSize[0]-TargetSize[1]*SourceAspect)/2, 0)
464 else:
465 GetZoomFactor.Cached_Result = (TargetSize[0]/SourceSize[0], 0, 0)
466 return GetZoomFactor.Cached_Result
467 except ZeroDivisionError:
468 GetZoomFactor.Cached_Result = (1, 0, 0)
469 return GetZoomFactor.Cached_Result
472 # Calculation is based on https://github.com/jabbany/CommentCoreLibrary/issues/5#issuecomment-40087282
473 # and https://github.com/m13253/danmaku2ass/issues/7#issuecomment-41489422
474 # ASS FOV = width*4/3.0
475 # But Flash FOV = width/math.tan(100*math.pi/360.0)/2 will be used instead
476 # Result: (transX, transY, rotX, rotY, rotZ, scaleX, scaleY)
477 def ConvertFlashRotation(rotY, rotZ, X, Y, width, height):
478 def WrapAngle(deg):
479 return 180-((180-deg) % 360)
480 rotY = WrapAngle(rotY)
481 rotZ = WrapAngle(rotZ)
482 if rotY in (90, -90):
483 rotY -= 1
484 if rotY == 0 or rotZ == 0:
485 outX = 0
486 outY = -rotY # Positive value means clockwise in Flash
487 outZ = -rotZ
488 rotY *= math.pi/180.0
489 rotZ *= math.pi/180.0
490 else:
491 rotY *= math.pi/180.0
492 rotZ *= math.pi/180.0
493 outY = math.atan2(-math.sin(rotY)*math.cos(rotZ), math.cos(rotY))*180/math.pi
494 outZ = math.atan2(-math.cos(rotY)*math.sin(rotZ), math.cos(rotZ))*180/math.pi
495 outX = math.asin(math.sin(rotY)*math.sin(rotZ))*180/math.pi
496 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
497 trY = Y*math.cos(rotZ)-X*math.sin(rotZ)+math.sin(rotZ)*width/2+(1-math.cos(rotZ))*height/2
498 trZ = (trX-width/2)*math.sin(rotY)
499 FOV = width*math.tan(2*math.pi/9.0)/2
500 try:
501 scaleXY = FOV/(FOV+trZ)
502 except ZeroDivisionError:
503 logging.error('Rotation makes object behind the camera: trZ == %.0f' % trZ)
504 scaleXY = 1
505 trX = (trX-width/2)*scaleXY+width/2
506 trY = (trY-height/2)*scaleXY+height/2
507 if scaleXY < 0:
508 scaleXY = -scaleXY
509 outX += 180
510 outY += 180
511 logging.error('Rotation makes object behind the camera: trZ == %.0f < %.0f' % (trZ, FOV))
512 return (trX, trY, WrapAngle(outX), WrapAngle(outY), WrapAngle(outZ), scaleXY*100, scaleXY*100)
515 def ProcessComments(comments, f, width, height, bottomReserved, fontface, fontsize, alpha, duration_marquee, duration_still, filter_regex, reduced, progress_callback):
516 styleid = 'Danmaku2ASS_%04x' % random.randint(0, 0xffff)
517 WriteASSHead(f, width, height, fontface, fontsize, alpha, styleid)
518 rows = [[None]*(height-bottomReserved+1) for i in range(4)]
519 for idx, i in enumerate(comments):
520 if progress_callback and idx % 1000 == 0:
521 progress_callback(idx, len(comments))
522 if isinstance(i[4], int):
523 if filter_regex and filter_regex.search(i[3]):
524 continue
525 row = 0
526 rowmax = height-bottomReserved-i[7]
527 while row <= rowmax:
528 freerows = TestFreeRows(rows, i, row, width, height, bottomReserved, duration_marquee, duration_still)
529 if freerows >= i[7]:
530 MarkCommentRow(rows, i, row)
531 WriteComment(f, i, row, width, height, bottomReserved, fontsize, duration_marquee, duration_still, styleid)
532 break
533 else:
534 row += freerows or 1
535 else:
536 if not reduced:
537 row = FindAlternativeRow(rows, i, height, bottomReserved)
538 MarkCommentRow(rows, i, row)
539 WriteComment(f, i, row, width, height, bottomReserved, fontsize, duration_marquee, duration_still, styleid)
540 elif i[4] == 'bilipos':
541 WriteCommentBilibiliPositioned(f, i, width, height, styleid)
542 elif i[4] == 'acfunpos':
543 WriteCommentAcfunPositioned(f, i, width, height, styleid)
544 else:
545 logging.warning(_('Invalid comment: %r') % i[3])
546 if progress_callback:
547 progress_callback(len(comments), len(comments))
550 def TestFreeRows(rows, c, row, width, height, bottomReserved, duration_marquee, duration_still):
551 res = 0
552 rowmax = height-bottomReserved
553 targetRow = None
554 if c[4] in (1, 2):
555 while row < rowmax and res < c[7]:
556 if targetRow != rows[c[4]][row]:
557 targetRow = rows[c[4]][row]
558 if targetRow and targetRow[0]+duration_still > c[0]:
559 break
560 row += 1
561 res += 1
562 else:
563 try:
564 thresholdTime = c[0]-duration_marquee*(1-width/(c[8]+width))
565 except ZeroDivisionError:
566 thresholdTime = c[0]-duration_marquee
567 while row < rowmax and res < c[7]:
568 if targetRow != rows[c[4]][row]:
569 targetRow = rows[c[4]][row]
570 try:
571 if targetRow and (targetRow[0] > thresholdTime or targetRow[0]+targetRow[8]*duration_marquee/(targetRow[8]+width) > c[0]):
572 break
573 except ZeroDivisionError:
574 pass
575 row += 1
576 res += 1
577 return res
580 def FindAlternativeRow(rows, c, height, bottomReserved):
581 res = 0
582 for row in range(height-bottomReserved-math.ceil(c[7])):
583 if not rows[c[4]][row]:
584 return row
585 elif rows[c[4]][row][0] < rows[c[4]][res][0]:
586 res = row
587 return res
590 def MarkCommentRow(rows, c, row):
591 try:
592 for i in range(row, row+math.ceil(c[7])):
593 rows[c[4]][i] = c
594 except IndexError:
595 pass
598 def WriteASSHead(f, width, height, fontface, fontsize, alpha, styleid):
599 f.write(
600 '''[Script Info]
601 ; Script generated by Danmaku2ASS
602 ; https://github.com/m13253/danmaku2ass
603 Script Updated By: Danmaku2ASS (https://github.com/m13253/danmaku2ass)
604 ScriptType: v4.00+
605 PlayResX: %(width)d
606 PlayResY: %(height)d
607 Aspect Ratio: %(width)d:%(height)d
608 Collisions: Normal
609 WrapStyle: 2
610 ScaledBorderAndShadow: yes
611 YCbCr Matrix: TV.601
613 [V4+ Styles]
614 Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
615 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
617 [Events]
618 Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
619 ''' % {'width': width, 'height': height, 'fontface': fontface, 'fontsize': fontsize, 'alpha': 255-round(alpha*255), 'outline': max(fontsize/25.0, 1), 'styleid': styleid}
623 def WriteComment(f, c, row, width, height, bottomReserved, fontsize, duration_marquee, duration_still, styleid):
624 text = ASSEscape(c[3])
625 styles = []
626 if c[4] == 1:
627 styles.append('\\an8\\pos(%(halfwidth)d, %(row)d)' % {'halfwidth': width/2, 'row': row})
628 duration = duration_still
629 elif c[4] == 2:
630 styles.append('\\an2\\pos(%(halfwidth)d, %(row)d)' % {'halfwidth': width/2, 'row': ConvertType2(row, height, bottomReserved)})
631 duration = duration_still
632 elif c[4] == 3:
633 styles.append('\\move(%(neglen)d, %(row)d, %(width)d, %(row)d)' % {'width': width, 'row': row, 'neglen': -math.ceil(c[8])})
634 duration = duration_marquee
635 else:
636 styles.append('\\move(%(width)d, %(row)d, %(neglen)d, %(row)d)' % {'width': width, 'row': row, 'neglen': -math.ceil(c[8])})
637 duration = duration_marquee
638 if not (-1 < c[6]-fontsize < 1):
639 styles.append('\\fs%.0f' % c[6])
640 if c[5] != 0xffffff:
641 styles.append('\\c&H%s&' % ConvertColor(c[5]))
642 if c[5] == 0x000000:
643 styles.append('\\3c&HFFFFFF&')
644 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})
647 def ASSEscape(s):
648 def ReplaceLeadingSpace(s):
649 sstrip = s.strip(' ')
650 slen = len(s)
651 if slen == len(sstrip):
652 return s
653 else:
654 llen = slen-len(s.lstrip(' '))
655 rlen = slen-len(s.rstrip(' '))
656 return ''.join(('\u2007'*llen, sstrip, '\u2007'*rlen))
657 return '\\N'.join((ReplaceLeadingSpace(i) or ' ' for i in str(s).replace('\\', '\\\\').replace('{', '\\{').replace('}', '\\}').split('\n')))
660 def CalculateLength(s):
661 return max(map(len, s.split('\n'))) # May not be accurate
664 def ConvertTimestamp(timestamp):
665 timestamp = round(timestamp*100.0)
666 hour, minute = divmod(timestamp, 360000)
667 minute, second = divmod(minute, 6000)
668 second, centsecond = divmod(second, 100)
669 return '%d:%02d:%02d.%02d' % (int(hour), int(minute), int(second), int(centsecond))
672 def ConvertColor(RGB, width=1280, height=576):
673 if RGB == 0x000000:
674 return '000000'
675 elif RGB == 0xffffff:
676 return 'FFFFFF'
677 R = (RGB >> 16) & 0xff
678 G = (RGB >> 8) & 0xff
679 B = RGB & 0xff
680 if width < 1280 and height < 576:
681 return '%02X%02X%02X' % (B, G, R)
682 else: # VobSub always uses BT.601 colorspace, convert to BT.709
683 ClipByte = lambda x: 255 if x > 255 else 0 if x < 0 else round(x)
684 return '%02X%02X%02X' % (
685 ClipByte(R*0.00956384088080656+G*0.03217254540203729+B*0.95826361371715607),
686 ClipByte(R*-0.10493933142075390+G*1.17231478191855154+B*-0.06737545049779757),
687 ClipByte(R*0.91348912373987645+G*0.07858536372532510+B*0.00792551253479842)
691 def ConvertType2(row, height, bottomReserved):
692 return height-bottomReserved-row
695 def ConvertToFile(filename_or_file, *args, **kwargs):
696 if isinstance(filename_or_file, bytes):
697 filename_or_file = str(bytes(filename_or_file).decode('utf-8', 'replace'))
698 if isinstance(filename_or_file, str):
699 return open(filename_or_file, *args, **kwargs)
700 else:
701 return filename_or_file
704 def FilterBadChars(f):
705 s = f.read()
706 s = re.sub('[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f]', '\ufffd', s)
707 return io.StringIO(s)
710 class safe_list(list):
711 def get(self, index, default=None):
712 try:
713 return self[index]
714 except IndexError:
715 return default
718 def export(func):
719 global __all__
720 try:
721 __all__.append(func.__name__)
722 except NameError:
723 __all__ = [func.__name__]
724 return func
727 @export
728 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, is_reduce_comments=False, progress_callback=None):
729 try:
730 if comment_filter:
731 filter_regex = re.compile(comment_filter)
732 else:
733 filter_regex = None
734 except:
735 raise ValueError(_('Invalid regular expression: %s') % comment_filter)
736 fo = None
737 comments = ReadComments(input_files, input_format, font_size)
738 try:
739 if output_file:
740 fo = ConvertToFile(output_file, 'w', encoding='utf-8-sig', errors='replace', newline='\r\n')
741 else:
742 fo = sys.stdout
743 ProcessComments(comments, fo, stage_width, stage_height, reserve_blank, font_face, font_size, text_opacity, duration_marquee, duration_still, filter_regex, is_reduce_comments, progress_callback)
744 finally:
745 if output_file and fo != output_file:
746 fo.close()
749 @export
750 def ReadComments(input_files, input_format, font_size=25.0, progress_callback=None):
751 if isinstance(input_files, bytes):
752 input_files = str(bytes(input_files).decode('utf-8', 'replace'))
753 if isinstance(input_files, str):
754 input_files = [input_files]
755 else:
756 input_files = list(input_files)
757 comments = []
758 for idx, i in enumerate(input_files):
759 if progress_callback:
760 progress_callback(idx, len(input_files))
761 with ConvertToFile(i, 'r', encoding='utf-8', errors='replace') as f:
762 s = f.read()
763 str_io = io.StringIO(s)
764 if input_format == 'autodetect':
765 CommentProcessor = GetCommentProcessor(str_io)
766 if not CommentProcessor:
767 raise ValueError(
768 _('Failed to detect comment file format: %s') % i
770 else:
771 CommentProcessor = CommentFormatMap.get(input_format)
772 if not CommentProcessor:
773 raise ValueError(
774 _('Unknown comment file format: %s') % input_format
776 comments.extend(CommentProcessor(FilterBadChars(str_io), font_size))
777 if progress_callback:
778 progress_callback(len(input_files), len(input_files))
779 comments.sort()
780 return comments
783 @export
784 def GetCommentProcessor(input_file):
785 return CommentFormatMap.get(ProbeCommentFormat(input_file))
788 def main():
789 logging.basicConfig(format='%(levelname)s: %(message)s')
790 if len(sys.argv) == 1:
791 sys.argv.append('--help')
792 parser = argparse.ArgumentParser()
793 parser.add_argument('-f', '--format', metavar=_('FORMAT'), help=_('Format of input file (autodetect|%s) [default: autodetect]') % '|'.join(i for i in CommentFormatMap), default='autodetect')
794 parser.add_argument('-o', '--output', metavar=_('OUTPUT'), help=_('Output file'))
795 parser.add_argument('-s', '--size', metavar=_('WIDTHxHEIGHT'), required=True, help=_('Stage size in pixels'))
796 parser.add_argument('-fn', '--font', metavar=_('FONT'), help=_('Specify font face [default: %s]') % _('(FONT) sans-serif')[7:], default=_('(FONT) sans-serif')[7:])
797 parser.add_argument('-fs', '--fontsize', metavar=_('SIZE'), help=(_('Default font size [default: %s]') % 25), type=float, default=25.0)
798 parser.add_argument('-a', '--alpha', metavar=_('ALPHA'), help=_('Text opacity'), type=float, default=1.0)
799 parser.add_argument('-dm', '--duration-marquee', metavar=_('SECONDS'), help=_('Duration of scrolling comment display [default: %s]') % 5, type=float, default=5.0)
800 parser.add_argument('-ds', '--duration-still', metavar=_('SECONDS'), help=_('Duration of still comment display [default: %s]') % 5, type=float, default=5.0)
801 parser.add_argument('-fl', '--filter', help=_('Regular expression to filter comments'))
802 parser.add_argument('-p', '--protect', metavar=_('HEIGHT'), help=_('Reserve blank on the bottom of the stage'), type=int, default=0)
803 parser.add_argument('-r', '--reduce', action='store_true', help=_('Reduce the amount of comments if stage is full'))
804 parser.add_argument('file', metavar=_('FILE'), nargs='+', help=_('Comment file to be processed'))
805 args = parser.parse_args()
806 try:
807 width, height = str(args.size).split('x', 1)
808 width = int(width)
809 height = int(height)
810 except ValueError:
811 raise ValueError(_('Invalid stage size: %r') % args.size)
812 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.reduce)
815 if __name__ == '__main__':
816 main()