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
30 import xml
.dom
.minidom
31 from itertools
import imap
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_
):
47 return function(file_
)
50 return decorated_function
53 def EOFAsNone(function
):
54 def decorated_function(*args
, **kwargs
):
56 return function(*args
, **kwargs
)
59 return decorated_function
64 def ProbeCommentFormat(f
):
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!
73 if tmp
== '"status_code":':
75 elif tmp
== '"root":{"total':
81 if tmp
== 'xml version="1.0" encoding="UTF-8"?><p':
83 elif tmp
== 'xml version="1.0" encoding="UTF-8"?><i':
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<':
92 return 'Niconico' # Himawari Douga, with the same file format as Niconico Douga
96 # ReadComments**** protocol
100 # fontsize: Default font size
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
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
:
132 c
= str(comment
.childNodes
[0].wholeText
)
133 if c
.startswith('/'):
134 continue # ignore advanced comments
138 for mailstyle
in str(comment
.getAttribute('mail')).split():
139 if mailstyle
== 'ue':
141 elif mailstyle
== 'shita':
143 elif mailstyle
== 'big':
145 elif mailstyle
== 'small':
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())
155 def ReadCommentsAcfun(f
, fontsize
):
156 comment_element
= json
.load(f
)
157 for i
, comment
in enumerate(comment_element
):
159 p
= str(comment
['c']).split(',')
161 assert p
[2] in ('1', '2', '4', '5', '7')
162 size
= int(p
[3])*fontsize
/25.0
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
)
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
)
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
):
179 p
= str(comment
.getAttribute('p')).split(',')
181 assert p
[1] in ('1', '4', '5', '6', '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())
194 def ReadCommentsTudou(f
, fontsize
):
195 comment_element
= json
.load(f
)
196 for i
, comment
in enumerate(comment_element
['comment_list']):
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
)
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
):
214 message
= comment
.getElementsByTagName('message')[0]
215 c
= str(message
.childNodes
[0].wholeText
)
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())
224 def ReadCommentsSH5V(f
, fontsize
):
225 comment_element
= json
.load(f
)
226 for i
, comment
in enumerate(comment_element
["root"]["bgs"]):
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'])
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
)
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
)
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):
266 return ZoomFactor
[0]*InputPos
+ZoomFactor
[isHeight
+1]
268 return BiliPlayerSize
[isHeight
]*ZoomFactor
[0]*InputPos
+ZoomFactor
[isHeight
+1]
271 InputPos
= int(InputPos
)
273 InputPos
= float(InputPos
)
274 return GetPosition(InputPos
, isHeight
)
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]))
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]))
312 styles
.append('\\fn%s' % ASSEscape(fontface
))
313 styles
.append('\\fs%.0f' % (c
[6]*ZoomFactor
[0]))
315 styles
.append('\\c&H%s&' % ConvertColor(c
[5]))
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))
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
:
331 logging
.warning(_('Invalid comment: %r') % c
[3])
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):
347 if rotate_z
is not None and rotate_y
is not None:
350 rotarg
= ConvertFlashRotation(rotate_y
, rotate_z
, x
, y
, width
, height
)
351 out_x
, out_y
= rotarg
[0:2]
356 styles
.append('\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f' % (rotarg
[2:5]+(rotarg
[5]*scale_x
, rotarg
[6]*scale_y
)))
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
})
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)
381 common_styles
.append('\\an%s' % anchor
)
382 font
= comment_args
.get('w')
385 fontface
= font
.get('f')
387 common_styles
.append('\\fn%s' % ASSEscape(str(fontface
)))
388 fontbold
= bool(font
.get('b'))
390 common_styles
.append('\\b1')
391 common_styles
.append('\\fs%.0f' % (c
[6]*ZoomFactor
[0]))
392 isborder
= bool(comment_args
.get('b', True))
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))
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))
421 to_x
= round(GetPosition(int(action
['x']), False))
423 to_y
= round(GetPosition(int(action
['y']), True))
425 to_scale_x
= float(action
['f'])
427 to_scale_y
= float(action
['g'])
429 to_color
= int(action
['c'])
431 to_alpha
= float(action
['t'])
433 to_rotate_z
= float(action
['d'])
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
)
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
)
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):
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
)
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
})
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])
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
):
498 if (SourceSize
, TargetSize
) == GetZoomFactor
.Cached_Size
:
499 return GetZoomFactor
.Cached_Result
500 except AttributeError:
502 GetZoomFactor
.Cached_Size
= (SourceSize
, TargetSize
)
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)
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
):
527 return 180-((180-deg
) % 360)
528 rotY
= WrapAngle(rotY
)
529 rotZ
= WrapAngle(rotZ
)
530 if rotY
in (90, -90):
532 if rotY
== 0 or rotZ
== 0:
534 outY
= -rotY
# Positive value means clockwise in Flash
536 rotY
*= math
.pi
/180.0
537 rotZ
*= math
.pi
/180.0
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
549 scaleXY
= FOV
/(FOV
+trZ
)
550 except ZeroDivisionError:
551 logging
.error('Rotation makes object behind the camera: trZ == %.0f' % trZ
)
553 trX
= (trX
-width
/2)*scaleXY
+width
/2
554 trY
= (trY
-height
/2)*scaleXY
+height
/2
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):
572 rowmax
= height
-bottomReserved
-i
[7]
574 freerows
= TestFreeRows(rows
, i
, row
, width
, height
, bottomReserved
, duration_marquee
, duration_still
)
576 MarkCommentRow(rows
, i
, row
)
577 WriteComment(f
, i
, row
, width
, height
, bottomReserved
, fontsize
, duration_marquee
, duration_still
, styleid
)
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
)
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
):
600 rowmax
= height
-bottomReserved
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]:
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
]
619 if targetRow
and (targetRow
[0] > thresholdTime
or targetRow
[0]+targetRow
[8]*duration_marquee
/(targetRow
[8]+width
) > c
[0]):
621 except ZeroDivisionError:
628 def FindAlternativeRow(rows
, c
, height
, bottomReserved
):
630 for row
in xrange(height
-bottomReserved
-int(math
.ceil(c
[7]))):
631 if not rows
[c
[4]][row
]:
633 elif rows
[c
[4]][row
][0] < rows
[c
[4]][res
][0]:
638 def MarkCommentRow(rows
, c
, row
):
640 for i
in xrange(row
, row
+int(math
.ceil(c
[7]))):
646 def WriteASSHead(f
, width
, height
, fontface
, fontsize
, alpha
, styleid
):
650 ; Script generated by Danmaku2ASS
651 ; https://github.com/m13253/danmaku2ass
652 Script Updated By: Danmaku2ASS (https://github.com/m13253/danmaku2ass)
656 Aspect Ratio: %(width)d:%(height)d
659 ScaledBorderAndShadow: yes
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
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])
676 styles
.append('\\an8\\pos(%(halfwidth)d, %(row)d)' % {'halfwidth': width
/2, 'row': row
})
677 duration
= duration_still
679 styles
.append('\\an2\\pos(%(halfwidth)d, %(row)d)' % {'halfwidth': width
/2, 'row': ConvertType2(row
, height
, bottomReserved
)})
680 duration
= duration_still
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
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])
690 styles
.append('\\c&H%s&' % ConvertColor(c
[5]))
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
})
697 def ReplaceLeadingSpace(s
):
698 sstrip
= s
.strip(' ')
700 if slen
== len(sstrip
):
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):
724 elif RGB
== 0xffffff:
726 R
= (RGB
>> 16) & 0xff
727 G
= (RGB
>> 8) & 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
)
750 return filename_or_file
753 def FilterBadChars(f
):
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):
770 __all__
.append(func
.__name
__)
772 __all__
= [func
.__name
__]
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):
779 comments
= ReadComments(input_files
, font_size
)
782 fo
= ConvertToFile(output_file
, 'w', encoding
='utf-8-sig', errors
='replace', newline
='\r\n')
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
)
787 if output_file
and fo
!= output_file
:
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
]
798 input_files
= list(input_files
)
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
))
815 def GetCommentProcessor(input_file
):
816 return CommentFormatMap
[ProbeCommentFormat(input_file
)]
818 def turn_List_Unicode(List
):
821 Files
.append(_file
.decode(sys
.stdin
.encoding
))
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()
841 width
, height
= bytes(args
.size
).decode('utf-8', 'replace').split('x', 1)
844 args
.file = turn_List_Unicode(args
.file)
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__':