diff --git a/pycaption/subtitler_image_based.py b/pycaption/subtitler_image_based.py index c30b01f2..e317c5c7 100644 --- a/pycaption/subtitler_image_based.py +++ b/pycaption/subtitler_image_based.py @@ -27,7 +27,6 @@ def get_sst_pixel_display_params(video_width, video_height): return py0, py1, dy0, dy1, dx0, dx1 - class SubtitleImageBasedWriter(BaseWriter): VALID_POSITION = ['top', 'bottom', 'source'] @@ -54,12 +53,9 @@ def __init__(self, relativize=True, video_width=720, video_height=480, fit_to_sc Language.get('th'): {'fontfile': f"{os.path.dirname(__file__)}/NotoSansThai-Regular.ttf"}, } - - def save_image(self, tmp_dir, index, img): pass - def get_characters(self, captions): all_characters = [] for caption_list in captions: @@ -162,8 +158,6 @@ def get_distances(self, lang, font_langs): distances.sort(key=lambda l: l[0]) return distances - - def write_images( self, caption_list: CaptionList, @@ -175,7 +169,8 @@ def write_images( position = position.lower().strip() if position not in SubtitleImageBasedWriter.VALID_POSITION: - raise ValueError('Unknown position. Supported: {}'.format(','.join(SubtitleImageBasedWriter.VALID_POSITION))) + raise ValueError( + 'Unknown position. Supported: {}'.format(','.join(SubtitleImageBasedWriter.VALID_POSITION))) # group captions that have the same start time caps_start_time = self.group_captions_by_start_time(caption_list) @@ -217,7 +212,6 @@ def write_images( index = 1 for i, cap_list in enumerate(caps_final): - # Create RGBA image with transparent background img = Image.new('RGBA', (self.video_width, self.video_height), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) @@ -233,7 +227,7 @@ def write_images( def printLine(self, draw: ImageDraw, caption_list: Caption, fnt: ImageFont, position: str = 'bottom', align: str = 'left'): ascender, descender = fnt.getmetrics() - line_spacing = ascender + abs(descender) # Basic line height without extra padding + line_spacing = (ascender + abs(descender)) * 0.75 # Basic line height without extra padding lines_written = 0 for caption in caption_list[::-1]: text = caption.get_text() @@ -272,7 +266,8 @@ def printLine(self, draw: ImageDraw, caption_list: Caption, fnt: ImageFont, posi if position != 'source': x = self.video_width / 2 - r / 2 if position == 'bottom': - y = self.video_height - b - 10 - lines_written * line_spacing # padding for readability + # Place baseline at 5% from the bottom; descender runs below + y = self.video_height * 0.92 - ascender - lines_written * line_spacing elif position == 'top': y = 10 + lines_written * line_spacing else: @@ -284,12 +279,12 @@ def printLine(self, draw: ImageDraw, caption_list: Caption, fnt: ImageFont, posi text_top = y - border_offset + t text_right = x + border_offset + r text_bottom = y + border_offset + b - if text_left < 0 or text_top < 0 or text_right > self.video_width or text_bottom > self.video_height: + if text_left < 0 or text_top < 0 or text_right > self.video_width or ( + position != 'bottom' and text_bottom > self.video_height): raise CaptionRendererError( f'Text runs off screen: text="{text}"' ) - border = (*self.borderColor, 255) # Add alpha for RGBA font = (*self.fontColor, 255) # Add alpha for RGBA for adj in range(2): diff --git a/tests/test_concat_muxer.py b/tests/test_concat_muxer.py new file mode 100644 index 00000000..574a01b8 --- /dev/null +++ b/tests/test_concat_muxer.py @@ -0,0 +1,174 @@ +import zipfile +from io import BytesIO + +from pycaption import SRTReader +from pycaption.filtergraph import FiltergraphWriter + + +class TestFiltergraphWriterTestCase: + """Tests for the FiltergraphWriter that generates FFmpeg concat demuxer files.""" + + def setup_method(self): + self.writer = FiltergraphWriter() + + def test_zip_contents(self): + """Test that write returns a ZIP with correct contents.""" + srt_content = """1 +00:00:01,000 --> 00:00:04,000 +Hello World + +2 +00:00:05,000 --> 00:00:08,000 +Second subtitle +""" + caption_set = SRTReader().read(srt_content) + zip_data = self.writer.write(caption_set) + + with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: + names = zf.namelist() + assert 'embedded_subs/subtitle0001.png' in names + assert 'embedded_subs/subtitle0002.png' in names + assert 'embedded_subs/blank.png' in names + assert 'embedded_subs/concat.txt' in names + + def test_concat_header(self): + """Test that concat file starts with ffconcat header.""" + srt_content = """1 +00:00:01,000 --> 00:00:04,000 +Test +""" + caption_set = SRTReader().read(srt_content) + zip_data = self.writer.write(caption_set) + + with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: + concat = zf.read('embedded_subs/concat.txt').decode() + assert concat.startswith('ffconcat version 1.0') + + def test_concat_file_structure(self): + """Test that concat demuxer file has correct timing entries.""" + srt_content = """1 +00:00:01,000 --> 00:00:04,000 +First + +2 +00:00:05,000 --> 00:00:08,000 +Second +""" + caption_set = SRTReader().read(srt_content) + zip_data = self.writer.write(caption_set) + + with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: + concat = zf.read('embedded_subs/concat.txt').decode() + + # Gap before first subtitle (1 second) + assert 'file blank.png\nduration 1.000' in concat + + # First subtitle (3 seconds) + assert 'file subtitle0001.png\nduration 3.000' in concat + + # Gap between subtitles (1 second) + lines = concat.split('\n') + idx_sub1 = next(i for i, l in enumerate(lines) if 'subtitle0001' in l) + assert lines[idx_sub1 + 2] == 'file blank.png' + assert lines[idx_sub1 + 3] == 'duration 1.000' + + # Second subtitle (3 seconds) + assert 'file subtitle0002.png\nduration 3.000' in concat + + # Trailing blank at the end + assert lines[-1] == 'file blank.png' + + def test_custom_output_dir(self): + """Test custom output directory.""" + writer = FiltergraphWriter(output_dir='subs') + srt_content = """1 +00:00:01,000 --> 00:00:04,000 +Test +""" + caption_set = SRTReader().read(srt_content) + zip_data = writer.write(caption_set) + + with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: + names = zf.namelist() + assert 'subs/subtitle0001.png' in names + assert 'subs/blank.png' in names + assert 'subs/concat.txt' in names + + def test_multiple_subtitles_in_concat(self): + """Test that multiple subtitles produce correct concat entries.""" + srt_content = """1 +00:00:01,000 --> 00:00:04,000 +First + +2 +00:00:05,000 --> 00:00:08,000 +Second + +3 +00:00:10,000 --> 00:00:13,000 +Third +""" + caption_set = SRTReader().read(srt_content) + zip_data = self.writer.write(caption_set) + + with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: + concat = zf.read('embedded_subs/concat.txt').decode() + + assert 'file subtitle0001.png' in concat + assert 'file subtitle0002.png' in concat + assert 'file subtitle0003.png' in concat + + def test_custom_resolution(self): + """Test custom video resolution produces correctly sized images.""" + writer = FiltergraphWriter(video_width=1280, video_height=720) + srt_content = """1 +00:00:01,000 --> 00:00:04,000 +Test +""" + caption_set = SRTReader().read(srt_content) + zip_data = writer.write(caption_set) + + with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: + # Verify blank image has correct dimensions + from PIL import Image + blank_data = zf.read('embedded_subs/blank.png') + img = Image.open(BytesIO(blank_data)) + assert img.size == (1280, 720) + + def test_back_to_back_subtitles_no_gap(self): + """Test subtitles with no gap between them produce no blank entry.""" + srt_content = """1 +00:00:01,000 --> 00:00:04,000 +First + +2 +00:00:04,000 --> 00:00:07,000 +Immediately after +""" + caption_set = SRTReader().read(srt_content) + zip_data = self.writer.write(caption_set) + + with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: + concat = zf.read('embedded_subs/concat.txt').decode() + lines = concat.split('\n') + + # Find subtitle0001 line + idx_sub1 = next(i for i, l in enumerate(lines) if 'subtitle0001' in l) + # Next file entry should be subtitle0002 directly (no blank in between) + assert lines[idx_sub1 + 2] == 'file subtitle0002.png' + + def test_subtitle_starting_at_zero(self): + """Test subtitle starting at time 0 produces no leading blank.""" + srt_content = """1 +00:00:00,000 --> 00:00:03,000 +Starts immediately +""" + caption_set = SRTReader().read(srt_content) + zip_data = self.writer.write(caption_set) + + with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: + concat = zf.read('embedded_subs/concat.txt').decode() + lines = concat.split('\n') + + # First file entry should be the subtitle, not blank + assert lines[1] == 'file subtitle0001.png' \ No newline at end of file diff --git a/tests/test_subtitler_image_based.py b/tests/test_subtitler_image_based.py index fca70f7d..702cf605 100644 --- a/tests/test_subtitler_image_based.py +++ b/tests/test_subtitler_image_based.py @@ -103,4 +103,42 @@ def test_left_sticks_out(self): layout = make_source_layout(x_pct=10, y_pct=50) caption = make_caption("This text sticks out on the left", layout_info=layout) with pytest.raises(CaptionRendererError, match="Text runs off screen"): - writer.printLine(draw, [caption], fnt, position='source', align='left') \ No newline at end of file + writer.printLine(draw, [caption], fnt, position='source', align='left') + + +class TestBaselineAlignment: + """Render subtitle images with/without descenders to visually verify + that the baseline sits at a consistent 5% from the bottom.""" + + NO_DESCENDER = "AHLEN" # no descenders + WITH_DESCENDER = "gypsy" # descenders: g, y, p + + COMBOS = [ + ("no_desc_x2", [NO_DESCENDER, NO_DESCENDER]), + ("desc_x2", [WITH_DESCENDER, WITH_DESCENDER]), + ("top_no_bottom_yes", [NO_DESCENDER, WITH_DESCENDER]), + ("top_yes_bottom_no", [WITH_DESCENDER, NO_DESCENDER]), + ] + + @pytest.fixture(params=COMBOS, ids=[c[0] for c in COMBOS]) + def combo(self, request): + return request.param + + def test_baseline_visual(self, combo, tmp_path): + name, lines = combo + width, height = 720, 480 + writer, draw = make_writer_and_draw(width, height) + fnt = ImageFont.truetype(FONT_PATH, 28) + + captions = [make_caption(text) for text in lines] + writer.printLine(draw, captions, fnt, position='bottom', align='center') + + # Draw a red guide line at the 5% baseline position + baseline_y = int(height * 0.95) + img = draw._image + guide = ImageDraw.Draw(img) + guide.line([(0, baseline_y), (width, baseline_y)], fill=(255, 0, 0, 200), width=1) + + out = tmp_path / f"baseline_{name}.png" + img.save(str(out)) + print(f"\nSaved: {out}") \ No newline at end of file