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
== '"root":{"total':
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<':
84 return 'Niconico' # Himawari Douga, with the same file format as Niconico Douga
88 # ReadComments**** protocol
92 # fontsize: Default font size
96 # (timeline, timestamp, no, comment, pos, color, size, height, width)
97 # timeline: The position when the comment is replayed
98 # timestamp: The UNIX timestamp when the comment is submitted
99 # no: A sequence of 1, 2, 3, ..., used for sorting
100 # comment: The content of the comment
101 # pos: 0 for regular moving comment,
102 # 1 for bottom centered comment,
103 # 2 for top centered comment,
104 # 3 for reversed moving comment
105 # color: Font color represented in 0xRRGGBB,
106 # e.g. 0xffffff for white
108 # height: The estimated height in pixels
109 # i.e. (comment.count('\n')+1)*size
110 # width: The estimated width in pixels
111 # i.e. CalculateLength(comment)*size
113 # After implementing ReadComments****, make sure to update ProbeCommentFormat
114 # and CommentFormatMap.
118 def ReadCommentsNiconico(f
, fontsize
):
119 NiconicoColorMap
= {'red': 0xff0000, 'pink': 0xff8080, 'orange': 0xffcc00, 'yellow': 0xffff00, 'green': 0x00ff00, 'cyan': 0x00ffff, 'blue': 0x0000ff, 'purple': 0xc000ff, 'black': 0x000000, 'niconicowhite': 0xcccc99, 'white2': 0xcccc99, 'truered': 0xcc0033, 'red2': 0xcc0033, 'passionorange': 0xff6600, 'orange2': 0xff6600, 'madyellow': 0x999900, 'yellow2': 0x999900, 'elementalgreen': 0x00cc66, 'green2': 0x00cc66, 'marineblue': 0x33ffcc, 'blue2': 0x33ffcc, 'nobleviolet': 0x6633cc, 'purple2': 0x6633cc}
120 dom
= xml
.dom
.minidom
.parse(f
)
121 comment_element
= dom
.getElementsByTagName('chat')
122 for comment
in comment_element
:
124 c
= str(comment
.childNodes
[0].wholeText
)
125 if c
.startswith('/'):
126 continue # ignore advanced comments
130 for mailstyle
in str(comment
.getAttribute('mail')).split():
131 if mailstyle
== 'ue':
133 elif mailstyle
== 'shita':
135 elif mailstyle
== 'big':
137 elif mailstyle
== 'small':
139 elif mailstyle
in NiconicoColorMap
:
140 color
= NiconicoColorMap
[mailstyle
]
141 yield (max(int(comment
.getAttribute('vpos')), 0)*0.01, int(comment
.getAttribute('date')), int(comment
.getAttribute('no')), c
, pos
, color
, size
, (c
.count('\n')+1)*size
, CalculateLength(c
)*size
)
142 except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
143 logging
.warning(_('Invalid comment: %s') % comment
.toxml())
147 def ReadCommentsAcfun(f
, fontsize
):
148 comment_element
= json
.load(f
)
149 for i
, comment
in enumerate(comment_element
):
151 p
= str(comment
['c']).split(',')
153 assert p
[2] in ('1', '2', '4', '5', '7')
154 size
= int(p
[3])*fontsize
/25.0
156 c
= str(comment
['m']).replace('\\r', '\n').replace('\r', '\n')
157 yield (float(p
[0]), int(p
[5]), i
, c
, {'1': 0, '2': 0, '4': 2, '5': 1}[p
[2]], int(p
[1]), size
, (c
.count('\n')+1)*size
, CalculateLength(c
)*size
)
159 c
= dict(json
.loads(comment
['m']))
160 yield (float(p
[0]), int(p
[5]), i
, c
, 'acfunpos', int(p
[1]), size
, 0, 0)
161 except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
162 logging
.warning(_('Invalid comment: %r') % comment
)
166 def ReadCommentsBilibili(f
, fontsize
):
167 dom
= xml
.dom
.minidom
.parse(f
)
168 comment_element
= dom
.getElementsByTagName('d')
169 for i
, comment
in enumerate(comment_element
):
171 p
= str(comment
.getAttribute('p')).split(',')
173 assert p
[1] in ('1', '4', '5', '6', '7')
175 c
= str(comment
.childNodes
[0].wholeText
).replace('/n', '\n')
176 size
= int(p
[2])*fontsize
/25.0
177 yield (float(p
[0]), int(p
[4]), i
, c
, {'1': 0, '4': 2, '5': 1, '6': 3}[p
[1]], int(p
[3]), size
, (c
.count('\n')+1)*size
, CalculateLength(c
)*size
)
178 else: # positioned comment
179 c
= str(comment
.childNodes
[0].wholeText
)
180 yield (float(p
[0]), int(p
[4]), i
, c
, 'bilipos', int(p
[3]), int(p
[2]), 0, 0)
181 except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
182 logging
.warning(_('Invalid comment: %s') % comment
.toxml())
186 def ReadCommentsTudou(f
, fontsize
):
187 comment_element
= json
.load(f
)
188 for i
, comment
in enumerate(comment_element
['comment_list']):
190 assert comment
['pos'] in (3, 4, 6)
191 c
= str(comment
['data'])
192 assert comment
['size'] in (0, 1, 2)
193 size
= {0: 0.64, 1: 1, 2: 1.44}[comment
['size']]*fontsize
194 yield (int(comment
['replay_time']*0.001), int(comment
['commit_time']), i
, c
, {3: 0, 4: 2, 6: 1}[comment
['pos']], int(comment
['color']), size
, (c
.count('\n')+1)*size
, CalculateLength(c
)*size
)
195 except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
196 logging
.warning(_('Invalid comment: %r') % comment
)
200 def ReadCommentsMioMio(f
, fontsize
):
201 NiconicoColorMap
= {'red': 0xff0000, 'pink': 0xff8080, 'orange': 0xffc000, 'yellow': 0xffff00, 'green': 0x00ff00, 'cyan': 0x00ffff, 'blue': 0x0000ff, 'purple': 0xc000ff, 'black': 0x000000}
202 dom
= xml
.dom
.minidom
.parse(f
)
203 comment_element
= dom
.getElementsByTagName('data')
204 for i
, comment
in enumerate(comment_element
):
206 message
= comment
.getElementsByTagName('message')[0]
207 c
= str(message
.childNodes
[0].wholeText
)
209 size
= int(message
.getAttribute('fontsize'))*fontsize
/25.0
210 yield (float(comment
.getElementsByTagName('playTime')[0].childNodes
[0].wholeText
), int(calendar
.timegm(time
.strptime(comment
.getElementsByTagName('times')[0].childNodes
[0].wholeText
, '%Y-%m-%d %H:%M:%S')))-28800, i
, c
, {'1': 0, '4': 2, '5': 1}[message
.getAttribute('mode')], int(message
.getAttribute('color')), size
, (c
.count('\n')+1)*size
, CalculateLength(c
)*size
)
211 except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
212 logging
.warning(_('Invalid comment: %s') % comment
.toxml())
216 def ReadCommentsSH5V(f
, fontsize
):
217 comment_element
= json
.load(f
)
218 for i
, comment
in enumerate(comment_element
["root"]["bgs"]):
220 c_at
= str(comment
['at'])
221 c_type
= str(comment
['type'])
222 c_date
= str(comment
['timestamp'])
223 c_color
= str(comment
['color'])
224 c
= str(comment
['text'])
227 yield (float(c_at
), int(c_date
), i
, c
, {'0': 0, '1': 0, '4': 2, '5': 1}[c_type
], int(c_color
[1:], 16), size
, (c
.count('\n')+1)*size
, CalculateLength(c
)*size
)
229 c_x
= float(comment
['x'])
230 c_y
= float(comment
['y'])
231 size
= int(comment
['size'])
232 dur
= int(comment
['dur'])
233 data1
= float(comment
['data1'])
234 data2
= float(comment
['data2'])
235 data3
= int(comment
['data3'])
236 data4
= int(comment
['data4'])
237 yield (float(c_at
), int(c_date
), i
, c
, 'sH5Vpos', int(c_color
[1:], 16), size
, 0, 0, c_x
, c_y
, dur
, data1
, data2
, data3
, data4
)
238 except (AssertionError, AttributeError, IndexError, TypeError, ValueError):
239 logging
.warning(_('Invalid comment: %r') % comment
)
243 CommentFormatMap
= {None: None, 'Niconico': ReadCommentsNiconico
, 'Acfun': ReadCommentsAcfun
, 'Bilibili': ReadCommentsBilibili
, 'Tudou': ReadCommentsTudou
, 'MioMio': ReadCommentsMioMio
, 'sH5V': ReadCommentsSH5V
}
246 def WriteCommentBilibiliPositioned(f
, c
, width
, height
, styleid
):
247 #BiliPlayerSize = (512, 384) # Bilibili player version 2010
248 #BiliPlayerSize = (540, 384) # Bilibili player version 2012
249 BiliPlayerSize
= (672, 438) # Bilibili player version 2014
250 ZoomFactor
= GetZoomFactor(BiliPlayerSize
, (width
, height
))
252 def GetPosition(InputPos
, isHeight
):
253 isHeight
= int(isHeight
) # True -> 1
254 if isinstance(InputPos
, int):
255 return ZoomFactor
[0]*InputPos
+ZoomFactor
[isHeight
+1]
256 elif isinstance(InputPos
, float):
258 return ZoomFactor
[0]*InputPos
+ZoomFactor
[isHeight
+1]
260 return BiliPlayerSize
[isHeight
]*ZoomFactor
[0]*InputPos
+ZoomFactor
[isHeight
+1]
263 InputPos
= int(InputPos
)
265 InputPos
= float(InputPos
)
266 return GetPosition(InputPos
, isHeight
)
269 comment_args
= safe_list(json
.loads(c
[3]))
270 text
= ASSEscape(str(comment_args
[4]).replace('/n', '\n'))
271 from_x
= comment_args
.get(0, 0)
272 from_y
= comment_args
.get(1, 0)
273 to_x
= comment_args
.get(7, from_x
)
274 to_y
= comment_args
.get(8, from_y
)
275 from_x
= GetPosition(from_x
, False)
276 from_y
= GetPosition(from_y
, True)
277 to_x
= GetPosition(to_x
, False)
278 to_y
= GetPosition(to_y
, True)
279 alpha
= safe_list(str(comment_args
.get(2, '1')).split('-'))
280 from_alpha
= float(alpha
.get(0, 1))
281 to_alpha
= float(alpha
.get(1, from_alpha
))
282 from_alpha
= 255-round(from_alpha
*255)
283 to_alpha
= 255-round(to_alpha
*255)
284 rotate_z
= int(comment_args
.get(5, 0))
285 rotate_y
= int(comment_args
.get(6, 0))
286 lifetime
= float(comment_args
.get(3, 4500))
287 duration
= int(comment_args
.get(9, lifetime
*1000))
288 delay
= int(comment_args
.get(10, 0))
289 fontface
= comment_args
.get(12)
290 isborder
= comment_args
.get(11, 'true')
291 from_rotarg
= ConvertFlashRotation(rotate_y
, rotate_z
, from_x
, from_y
, width
, height
)
292 to_rotarg
= ConvertFlashRotation(rotate_y
, rotate_z
, to_x
, to_y
, width
, height
)
293 styles
= ['\\org(%d, %d)' % (width
/2, height
/2)]
294 if from_rotarg
[0:2] == to_rotarg
[0:2]:
295 styles
.append('\\pos(%.0f, %.0f)' % (from_rotarg
[0:2]))
297 styles
.append('\\move(%.0f, %.0f, %.0f, %.0f, %.0f, %.0f)' % (from_rotarg
[0:2]+to_rotarg
[0:2]+(delay
, delay
+duration
)))
298 styles
.append('\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f' % (from_rotarg
[2:7]))
299 if (from_x
, from_y
) != (to_x
, to_y
):
300 styles
.append('\\t(%d, %d, ' % (delay
, delay
+duration
))
301 styles
.append('\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f' % (to_rotarg
[2:7]))
304 styles
.append('\\fn%s' % ASSEscape(fontface
))
305 styles
.append('\\fs%.0f' % (c
[6]*ZoomFactor
[0]))
307 styles
.append('\\c&H%s&' % ConvertColor(c
[5]))
309 styles
.append('\\3c&HFFFFFF&')
310 if from_alpha
== to_alpha
:
311 styles
.append('\\alpha&H%02X' % from_alpha
)
312 elif (from_alpha
, to_alpha
) == (255, 0):
313 styles
.append('\\fad(%.0f,0)' % (lifetime
*1000))
314 elif (from_alpha
, to_alpha
) == (0, 255):
315 styles
.append('\\fad(0, %.0f)' % (lifetime
*1000))
317 styles
.append('\\fade(%(from_alpha)d, %(to_alpha)d, %(to_alpha)d, 0, %(end_time).0f, %(end_time).0f, %(end_time).0f)' % {'from_alpha': from_alpha
, 'to_alpha': to_alpha
, 'end_time': lifetime
*1000})
318 if isborder
== 'false':
319 styles
.append('\\bord0')
320 f
.write('Dialogue: -1,%(start)s,%(end)s,%(styleid)s,,0,0,0,,{%(styles)s}%(text)s\n' % {'start': ConvertTimestamp(c
[0]), 'end': ConvertTimestamp(c
[0]+lifetime
), 'styles': ''.join(styles
), 'text': text
, 'styleid': styleid
})
321 except (IndexError, ValueError) as e
:
323 logging
.warning(_('Invalid comment: %r') % c
[3])
325 logging
.warning(_('Invalid comment: %r') % c
)
328 def WriteCommentAcfunPositioned(f
, c
, width
, height
, styleid
):
329 AcfunPlayerSize
= (560, 400)
330 ZoomFactor
= GetZoomFactor(AcfunPlayerSize
, (width
, height
))
332 def GetPosition(InputPos
, isHeight
):
333 isHeight
= int(isHeight
) # True -> 1
334 return AcfunPlayerSize
[isHeight
]*ZoomFactor
[0]*InputPos
*0.001+ZoomFactor
[isHeight
+1]
336 def GetTransformStyles(x
=None, y
=None, scale_x
=None, scale_y
=None, rotate_z
=None, rotate_y
=None, color
=None, alpha
=None):
339 if rotate_z
is not None and rotate_y
is not None:
342 rotarg
= ConvertFlashRotation(rotate_y
, rotate_z
, x
, y
, width
, height
)
343 out_x
, out_y
= rotarg
[0:2]
348 styles
.append('\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f' % (rotarg
[2:5]+(rotarg
[5]*scale_x
, rotarg
[6]*scale_y
)))
350 if scale_x
is not None:
351 styles
.append('\\fscx%.0f' % (scale_x
*100))
352 if scale_y
is not None:
353 styles
.append('\\fscy%.0f' % (scale_y
*100))
354 if color
is not None:
355 styles
.append('\\c&H%s&' % ConvertColor(color
))
356 if color
== 0x000000:
357 styles
.append('\\3c&HFFFFFF&')
358 if alpha
is not None:
359 alpha
= 255-round(alpha
*255)
360 styles
.append('\\alpha&H%02X' % alpha
)
361 return out_x
, out_y
, styles
363 def FlushCommentLine(f
, text
, styles
, start_time
, end_time
, styleid
):
364 if end_time
> start_time
:
365 f
.write('Dialogue: -1,%(start)s,%(end)s,%(styleid)s,,0,0,0,,{%(styles)s}%(text)s\n' % {'start': ConvertTimestamp(start_time
), 'end': ConvertTimestamp(end_time
), 'styles': ''.join(styles
), 'text': text
, 'styleid': styleid
})
369 text
= ASSEscape(str(comment_args
['n']).replace('\r', '\n'))
370 common_styles
= ['\org(%d, %d)' % (width
/2, height
/2)]
371 anchor
= {0: 7, 1: 8, 2: 9, 3: 4, 4: 5, 5: 6, 6: 1, 7: 2, 8: 3}.get(comment_args
.get('c', 0), 7)
373 common_styles
.append('\\an%s' % anchor
)
374 font
= comment_args
.get('w')
377 fontface
= font
.get('f')
379 common_styles
.append('\\fn%s' % ASSEscape(str(fontface
)))
380 fontbold
= bool(font
.get('b'))
382 common_styles
.append('\\b1')
383 common_styles
.append('\\fs%.0f' % (c
[6]*ZoomFactor
[0]))
384 isborder
= bool(comment_args
.get('b', True))
386 common_styles
.append('\\bord0')
387 to_pos
= dict(comment_args
.get('p', {'x': 0, 'y': 0}))
388 to_x
= round(GetPosition(int(to_pos
.get('x', 0)), False))
389 to_y
= round(GetPosition(int(to_pos
.get('y', 0)), True))
390 to_scale_x
= float(comment_args
.get('e', 1.0))
391 to_scale_y
= float(comment_args
.get('f', 1.0))
392 to_rotate_z
= float(comment_args
.get('r', 0.0))
393 to_rotate_y
= float(comment_args
.get('k', 0.0))
395 to_alpha
= float(comment_args
.get('a', 1.0))
396 from_time
= float(comment_args
.get('t', 0.0))
397 action_time
= float(comment_args
.get('l', 3.0))
398 actions
= list(comment_args
.get('z', []))
399 to_out_x
, to_out_y
, transform_styles
= GetTransformStyles(to_x
, to_y
, to_scale_x
, to_scale_y
, to_rotate_z
, to_rotate_y
, to_color
, to_alpha
)
400 FlushCommentLine(f
, text
, common_styles
+['\\pos(%.0f, %.0f)' % (to_out_x
, to_out_y
)]+transform_styles
, c
[0]+from_time
, c
[0]+from_time
+action_time
, styleid
)
401 action_styles
= transform_styles
402 for action
in actions
:
403 action
= dict(action
)
404 from_x
, from_y
= to_x
, to_y
405 from_out_x
, from_out_y
= to_out_x
, to_out_y
406 from_scale_x
, from_scale_y
= to_scale_x
, to_scale_y
407 from_rotate_z
, from_rotate_y
= to_rotate_z
, to_rotate_y
408 from_color
, from_alpha
= to_color
, to_alpha
409 transform_styles
, action_styles
= action_styles
, []
410 from_time
+= action_time
411 action_time
= float(action
.get('l', 0.0))
413 to_x
= round(GetPosition(int(action
['x']), False))
415 to_y
= round(GetPosition(int(action
['y']), True))
417 to_scale_x
= float(action
['f'])
419 to_scale_y
= float(action
['g'])
421 to_color
= int(action
['c'])
423 to_alpha
= float(action
['t'])
425 to_rotate_z
= float(action
['d'])
427 to_rotate_y
= float(action
['e'])
428 to_out_x
, to_out_y
, action_styles
= GetTransformStyles(to_x
, to_y
, from_scale_x
, from_scale_y
, to_rotate_z
, to_rotate_y
, from_color
, from_alpha
)
429 if (from_out_x
, from_out_y
) == (to_out_x
, to_out_y
):
430 pos_style
= '\\pos(%.0f, %.0f)' % (to_out_x
, to_out_y
)
432 pos_style
= '\\move(%.0f, %.0f, %.0f, %.0f)' % (from_out_x
, from_out_y
, to_out_x
, to_out_y
)
433 styles
= common_styles
+transform_styles
434 styles
.append(pos_style
)
436 styles
.append('\\t(%s)' % (''.join(action_styles
)))
437 FlushCommentLine(f
, text
, styles
, c
[0]+from_time
, c
[0]+from_time
+action_time
, styleid
)
438 except (IndexError, ValueError) as e
:
439 logging
.warning(_('Invalid comment: %r') % c
[3])
442 def WriteCommentSH5VPositioned(f
, c
, width
, height
, styleid
):
444 def GetTransformStyles(x
=None, y
=None, fsize
=None, rotate_z
=None, rotate_y
=None, color
=None, alpha
=None):
446 if x
is not None and y
is not None:
447 styles
.append('\\pos(%.0f, %.0f)' % (x
, y
))
448 if fsize
is not None:
449 styles
.append('\\fs%.0f' % fsize
)
450 if rotate_y
is not None and rotate_z
is not None:
451 styles
.append('\\frz%.0f' % rotate_z
)
452 styles
.append('\\fry%.0f' % rotate_y
)
453 if color
is not None:
454 styles
.append('\\c&H%s&' % ConvertColor(color
))
455 if color
== 0x000000:
456 styles
.append('\\3c&HFFFFFF&')
457 if alpha
is not None:
458 alpha
= 255-round(alpha
*255)
459 styles
.append('\\alpha&H%02X' % alpha
)
462 def FlushCommentLine(f
, text
, styles
, start_time
, end_time
, styleid
):
463 if end_time
> start_time
:
464 f
.write('Dialogue: -1,%(start)s,%(end)s,%(styleid)s,,0,0,0,,{%(styles)s}%(text)s\n' % {'start': ConvertTimestamp(start_time
), 'end': ConvertTimestamp(end_time
), 'styles': ''.join(styles
), 'text': text
, 'styleid': styleid
})
467 text
= ASSEscape(str(c
[3]))
468 to_x
= float(c
[9])*width
469 to_y
= float(c
[10])*height
470 to_rotate_z
= -int(c
[14])
471 to_rotate_y
= -int(c
[15])
473 to_alpha
= float(c
[12])
474 # Note: Alpha transition hasn't been worked out yet.
475 to_size
= int(c
[6])*math
.sqrt(width
*height
/307200)
476 # Note: Because sH5V's data is the absolute size of font,temporarily solve by it at present.[*math.sqrt(width/640*height/480)]
477 # But it seems to be working fine...
478 from_time
= float(c
[0])
479 action_time
= float(c
[11])/1000
480 transform_styles
= GetTransformStyles(to_x
, to_y
, to_size
, to_rotate_z
, to_rotate_y
, to_color
, to_alpha
)
481 FlushCommentLine(f
, text
, transform_styles
, from_time
, from_time
+action_time
, styleid
)
482 except (IndexError, ValueError) as e
:
483 logging
.warning(_('Invalid comment: %r') % c
[3])
486 # Result: (f, dx, dy)
487 # To convert: NewX = f*x+dx, NewY = f*y+dy
488 def GetZoomFactor(SourceSize
, TargetSize
):
490 if (SourceSize
, TargetSize
) == GetZoomFactor
.Cached_Size
:
491 return GetZoomFactor
.Cached_Result
492 except AttributeError:
494 GetZoomFactor
.Cached_Size
= (SourceSize
, TargetSize
)
496 SourceAspect
= SourceSize
[0]/SourceSize
[1]
497 TargetAspect
= TargetSize
[0]/TargetSize
[1]
498 if TargetAspect
< SourceAspect
: # narrower
499 ScaleFactor
= TargetSize
[0]/SourceSize
[0]
500 GetZoomFactor
.Cached_Result
= (ScaleFactor
, 0, (TargetSize
[1]-TargetSize
[0]/SourceAspect
)/2)
501 elif TargetAspect
> SourceAspect
: # wider
502 ScaleFactor
= TargetSize
[1]/SourceSize
[1]
503 GetZoomFactor
.Cached_Result
= (ScaleFactor
, (TargetSize
[0]-TargetSize
[1]*SourceAspect
)/2, 0)
505 GetZoomFactor
.Cached_Result
= (TargetSize
[0]/SourceSize
[0], 0, 0)
506 return GetZoomFactor
.Cached_Result
507 except ZeroDivisionError:
508 GetZoomFactor
.Cached_Result
= (1, 0, 0)
509 return GetZoomFactor
.Cached_Result
512 # Calculation is based on https://github.com/jabbany/CommentCoreLibrary/issues/5#issuecomment-40087282
513 # and https://github.com/m13253/danmaku2ass/issues/7#issuecomment-41489422
514 # ASS FOV = width*4/3.0
515 # But Flash FOV = width/math.tan(100*math.pi/360.0)/2 will be used instead
516 # Result: (transX, transY, rotX, rotY, rotZ, scaleX, scaleY)
517 def ConvertFlashRotation(rotY
, rotZ
, X
, Y
, width
, height
):
519 return 180-((180-deg
) % 360)
520 rotY
= WrapAngle(rotY
)
521 rotZ
= WrapAngle(rotZ
)
522 if rotY
in (90, -90):
524 if rotY
== 0 or rotZ
== 0:
526 outY
= -rotY
# Positive value means clockwise in Flash
528 rotY
*= math
.pi
/180.0
529 rotZ
*= math
.pi
/180.0
531 rotY
*= math
.pi
/180.0
532 rotZ
*= math
.pi
/180.0
533 outY
= math
.atan2(-math
.sin(rotY
)*math
.cos(rotZ
), math
.cos(rotY
))*180/math
.pi
534 outZ
= math
.atan2(-math
.cos(rotY
)*math
.sin(rotZ
), math
.cos(rotZ
))*180/math
.pi
535 outX
= math
.asin(math
.sin(rotY
)*math
.sin(rotZ
))*180/math
.pi
536 trX
= (X
*math
.cos(rotZ
)+Y
*math
.sin(rotZ
))/math
.cos(rotY
)+(1-math
.cos(rotZ
)/math
.cos(rotY
))*width
/2-math
.sin(rotZ
)/math
.cos(rotY
)*height
/2
537 trY
= Y
*math
.cos(rotZ
)-X
*math
.sin(rotZ
)+math
.sin(rotZ
)*width
/2+(1-math
.cos(rotZ
))*height
/2
538 trZ
= (trX
-width
/2)*math
.sin(rotY
)
539 FOV
= width
*math
.tan(2*math
.pi
/9.0)/2
541 scaleXY
= FOV
/(FOV
+trZ
)
542 except ZeroDivisionError:
543 logging
.error('Rotation makes object behind the camera: trZ == %.0f' % trZ
)
545 trX
= (trX
-width
/2)*scaleXY
+width
/2
546 trY
= (trY
-height
/2)*scaleXY
+height
/2
551 logging
.error('Rotation makes object behind the camera: trZ == %.0f < %.0f' % (trZ
, FOV
))
552 return (trX
, trY
, WrapAngle(outX
), WrapAngle(outY
), WrapAngle(outZ
), scaleXY
*100, scaleXY
*100)
555 def ProcessComments(comments
, f
, width
, height
, bottomReserved
, fontface
, fontsize
, alpha
, duration_marquee
, duration_still
, reduced
, progress_callback
):
556 styleid
= 'Danmaku2ASS_%04x' % random
.randint(0, 0xffff)
557 WriteASSHead(f
, width
, height
, fontface
, fontsize
, alpha
, styleid
)
558 rows
= [[None]*(height
-bottomReserved
+1) for i
in range(4)]
559 for idx
, i
in enumerate(comments
):
560 if progress_callback
and idx
% 1000 == 0:
561 progress_callback(idx
, len(comments
))
562 if isinstance(i
[4], int):
564 rowmax
= height
-bottomReserved
-i
[7]
566 freerows
= TestFreeRows(rows
, i
, row
, width
, height
, bottomReserved
, duration_marquee
, duration_still
)
568 MarkCommentRow(rows
, i
, row
)
569 WriteComment(f
, i
, row
, width
, height
, bottomReserved
, fontsize
, duration_marquee
, duration_still
, styleid
)
575 row
= FindAlternativeRow(rows
, i
, height
, bottomReserved
)
576 MarkCommentRow(rows
, i
, row
)
577 WriteComment(f
, i
, row
, width
, height
, bottomReserved
, fontsize
, duration_marquee
, duration_still
, styleid
)
578 elif i
[4] == 'bilipos':
579 WriteCommentBilibiliPositioned(f
, i
, width
, height
, styleid
)
580 elif i
[4] == 'acfunpos':
581 WriteCommentAcfunPositioned(f
, i
, width
, height
, styleid
)
582 elif i
[4] == 'sH5Vpos':
583 WriteCommentSH5VPositioned(f
, i
, width
, height
, styleid
)
585 logging
.warning(_('Invalid comment: %r') % i
[3])
586 if progress_callback
:
587 progress_callback(len(comments
), len(comments
))
590 def TestFreeRows(rows
, c
, row
, width
, height
, bottomReserved
, duration_marquee
, duration_still
):
592 rowmax
= height
-bottomReserved
595 while row
< rowmax
and res
< c
[7]:
596 if targetRow
!= rows
[c
[4]][row
]:
597 targetRow
= rows
[c
[4]][row
]
598 if targetRow
and targetRow
[0]+duration_still
> c
[0]:
604 thresholdTime
= c
[0]-duration_marquee
*(1-width
/(c
[8]+width
))
605 except ZeroDivisionError:
606 thresholdTime
= c
[0]-duration_marquee
607 while row
< rowmax
and res
< c
[7]:
608 if targetRow
!= rows
[c
[4]][row
]:
609 targetRow
= rows
[c
[4]][row
]
611 if targetRow
and (targetRow
[0] > thresholdTime
or targetRow
[0]+targetRow
[8]*duration_marquee
/(targetRow
[8]+width
) > c
[0]):
613 except ZeroDivisionError:
620 def FindAlternativeRow(rows
, c
, height
, bottomReserved
):
622 for row
in range(height
-bottomReserved
-math
.ceil(c
[7])):
623 if not rows
[c
[4]][row
]:
625 elif rows
[c
[4]][row
][0] < rows
[c
[4]][res
][0]:
630 def MarkCommentRow(rows
, c
, row
):
632 for i
in range(row
, row
+math
.ceil(c
[7])):
638 def WriteASSHead(f
, width
, height
, fontface
, fontsize
, alpha
, styleid
):
642 ; Script generated by Danmaku2ASS
643 ; https://github.com/m13253/danmaku2ass
644 Script Updated By: Danmaku2ASS (https://github.com/m13253/danmaku2ass)
648 Aspect Ratio: %(width)d:%(height)d
651 ScaledBorderAndShadow: yes
655 Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
656 Style: %(styleid)s, %(fontface)s, %(fontsize).0f, &H%(alpha)02XFFFFFF, &H%(alpha)02XFFFFFF, &H%(alpha)02X000000, &H%(alpha)02X000000, 0, 0, 0, 0, 100, 100, 0.00, 0.00, 1, %(outline).0f, 0, 7, 0, 0, 0, 0
659 Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
660 ''' % {'width': width
, 'height': height
, 'fontface': fontface
, 'fontsize': fontsize
, 'alpha': 255-round(alpha
*255), 'outline': max(fontsize
/25.0, 1), 'styleid': styleid
}
664 def WriteComment(f
, c
, row
, width
, height
, bottomReserved
, fontsize
, duration_marquee
, duration_still
, styleid
):
665 text
= ASSEscape(c
[3])
668 styles
.append('\\an8\\pos(%(halfwidth)d, %(row)d)' % {'halfwidth': width
/2, 'row': row
})
669 duration
= duration_still
671 styles
.append('\\an2\\pos(%(halfwidth)d, %(row)d)' % {'halfwidth': width
/2, 'row': ConvertType2(row
, height
, bottomReserved
)})
672 duration
= duration_still
674 styles
.append('\\move(%(neglen)d, %(row)d, %(width)d, %(row)d)' % {'width': width
, 'row': row
, 'neglen': -math
.ceil(c
[8])})
675 duration
= duration_marquee
677 styles
.append('\\move(%(width)d, %(row)d, %(neglen)d, %(row)d)' % {'width': width
, 'row': row
, 'neglen': -math
.ceil(c
[8])})
678 duration
= duration_marquee
679 if not (-1 < c
[6]-fontsize
< 1):
680 styles
.append('\\fs%.0f' % c
[6])
682 styles
.append('\\c&H%s&' % ConvertColor(c
[5]))
684 styles
.append('\\3c&HFFFFFF&')
685 f
.write('Dialogue: 2,%(start)s,%(end)s,%(styleid)s,,0000,0000,0000,,{%(styles)s}%(text)s\n' % {'start': ConvertTimestamp(c
[0]), 'end': ConvertTimestamp(c
[0]+duration
), 'styles': ''.join(styles
), 'text': text
, 'styleid': styleid
})
689 def ReplaceLeadingSpace(s
):
690 sstrip
= s
.strip(' ')
692 if slen
== len(sstrip
):
695 llen
= slen
-len(s
.lstrip(' '))
696 rlen
= slen
-len(s
.rstrip(' '))
697 return ''.join(('\u2007'*llen
, sstrip
, '\u2007'*rlen
))
698 return '\\N'.join((ReplaceLeadingSpace(i
) or ' ' for i
in str(s
).replace('\\', '\\\\').replace('{', '\\{').replace('}', '\\}').split('\n')))
701 def CalculateLength(s
):
702 return max(map(len, s
.split('\n'))) # May not be accurate
705 def ConvertTimestamp(timestamp
):
706 timestamp
= round(timestamp
*100.0)
707 hour
, minute
= divmod(timestamp
, 360000)
708 minute
, second
= divmod(minute
, 6000)
709 second
, centsecond
= divmod(second
, 100)
710 return '%d:%02d:%02d.%02d' % (int(hour
), int(minute
), int(second
), int(centsecond
))
713 def ConvertColor(RGB
, width
=1280, height
=576):
716 elif RGB
== 0xffffff:
718 R
= (RGB
>> 16) & 0xff
719 G
= (RGB
>> 8) & 0xff
721 if width
< 1280 and height
< 576:
722 return '%02X%02X%02X' % (B
, G
, R
)
723 else: # VobSub always uses BT.601 colorspace, convert to BT.709
724 ClipByte
= lambda x
: 255 if x
> 255 else 0 if x
< 0 else round(x
)
725 return '%02X%02X%02X' % (
726 ClipByte(R
*0.00956384088080656+G
*0.03217254540203729+B
*0.95826361371715607),
727 ClipByte(R
*-0.10493933142075390+G
*1.17231478191855154+B
*-0.06737545049779757),
728 ClipByte(R
*0.91348912373987645+G
*0.07858536372532510+B
*0.00792551253479842)
732 def ConvertType2(row
, height
, bottomReserved
):
733 return height
-bottomReserved
-row
736 def ConvertToFile(filename_or_file
, *args
, **kwargs
):
737 if isinstance(filename_or_file
, bytes
):
738 filename_or_file
= str(bytes(filename_or_file
).decode('utf-8', 'replace'))
739 if isinstance(filename_or_file
, str):
740 return open(filename_or_file
, *args
, **kwargs
)
742 return filename_or_file
745 def FilterBadChars(f
):
747 s
= re
.sub('[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f]', '\ufffd', s
)
748 return io
.StringIO(s
)
751 class safe_list(list):
752 def get(self
, index
, default
=None):
762 __all__
.append(func
.__name
__)
764 __all__
= [func
.__name
__]
769 def Danmaku2ASS(input_files
, output_file
, stage_width
, stage_height
, reserve_blank
=0, font_face
=_('(FONT) sans-serif')[7:], font_size
=25.0, text_opacity
=1.0, duration_marquee
=5.0, duration_still
=5.0, is_reduce_comments
=False, progress_callback
=None):
771 comments
= ReadComments(input_files
, font_size
)
774 fo
= ConvertToFile(output_file
, 'w', encoding
='utf-8-sig', errors
='replace', newline
='\r\n')
777 ProcessComments(comments
, fo
, stage_width
, stage_height
, reserve_blank
, font_face
, font_size
, text_opacity
, duration_marquee
, duration_still
, is_reduce_comments
, progress_callback
)
779 if output_file
and fo
!= output_file
:
784 def ReadComments(input_files
, font_size
=25.0, progress_callback
=None):
785 if isinstance(input_files
, bytes
):
786 input_files
= str(bytes(input_files
).decode('utf-8', 'replace'))
787 if isinstance(input_files
, str):
788 input_files
= [input_files
]
790 input_files
= list(input_files
)
792 for idx
, i
in enumerate(input_files
):
793 if progress_callback
:
794 progress_callback(idx
, len(input_files
))
795 with
ConvertToFile(i
, 'r', encoding
='utf-8', errors
='replace') as f
:
796 CommentProcessor
= GetCommentProcessor(f
)
797 if not CommentProcessor
:
798 raise ValueError(_('Unknown comment file format: %s') % i
)
799 comments
.extend(CommentProcessor(FilterBadChars(f
), font_size
))
800 if progress_callback
:
801 progress_callback(len(input_files
), len(input_files
))
807 def GetCommentProcessor(input_file
):
808 return CommentFormatMap
[ProbeCommentFormat(input_file
)]
812 logging
.basicConfig(format
='%(levelname)s: %(message)s')
813 if len(sys
.argv
) == 1:
814 sys
.argv
.append('--help')
815 parser
= argparse
.ArgumentParser()
816 parser
.add_argument('-o', '--output', metavar
=_('OUTPUT'), help=_('Output file'))
817 parser
.add_argument('-s', '--size', metavar
=_('WIDTHxHEIGHT'), required
=True, help=_('Stage size in pixels'))
818 parser
.add_argument('-fn', '--font', metavar
=_('FONT'), help=_('Specify font face [default: %s]') % _('(FONT) sans-serif')[7:], default
=_('(FONT) sans-serif')[7:])
819 parser
.add_argument('-fs', '--fontsize', metavar
=_('SIZE'), help=(_('Default font size [default: %s]') % 25), type=float, default
=25.0)
820 parser
.add_argument('-a', '--alpha', metavar
=_('ALPHA'), help=_('Text opacity'), type=float, default
=1.0)
821 parser
.add_argument('-dm', '--duration-marquee', metavar
=_('SECONDS'), help=_('Duration of scrolling comment display [default: %s]') % 5, type=float, default
=5.0)
822 parser
.add_argument('-ds', '--duration-still', metavar
=_('SECONDS'), help=_('Duration of still comment display [default: %s]') % 5, type=float, default
=5.0)
823 parser
.add_argument('-p', '--protect', metavar
=_('HEIGHT'), help=_('Reserve blank on the bottom of the stage'), type=int, default
=0)
824 parser
.add_argument('-r', '--reduce', action
='store_true', help=_('Reduce the amount of comments if stage is full'))
825 parser
.add_argument('file', metavar
=_('FILE'), nargs
='+', help=_('Comment file to be processed'))
826 args
= parser
.parse_args()
828 width
, height
= str(args
.size
).split('x', 1)
832 raise ValueError(_('Invalid stage size: %r') % args
.size
)
833 Danmaku2ASS(args
.file, args
.output
, width
, height
, args
.protect
, args
.font
, args
.fontsize
, args
.alpha
, args
.duration_marquee
, args
.duration_still
, args
.reduce)
836 if __name__
== '__main__':