11import os
22import tempfile
33import zipfile
4- from collections import OrderedDict
54from datetime import timedelta
65from io import BytesIO
76
8- from PIL import Image , ImageFont , ImageDraw
9- from fontTools .ttLib import TTFont
10- from langcodes import Language , tag_distance
11-
12- from pycaption .base import BaseWriter , CaptionSet , Caption , CaptionNode
13- from pycaption .geometry import UnitEnum , Size
7+ from pycaption .base import CaptionSet
8+ from pycaption .subtitler_image_based import SubtitleImageBasedWriter
149
1510
1611def get_sst_pixel_display_params (video_width , video_height ):
@@ -74,35 +69,13 @@ def _zippy(base_path, path, archive):
7469 archive .write (p , os .path .relpath (p , base_path ))
7570
7671
77- class ScenaristDVDWriter (BaseWriter ):
78- VALID_POSITION = ['top' , 'bottom' , 'source' ]
79-
80- paColor = (255 , 255 , 255 ) # letter body
81- e1Color = (190 , 190 , 190 ) # antialiasing color
82- e2Color = (0 , 0 , 0 ) # border color
83- bgColor = (0 , 255 , 0 ) # background color
84-
85- palette = [paColor , e1Color , e2Color , bgColor ]
72+ class ScenaristDVDWriter (SubtitleImageBasedWriter ):
8673
87- palette_image = Image .new ("P" , (1 , 1 ))
88- palette_image .putpalette ([* paColor , * e1Color , * e2Color , * bgColor ] + [0 , 0 , 0 ] * 252 )
89-
90- font_langs = {
91- Language .get ('en' ): {'fontfile' : f"{ os .path .dirname (__file__ )} /NotoSansDisplay-Regular-Note-Math.ttf" },
92- Language .get ('ru' ): {'fontfile' : f"{ os .path .dirname (__file__ )} /NotoSansDisplay-Regular-Note-Math.ttf" },
93- Language .get ('ar' ): {'fontfile' : f"{ os .path .dirname (__file__ )} /NotoSansDisplay-RegularAndArabic.ttf" , 'align' : 'right' },
94- Language .get ('he' ): {'fontfile' : f"{ os .path .dirname (__file__ )} /NotoSansHebrew-Regular.ttf" , 'align' : 'right' },
95- Language .get ('hi' ): {'fontfile' : f"{ os .path .dirname (__file__ )} /NotoSansDevanagari-Regular.ttf" },
96- Language .get ('ja-JP' ): {'fontfile' : f"{ os .path .dirname (__file__ )} /NotoSansJP+Math-Regular.ttf" },
97- Language .get ('zh-TW' ): {'fontfile' : f"{ os .path .dirname (__file__ )} /NotoSansTC+Math-Regular.ttf" },
98- Language .get ('zh-CN' ): {'fontfile' : f"{ os .path .dirname (__file__ )} /NotoSansSC+Math-Regular.ttf" },
99- Language .get ('ko-KR' ): {'fontfile' : f"{ os .path .dirname (__file__ )} /NotoSansKR+Math-Regular.ttf" },
100- Language .get ('th' ): {'fontfile' : f"{ os .path .dirname (__file__ )} /NotoSansThai-Regular.ttf" },
101- }
74+ tiff_compression = None
10275
10376 def __init__ (self , relativize = True , video_width = 720 , video_height = 480 , fit_to_screen = True , tape_type = 'NON_DROP' ,
10477 frame_rate = 25 , compat = False ):
105- super ().__init__ (relativize , video_width , video_height , fit_to_screen )
78+ super ().__init__ (relativize , video_width , video_height , fit_to_screen , frame_rate )
10679 self .tape_type = tape_type
10780 self .frame_rate = frame_rate
10881
@@ -113,165 +86,25 @@ def __init__(self, relativize=True, video_width=720, video_height=480, fit_to_sc
11386 self .color = '(0 1 2 3)'
11487 self .contrast = '(7 7 7 7)'
11588
116- def get_characters (self , captions ):
117- all_characters = []
118- for caption_list in captions :
119- for caption in caption_list :
120- all_characters .extend ([char for char in caption .get_text () if char and char .strip ()])
121- unique_characters = list (set (all_characters ))
122- return unique_characters
123-
124- def get_characters_with_captions (self , captions ): # -> dict[str, list[int]]:
125- chars_with_captions = {}
126- for caption_list in captions :
127- for caption in caption_list :
128- current_caption_chars = [char for char in caption .get_text () if char and char .strip ()]
129- for char in current_caption_chars :
130- if char not in chars_with_captions :
131- chars_with_captions [char ] = []
132- chars_with_captions [char ].append (caption )
133- return chars_with_captions
134-
135- def get_missing_glyphs (self , font , characters ):
136- ttf_font = TTFont (font )
137- glyphs = {c : self ._has_glyph (ttf_font , c ) for c in characters }
138-
139- missing_glyphs = {k : v for k , v in glyphs .items () if not v }
140-
141- return missing_glyphs
142-
143- @staticmethod
144- def _has_glyph (fnt , glyph ):
145- NOT_ACTUAL_GLYPHS = [
146- '\u202A ' , # Left-to-Right Embedding (LRE)
147- '\u202B ' , # Right-to-Left Embedding (RLE)
148- '\u202C ' , # Pop Directional Formatting (PDF)
149- '\u202D ' , # Left-to-Right Override (LRO)
150- '\u202E ' , # Right-to-Left Override (RLO)
151- '\u200E ' , # Left-to-Right Mark (LRM)
152- '\u200F ' # Right-to-Left Mark (RLM)
153- ]
154-
155- if glyph in NOT_ACTUAL_GLYPHS :
156- return True
157-
158- for table in fnt ['cmap' ].tables :
159- if ord (glyph ) in table .cmap .keys ():
160- return True
161-
162- return False
163-
164- def get_missing_glyphs_with_timestamps (
165- self , font , characters_with_timestamps # : dict[str, list[int]]
166- ): # -> dict[str, list[int]]:
167- ttf_font = TTFont (font )
168-
169- missing_glyphs_with_timestamps = {}
170- for glyph , timestamps in characters_with_timestamps .items ():
171- is_glyph_in_font = self ._has_glyph (ttf_font , glyph )
172- if not is_glyph_in_font :
173- missing_glyphs_with_timestamps [glyph ] = timestamps
174-
175- return missing_glyphs_with_timestamps
176-
177- @staticmethod
178- def group_captions_by_start_time (caps ):
179- # group captions that have the same start time
180- caps_start_time = OrderedDict ()
181- for i , cap in enumerate (caps ):
182- if cap .start not in caps_start_time :
183- caps_start_time [cap .start ] = [cap ]
184- else :
185- caps_start_time [cap .start ].append (cap )
186-
187- # order by start timestamp
188- caps_start_time = OrderedDict (sorted (caps_start_time .items (), key = lambda item : item [0 ]))
189- return caps_start_time
190-
191- def check_overlapping_subs (self , captions_by_start_time ):
192- caps_final = []
193- overlapping = []
194- for start_time , caps_list in captions_by_start_time .items ():
195- if len (caps_list ) == 1 :
196- caps_final .append (caps_list )
197- else :
198- end_times = list (set ([c .end for c in caps_list ]))
199- if len (end_times ) != 1 :
200- overlapping .append (caps_list )
201- else :
202- caps_final .append (caps_list )
203- return caps_final , overlapping
204-
205- def get_distances (self , lang , font_langs ):
206- requested_lang = Language .get (lang )
207- distances = [
208- (tag_distance (requested_lang , l ), fnt )
209- for l , fnt in font_langs .items ()
210- if tag_distance (requested_lang , l ) < 100
211- ]
212- if not distances :
213- return distances
214-
215- distances .sort (key = lambda l : l [0 ])
216- return distances
89+ def save_image (self , tmp_dir , index , img ):
90+ img .save (tmp_dir + '/subtitle%04d.tif' % index , compression = self .tiff_compression )
21791
21892 def write (
21993 self ,
22094 caption_set : CaptionSet ,
22195 position = 'bottom' ,
22296 avoid_same_next_start_prev_end = False ,
22397 tiff_compression = 'tiff_deflate' ,
224- align = 'center' ,
98+ align = 'center'
22599 ):
226- if tiff_compression not in ['tiff_deflate' , 'raw' ]:
227- raise ValueError ('Unknown tiff_compression. Supported: {}' .format ('tiff_deflate, raw' ))
228-
229- position = position .lower ().strip ()
230- if position not in ScenaristDVDWriter .VALID_POSITION :
231- raise ValueError ('Unknown position. Supported: {}' .format (',' .join (ScenaristDVDWriter .VALID_POSITION )))
232-
100+ self .tiff_compression = tiff_compression
233101 lang = caption_set .get_languages ().pop ()
234102 caps = caption_set .get_captions (lang )
235103
236- # group captions that have the same start time
237- caps_start_time = self .group_captions_by_start_time (caps )
238-
239- # check if captions with the same start time also have the same end time
240- # fail if different end times are found - this is not (yet?) supported
241- caps_final , overlapping = self .check_overlapping_subs (caps_start_time )
242- if overlapping :
243- raise ValueError ('Unsupported subtitles - overlapping subtitles with different end times found' )
244-
245- if avoid_same_next_start_prev_end :
246- min_diff = (1 / self .frame_rate ) * 1000000
247- for i , caps_list in enumerate (caps_final ):
248- if i == 0 :
249- continue
250-
251- prev_end_time = caps_final [i - 1 ][0 ].end
252- current_start_time = caps_list [0 ].start
253-
254- if (current_start_time == prev_end_time ) or ((current_start_time - prev_end_time ) < min_diff ):
255- for c in caps_list :
256- c .start = min (c .start + min_diff , c .end )
257-
258- distances = self .get_distances (lang , self .font_langs )
259- if not distances :
260- raise ValueError ('Cannot find appropriate font for selected language' )
261-
262- fnt = distances [0 ][1 ]['fontfile' ]
263- align = distances [0 ][1 ].get ('align' ) or align
264- missing_glyphs = self .get_missing_glyphs (fnt , self .get_characters (caps_final ))
265-
266- if missing_glyphs :
267- raise ValueError (f'Selected font was missing glyphs: { " " .join (missing_glyphs .keys ())} ' )
268-
269- font_size = int (self .video_width * 0.05 * 0.6 ) # rough estimate but should work
270-
271- fnt = ImageFont .truetype (fnt , font_size )
272104
273105 buf = BytesIO ()
274106 with tempfile .TemporaryDirectory () as tmpDir :
107+ caps_final , overlapping = self .write_images (caps , lang , tmpDir , position , align , avoid_same_next_start_prev_end )
275108 with open (tmpDir + '/subtitles.sst' , 'w+' ) as sst :
276109 index = 1
277110 py0 , py1 , dy0 , dy1 , dx0 , dx1 = get_sst_pixel_display_params (self .video_width , self .video_height )
@@ -292,15 +125,6 @@ def write(
292125 self .format_ts (cap_list [0 ].end ),
293126 index
294127 ))
295-
296- img = Image .new ('RGB' , (self .video_width , self .video_height ), self .bgColor )
297- draw = ImageDraw .Draw (img )
298- self .printLine (draw , cap_list , fnt , position , align )
299-
300- # quantize the image to our palette
301- img_quant = img .quantize (palette = self .palette_image , dither = 0 )
302- img_quant .save (tmpDir + '/subtitle%04d.tif' % index , compression = tiff_compression )
303-
304128 index = index + 1
305129 zipit (tmpDir , buf )
306130 buf .seek (0 )
@@ -315,73 +139,3 @@ def format_ts(self, value):
315139
316140 str_value = str_value + ':%02d' % (int ((int (value / 1000 ) % 1000 ) / int (1000 / self .frame_rate )))
317141 return str_value
318-
319- def printLine (self , draw : ImageDraw , caption_list : Caption , fnt : ImageFont , position : str = 'bottom' , align : str = 'center' ):
320- ascender , descender = fnt .getmetrics ()
321- line_spacing = ascender + abs (descender ) # Basic line height without extra padding
322- lines_written = 0
323- for caption in caption_list [::- 1 ]:
324- text = caption .get_text ()
325- l , t , r , b = draw .textbbox ((0 , 0 ), text , font = fnt , align = align )
326-
327- x = None
328- y = None
329-
330- # if position is specified as source, get the layout info
331- # fall back to "bottom" position if we can't get it
332- if position == 'source' :
333- try :
334- x_ = caption .layout_info .origin .x
335- y_ = caption .layout_info .origin .y
336-
337- if isinstance (x_ , Size ) \
338- and isinstance (y_ , Size ) \
339- and x_ .unit == UnitEnum .PERCENT \
340- and y_ .unit == UnitEnum .PERCENT :
341- x = self .video_width * (x_ .value / 100 )
342- y = self .video_height * (y_ .value / 100 )
343-
344- # make sure the text doesn't go out of the screen
345- box_rightmost_edge = x + r
346- if box_rightmost_edge > self .video_width :
347- x = float (self .video_width ) - float (r ) - float (10 )
348-
349- # padding for readability
350- if y_ .value > 70 :
351- y = y - 10
352- else :
353- position = 'bottom'
354- except :
355- position = 'bottom'
356-
357- if position != 'source' :
358- x = self .video_width / 2 - r / 2
359- if position == 'bottom' :
360- y = self .video_height - b - 10 - lines_written * line_spacing # padding for readability
361- elif position == 'top' :
362- y = 10 + lines_written * line_spacing
363- else :
364- raise ValueError ('Unknown "position": {}' .format (position ))
365-
366- borderColor = self .e2Color
367- fontColor = self .paColor
368- for adj in range (2 ):
369- # move right
370- draw .text ((x - adj , y ), text , font = fnt , fill = borderColor , align = align )
371- # move left
372- draw .text ((x + adj , y ), text , font = fnt , fill = borderColor , align = align )
373- # move up
374- draw .text ((x , y + adj ), text , font = fnt , fill = borderColor , align = align )
375- # move down
376- draw .text ((x , y - adj ), text , font = fnt , fill = borderColor , align = align )
377- # diagnal left up
378- draw .text ((x - adj , y + adj ), text , font = fnt , fill = borderColor , align = align )
379- # diagnal right up
380- draw .text ((x + adj , y + adj ), text , font = fnt , fill = borderColor , align = align )
381- # diagnal left down
382- draw .text ((x - adj , y - adj ), text , font = fnt , fill = borderColor , align = align )
383- # diagnal right down
384- draw .text ((x + adj , y - adj ), text , font = fnt , fill = borderColor , align = align )
385-
386- draw .text ((x , y ), text , font = fnt , fill = fontColor , align = align )
387- lines_written += 1
0 commit comments