Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 7 additions & 12 deletions pycaption/subtitler_image_based.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down
174 changes: 174 additions & 0 deletions tests/test_concat_muxer.py
Original file line number Diff line number Diff line change
@@ -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'
40 changes: 39 additions & 1 deletion tests/test_subtitler_image_based.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
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}")