Skip to content

Commit c3e625d

Browse files
authored
Merge pull request #30 from castlabs/VTK-1754-also-emit-ttml-w-background
Vtk 1754 also emit ttml w background
2 parents 67b9745 + 43331cc commit c3e625d

File tree

4 files changed

+498
-256
lines changed

4 files changed

+498
-256
lines changed

pycaption/scenarist.py

Lines changed: 10 additions & 256 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
11
import os
22
import tempfile
33
import zipfile
4-
from collections import OrderedDict
54
from datetime import timedelta
65
from 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

1611
def 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

Comments
 (0)