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.
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_
):
39 return function(file_
)
42 return decorated_function
45 def EOFAsNone(function
):
46 def decorated_function(*args
, **kwargs
):
48 return function(*args
, **kwargs
)
51 return decorated_function
56 def ProbeCommentFormat(f
):
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!
65 if tmp
== '"status_code":':
67 elif tmp
.strip().startswith('"result'):
73 if tmp
== 'xml version="1.0" encoding="UTF-8"?><p':
75 elif tmp
== 'xml version="1.0" encoding="UTF-8"?><i':
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<':
83 if tmp
== '!-- BoonSutazioData=':
84 return 'Niconico' # Niconico videos downloaded with NicoFox
88 return 'Niconico' # Himawari Douga, with the same file format as Niconico Douga
92 # ReadComments**** protocol
96 # fontsize: Default font size
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
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
:
128 c
= str(comment
.childNodes
[0].wholeText
)
129 if c
.startswith('/'):
130 continue # ignore advanced comments
134 for mailstyle
in str(comment
.getAttribute('mail')).split():
135 if mailstyle
== 'ue':
137 elif mailstyle
== 'shita':
139 elif mailstyle
== 'big':
141 elif mailstyle
== 'small':
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())
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
):
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
.parse(f
)
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', '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)
191 pass # ignore scripted comment
192 except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
193 logging
.warning(_('Invalid comment: %s') % comment
.toxml())
197 def ReadCommentsTudou(f
, fontsize
):
198 comment_element
= json
.load(f
)
199 for i
, comment
in enumerate(comment_element
['comment_list']):
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
)
211 def ReadCommentsTudou2(f
, fontsize
):
212 comment_element
= json
.load(f
)
213 for i
, comment
in enumerate(comment_element
['result']):
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)
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
)
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
):
237 message
= comment
.getElementsByTagName('message')[0]
238 c
= str(message
.childNodes
[0].wholeText
)
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())
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):
262 return ZoomFactor
[0]*InputPos
+ZoomFactor
[isHeight
+1]
264 return BiliPlayerSize
[isHeight
]*ZoomFactor
[0]*InputPos
+ZoomFactor
[isHeight
+1]
267 InputPos
= int(InputPos
)
269 InputPos
= float(InputPos
)
270 return GetPosition(InputPos
, isHeight
)
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]))
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]))
308 styles
.append('\\fn%s' % ASSEscape(fontface
))
309 styles
.append('\\fs%.0f' % (c
[6]*ZoomFactor
[0]))
311 styles
.append('\\c&H%s&' % ConvertColor(c
[5]))
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))
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
:
327 logging
.warning(_('Invalid comment: %r') % c
[3])
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):
343 if rotate_z
is not None and rotate_y
is not None:
346 rotarg
= ConvertFlashRotation(rotate_y
, rotate_z
, x
, y
, width
, height
)
347 out_x
, out_y
= rotarg
[0:2]
352 styles
.append('\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f' % (rotarg
[2:5]+(rotarg
[5]*scale_x
, rotarg
[6]*scale_y
)))
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
})
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)
377 common_styles
.append('\\an%s' % anchor
)
378 font
= comment_args
.get('w')
381 fontface
= font
.get('f')
383 common_styles
.append('\\fn%s' % ASSEscape(str(fontface
)))
384 fontbold
= bool(font
.get('b'))
386 common_styles
.append('\\b1')
387 common_styles
.append('\\fs%.0f' % (c
[6]*ZoomFactor
[0]))
388 isborder
= bool(comment_args
.get('b', True))
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))
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))
417 to_x
= round(GetPosition(int(action
['x']), False))
419 to_y
= round(GetPosition(int(action
['y']), True))
421 to_scale_x
= float(action
['f'])
423 to_scale_y
= float(action
['g'])
425 to_color
= int(action
['c'])
427 to_alpha
= float(action
['t'])
429 to_rotate_z
= float(action
['d'])
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
)
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
)
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
):
450 if (SourceSize
, TargetSize
) == GetZoomFactor
.Cached_Size
:
451 return GetZoomFactor
.Cached_Result
452 except AttributeError:
454 GetZoomFactor
.Cached_Size
= (SourceSize
, TargetSize
)
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)
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
):
479 return 180-((180-deg
) % 360)
480 rotY
= WrapAngle(rotY
)
481 rotZ
= WrapAngle(rotZ
)
482 if rotY
in (90, -90):
484 if rotY
== 0 or rotZ
== 0:
486 outY
= -rotY
# Positive value means clockwise in Flash
488 rotY
*= math
.pi
/180.0
489 rotZ
*= math
.pi
/180.0
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
501 scaleXY
= FOV
/(FOV
+trZ
)
502 except ZeroDivisionError:
503 logging
.error('Rotation makes object behind the camera: trZ == %.0f' % trZ
)
505 trX
= (trX
-width
/2)*scaleXY
+width
/2
506 trY
= (trY
-height
/2)*scaleXY
+height
/2
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]):
526 rowmax
= height
-bottomReserved
-i
[7]
528 freerows
= TestFreeRows(rows
, i
, row
, width
, height
, bottomReserved
, duration_marquee
, duration_still
)
530 MarkCommentRow(rows
, i
, row
)
531 WriteComment(f
, i
, row
, width
, height
, bottomReserved
, fontsize
, duration_marquee
, duration_still
, styleid
)
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
)
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
):
552 rowmax
= height
-bottomReserved
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]:
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
]
571 if targetRow
and (targetRow
[0] > thresholdTime
or targetRow
[0]+targetRow
[8]*duration_marquee
/(targetRow
[8]+width
) > c
[0]):
573 except ZeroDivisionError:
580 def FindAlternativeRow(rows
, c
, height
, bottomReserved
):
582 for row
in range(height
-bottomReserved
-math
.ceil(c
[7])):
583 if not rows
[c
[4]][row
]:
585 elif rows
[c
[4]][row
][0] < rows
[c
[4]][res
][0]:
590 def MarkCommentRow(rows
, c
, row
):
592 for i
in range(row
, row
+math
.ceil(c
[7])):
598 def WriteASSHead(f
, width
, height
, fontface
, fontsize
, alpha
, styleid
):
601 ; Script generated by Danmaku2ASS
602 ; https://github.com/m13253/danmaku2ass
603 Script Updated By: Danmaku2ASS (https://github.com/m13253/danmaku2ass)
607 Aspect Ratio: %(width)d:%(height)d
610 ScaledBorderAndShadow: yes
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
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])
627 styles
.append('\\an8\\pos(%(halfwidth)d, %(row)d)' % {'halfwidth': width
/2, 'row': row
})
628 duration
= duration_still
630 styles
.append('\\an2\\pos(%(halfwidth)d, %(row)d)' % {'halfwidth': width
/2, 'row': ConvertType2(row
, height
, bottomReserved
)})
631 duration
= duration_still
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
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])
641 styles
.append('\\c&H%s&' % ConvertColor(c
[5]))
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
})
648 def ReplaceLeadingSpace(s
):
649 sstrip
= s
.strip(' ')
651 if slen
== len(sstrip
):
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):
675 elif RGB
== 0xffffff:
677 R
= (RGB
>> 16) & 0xff
678 G
= (RGB
>> 8) & 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
)
701 return filename_or_file
704 def FilterBadChars(f
):
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):
721 __all__
.append(func
.__name
__)
723 __all__
= [func
.__name
__]
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):
731 filter_regex
= re
.compile(comment_filter
)
735 raise ValueError(_('Invalid regular expression: %s') % comment_filter
)
737 comments
= ReadComments(input_files
, input_format
, font_size
)
740 fo
= ConvertToFile(output_file
, 'w', encoding
='utf-8-sig', errors
='replace', newline
='\r\n')
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
)
745 if output_file
and fo
!= output_file
:
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
]
756 input_files
= list(input_files
)
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
:
763 str_io
= io
.StringIO(s
)
764 if input_format
== 'autodetect':
765 CommentProcessor
= GetCommentProcessor(str_io
)
766 if not CommentProcessor
:
768 _('Failed to detect comment file format: %s') % i
771 CommentProcessor
= CommentFormatMap
.get(input_format
)
772 if not CommentProcessor
:
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
))
784 def GetCommentProcessor(input_file
):
785 return CommentFormatMap
.get(ProbeCommentFormat(input_file
))
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()
807 width
, height
= str(args
.size
).split('x', 1)
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__':