From 9a34b7f45eae4869db98c4e232c34c849763424d Mon Sep 17 00:00:00 2001 From: Sebastian Annies Date: Tue, 6 Jan 2026 08:39:05 +0200 Subject: [PATCH 01/15] Add ASS --- pycaption/__init__.py | 5 +- pycaption/ass.py | 183 +++++++++++++++++++++++++++++++++++++++ tests/test_ass.py | 193 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 pycaption/ass.py create mode 100644 tests/test_ass.py diff --git a/pycaption/__init__.py b/pycaption/__init__.py index fe31a773..7b84aefb 100644 --- a/pycaption/__init__.py +++ b/pycaption/__init__.py @@ -7,6 +7,7 @@ from .microdvd import MicroDVDReader, MicroDVDWriter from .sami import SAMIReader, SAMIWriter from .scenarist import ScenaristDVDWriter +from .ass import ASSWriter from .srt import SRTReader, SRTWriter from .scc import SCCReader, SCCWriter from .scc.translator import translate_scc @@ -22,8 +23,8 @@ 'MicroDVDWriter', 'SAMIReader', 'SAMIWriter', 'SRTReader', 'SRTWriter', 'SCCReader', 'SCCWriter', 'translate_scc', 'WebVTTReader', 'WebVTTWriter', 'CaptionReadError', 'CaptionReadNoCaptions', 'CaptionReadSyntaxError', - 'detect_format', 'CaptionNode', 'Caption', 'CaptionList', 'CaptionSet', 'ScenaristDVDWriter' - 'TranscriptWriter' + 'detect_format', 'CaptionNode', 'Caption', 'CaptionList', 'CaptionSet', + 'ScenaristDVDWriter', 'ASSWriter', 'TranscriptWriter' ] SUPPORTED_READERS = ( diff --git a/pycaption/ass.py b/pycaption/ass.py new file mode 100644 index 00000000..f3d79919 --- /dev/null +++ b/pycaption/ass.py @@ -0,0 +1,183 @@ +import base64 +import tempfile + +from pycaption.base import CaptionSet +from pycaption.subtitler_image_based import SubtitleImageBasedWriter + + +# ASS/SSA format header template +HEADER = """[Script Info] +; Script generated by pycaption ASSWriter +Title: {title} +ScriptType: v4.00+ +WrapStyle: 0 +ScaledBorderAndShadow: yes +YCbCr Matrix: TV.709 +PlayResX: {play_res_x} +PlayResY: {play_res_y} + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,0,0,0,1 + +""" + +# Graphics section for embedded images (base64 encoded) +GRAPHICS_HEADER = """[Graphics] +""" + +GRAPHICS_ENTRY = """filename: {filename} +{data} + +""" + +EVENTS_HEADER = """[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +""" + +# Picture event at position 0,0 for full-screen coverage +PICTURE_EVENT = """Picture: 0,{start},{end},Default,,0,0,0,,{filename},0,0,100,100,0 +""" + + +def uuencode_ass(data: bytes) -> str: + """ + Encode binary data in base64 format for ASS embedding. + + Each line contains up to 60 characters of base64 data. + """ + result = [] + encoded = base64.b64encode(data).decode('ascii') + for i in range(0, len(encoded), 60): + result.append(encoded[i:i + 60]) + return '\n'.join(result) + + +class ASSWriter(SubtitleImageBasedWriter): + """ + Advanced SubStation Alpha (ASS) writer for image-based subtitles. + + Generates an ASS file with embedded images in the [Graphics] section. + The images are base64-encoded and referenced via Picture events. + + By default, generates Full HD (1920x1080) images. The PlayResX/PlayResY + values match the image dimensions, so ASS-compatible players will + automatically scale the images to fit the actual video dimensions. + + Uses PNG format for images (best compatibility with libass/FFmpeg/VSFilter). + PNG with 4-color indexed palette compresses very well (~6 KB per Full HD image). + + This format is compatible with players that support ASS embedded graphics + (such as VSFilter, libass, VLC, mpv, FFmpeg, and various subtitle editors). + """ + + def __init__(self, relativize=True, video_width=1920, video_height=1080, + fit_to_screen=True, frame_rate=25, title='Subtitles'): + """ + Initialize the ASS writer. + + :param relativize: Convert absolute positioning to percentages + :param video_width: Width of generated subtitle images (default: 1920 for Full HD) + :param video_height: Height of generated subtitle images (default: 1080 for Full HD) + :param fit_to_screen: Ensure captions fit within screen bounds + :param frame_rate: Frame rate for timing calculations + :param title: Title to include in ASS metadata + """ + super().__init__(relativize, video_width, video_height, fit_to_screen, frame_rate) + self.title = title + + def save_image(self, tmp_dir, index, img): + """Save subtitle image as optimized PNG with transparency.""" + # PNG with indexed palette: best compatibility with libass/FFmpeg/VSFilter + # 4-color palette compresses very well (~6 KB for Full HD) + img.save( + tmp_dir + '/subtitle%04d.png' % index, + transparency=3, + optimize=True, + compress_level=9 + ) + + def format_ts(self, value): + """ + Format timestamp in ASS format: H:MM:SS.cc (centiseconds) + + ASS uses centiseconds (1/100th of a second) not milliseconds. + + :param value: Time in microseconds + :return: Formatted timestamp string + """ + total_seconds = value / 1_000_000 + hours = int(total_seconds // 3600) + minutes = int((total_seconds % 3600) // 60) + seconds = int(total_seconds % 60) + centiseconds = int((total_seconds * 100) % 100) + + return f"{hours}:{minutes:02d}:{seconds:02d}.{centiseconds:02d}" + + def write( + self, + caption_set: CaptionSet, + position='bottom', + avoid_same_next_start_prev_end=False, + align='center' + ): + """ + Write captions to ASS format with embedded full-screen image-based subtitles. + + Images are generated at the configured resolution (default Full HD 1920x1080) + and positioned at 0,0 to cover the entire frame. ASS-compatible players will + automatically scale the images to match the actual video dimensions based on + PlayResX/PlayResY values. + + :param caption_set: CaptionSet containing the captions to write + :param position: Position of subtitles ('top', 'bottom', 'source') + :param avoid_same_next_start_prev_end: Adjust timing to avoid overlaps + :param align: Text alignment ('left', 'center', 'right') + :return: ASS file contents as string with embedded PNG images + """ + lang = caption_set.get_languages().pop() + caps = caption_set.get_captions(lang) + + with tempfile.TemporaryDirectory() as tmpDir: + caps_final, overlapping = self.write_images( + caps, lang, tmpDir, position, align, avoid_same_next_start_prev_end + ) + + # Build ASS content + # PlayResX/PlayResY define the coordinate system - images will be scaled + # to fit the actual video dimensions by ASS-compatible players + ass_content = HEADER.format( + title=self.title, + play_res_x=self.video_width, + play_res_y=self.video_height + ) + + # Add Graphics section with embedded images + ass_content += GRAPHICS_HEADER + for i in range(1, len(caps_final) + 1): + img_path = tmpDir + '/subtitle%04d.png' % i + with open(img_path, 'rb') as img_file: + img_data = img_file.read() + encoded_data = uuencode_ass(img_data) + ass_content += GRAPHICS_ENTRY.format( + filename='subtitle%04d.png' % i, + data=encoded_data + ) + + # Add Events section + # Picture events positioned at 0,0 with 100% scale to cover full frame + ass_content += EVENTS_HEADER + index = 1 + for cap_list in caps_final: + start_ts = self.format_ts(cap_list[0].start) + end_ts = self.format_ts(cap_list[0].end) + filename = 'subtitle%04d.png' % index + + ass_content += PICTURE_EVENT.format( + start=start_ts, + end=end_ts, + filename=filename + ) + index += 1 + + return ass_content diff --git a/tests/test_ass.py b/tests/test_ass.py new file mode 100644 index 00000000..8ba26ed4 --- /dev/null +++ b/tests/test_ass.py @@ -0,0 +1,193 @@ +import re + +from pycaption import DFXPReader, SRTReader +from pycaption.ass import ASSWriter + + +class TestASSWriterTestCase: + + def setup_method(self): + self.writer = ASSWriter() + + def test_basic_output_structure(self): + """Test that basic ASS structure is correct.""" + srt_content = """1 +00:00:01,000 --> 00:00:04,000 +Hello World +""" + caption_set = SRTReader().read(srt_content) + results = self.writer.write(caption_set) + + # Check for required sections + assert '[Script Info]' in results + assert '[V4+ Styles]' in results + assert '[Graphics]' in results + assert '[Events]' in results + + def test_script_info_section(self): + """Test Script Info section contains correct metadata.""" + srt_content = """1 +00:00:01,000 --> 00:00:04,000 +Test +""" + caption_set = SRTReader().read(srt_content) + results = self.writer.write(caption_set) + + assert 'ScriptType: v4.00+' in results + assert 'PlayResX: 1920' in results + assert 'PlayResY: 1080' in results + + def test_custom_resolution(self): + """Test custom video resolution.""" + writer = ASSWriter(video_width=1280, video_height=720) + srt_content = """1 +00:00:01,000 --> 00:00:04,000 +Test +""" + caption_set = SRTReader().read(srt_content) + results = writer.write(caption_set) + + assert 'PlayResX: 1280' in results + assert 'PlayResY: 720' in results + + def test_custom_title(self): + """Test custom title in Script Info.""" + writer = ASSWriter(title='My Custom Title') + srt_content = """1 +00:00:01,000 --> 00:00:04,000 +Test +""" + caption_set = SRTReader().read(srt_content) + results = writer.write(caption_set) + + assert 'Title: My Custom Title' in results + + def test_graphics_section_contains_images(self): + """Test that Graphics section contains embedded PNG images.""" + srt_content = """1 +00:00:01,000 --> 00:00:04,000 +Hello World +""" + caption_set = SRTReader().read(srt_content) + results = self.writer.write(caption_set) + + # Check for embedded image + assert 'filename: subtitle0001.png' in results + # Check for base64 PNG header (iVBORw0KGgo is base64 for PNG magic bytes) + assert 'iVBORw0KGgo' in results + + def test_events_section_has_picture_events(self): + """Test that Events section contains Picture events.""" + 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) + results = self.writer.write(caption_set) + + # Check for Picture events + assert 'Picture: 0,0:00:01.00,0:00:04.00' in results + assert 'Picture: 0,0:00:05.00,0:00:08.00' in results + assert 'subtitle0001.png' in results + assert 'subtitle0002.png' in results + + def test_timestamp_format(self): + """Test ASS timestamp format (H:MM:SS.cc).""" + srt_content = """1 +01:23:45,670 --> 02:34:56,780 +Test +""" + caption_set = SRTReader().read(srt_content) + results = self.writer.write(caption_set) + + # ASS uses centiseconds, so 670ms -> 67cs, 780ms -> 78cs + assert '1:23:45.67' in results + assert '2:34:56.78' in results + + def test_arabic(self, sample_srt_arabic): + """Test Arabic subtitle rendering.""" + caption_set = SRTReader().read(sample_srt_arabic, lang='ar') + results = self.writer.write(caption_set) + + # Should have 4 images for 4 captions + assert results.count('filename: subtitle') == 4 + assert '[Graphics]' in results + assert '[Events]' in results + + def test_styling(self, sample_dfxp_from_sami_with_positioning): + """Test subtitle with positioning.""" + caption_set = DFXPReader().read(sample_dfxp_from_sami_with_positioning) + results = self.writer.write(caption_set) + + # Should have images for each caption + assert 'filename: subtitle0001.png' in results + assert '[Events]' in results + + def test_multiple_captions_same_start_time(self): + """Test handling of multiple captions with same start time.""" + srt_content = """1 +00:00:01,000 --> 00:00:04,000 +First line + +2 +00:00:01,000 --> 00:00:04,000 +Second line (same time) +""" + caption_set = SRTReader().read(srt_content) + results = self.writer.write(caption_set) + + # Both captions with same time should be combined into one image + # So we should only have one Picture event at 0:00:01.00 + picture_count = results.count('Picture: 0,0:00:01.00') + assert picture_count == 1 + + def test_image_count_matches_caption_groups(self): + """Test that number of images matches number of unique time slots.""" + 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) + results = self.writer.write(caption_set) + + # Should have 3 images + assert results.count('filename: subtitle') == 3 + # Should have 3 Picture events + assert results.count('Picture: 0,') == 3 + + def test_full_screen_positioning(self): + """Test that Picture events have full-screen positioning.""" + srt_content = """1 +00:00:01,000 --> 00:00:04,000 +Test +""" + caption_set = SRTReader().read(srt_content) + results = self.writer.write(caption_set) + + # Picture event should have positioning: x=0, y=0, scalex=100, scaley=100 + # Format: Picture: layer,start,end,style,name,marginL,marginR,marginV,effect,filename,x,y,scalex,scaley,fsp + assert ',0,0,100,100,0' in results + + def test_styles_have_zero_margins(self): + """Test that default style has zero margins for full-screen images.""" + srt_content = """1 +00:00:01,000 --> 00:00:04,000 +Test +""" + caption_set = SRTReader().read(srt_content) + results = self.writer.write(caption_set) + + # Style line should end with 0,0,0,1 (MarginL, MarginR, MarginV, Encoding) + assert ',0,0,0,1' in results From b1f9957056c39cc184b48b1697ddb6caac69a5a5 Mon Sep 17 00:00:00 2001 From: Sebastian Annies Date: Tue, 6 Jan 2026 18:03:11 +0200 Subject: [PATCH 02/15] remove ass and add filtergraph --- pycaption/__init__.py | 4 +- pycaption/ass.py | 183 ------------------------------------ pycaption/filtergraph.py | 160 +++++++++++++++++++++++++++++++ tests/test_ass.py | 193 -------------------------------------- tests/test_filtergraph.py | 170 +++++++++++++++++++++++++++++++++ 5 files changed, 332 insertions(+), 378 deletions(-) delete mode 100644 pycaption/ass.py create mode 100644 pycaption/filtergraph.py delete mode 100644 tests/test_ass.py create mode 100644 tests/test_filtergraph.py diff --git a/pycaption/__init__.py b/pycaption/__init__.py index 7b84aefb..0539f613 100644 --- a/pycaption/__init__.py +++ b/pycaption/__init__.py @@ -7,7 +7,7 @@ from .microdvd import MicroDVDReader, MicroDVDWriter from .sami import SAMIReader, SAMIWriter from .scenarist import ScenaristDVDWriter -from .ass import ASSWriter +from .filtergraph import FiltergraphWriter from .srt import SRTReader, SRTWriter from .scc import SCCReader, SCCWriter from .scc.translator import translate_scc @@ -24,7 +24,7 @@ 'SCCReader', 'SCCWriter', 'translate_scc', 'WebVTTReader', 'WebVTTWriter', 'CaptionReadError', 'CaptionReadNoCaptions', 'CaptionReadSyntaxError', 'detect_format', 'CaptionNode', 'Caption', 'CaptionList', 'CaptionSet', - 'ScenaristDVDWriter', 'ASSWriter', 'TranscriptWriter' + 'ScenaristDVDWriter', 'FiltergraphWriter', 'TranscriptWriter' ] SUPPORTED_READERS = ( diff --git a/pycaption/ass.py b/pycaption/ass.py deleted file mode 100644 index f3d79919..00000000 --- a/pycaption/ass.py +++ /dev/null @@ -1,183 +0,0 @@ -import base64 -import tempfile - -from pycaption.base import CaptionSet -from pycaption.subtitler_image_based import SubtitleImageBasedWriter - - -# ASS/SSA format header template -HEADER = """[Script Info] -; Script generated by pycaption ASSWriter -Title: {title} -ScriptType: v4.00+ -WrapStyle: 0 -ScaledBorderAndShadow: yes -YCbCr Matrix: TV.709 -PlayResX: {play_res_x} -PlayResY: {play_res_y} - -[V4+ Styles] -Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,0,0,0,1 - -""" - -# Graphics section for embedded images (base64 encoded) -GRAPHICS_HEADER = """[Graphics] -""" - -GRAPHICS_ENTRY = """filename: {filename} -{data} - -""" - -EVENTS_HEADER = """[Events] -Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text -""" - -# Picture event at position 0,0 for full-screen coverage -PICTURE_EVENT = """Picture: 0,{start},{end},Default,,0,0,0,,{filename},0,0,100,100,0 -""" - - -def uuencode_ass(data: bytes) -> str: - """ - Encode binary data in base64 format for ASS embedding. - - Each line contains up to 60 characters of base64 data. - """ - result = [] - encoded = base64.b64encode(data).decode('ascii') - for i in range(0, len(encoded), 60): - result.append(encoded[i:i + 60]) - return '\n'.join(result) - - -class ASSWriter(SubtitleImageBasedWriter): - """ - Advanced SubStation Alpha (ASS) writer for image-based subtitles. - - Generates an ASS file with embedded images in the [Graphics] section. - The images are base64-encoded and referenced via Picture events. - - By default, generates Full HD (1920x1080) images. The PlayResX/PlayResY - values match the image dimensions, so ASS-compatible players will - automatically scale the images to fit the actual video dimensions. - - Uses PNG format for images (best compatibility with libass/FFmpeg/VSFilter). - PNG with 4-color indexed palette compresses very well (~6 KB per Full HD image). - - This format is compatible with players that support ASS embedded graphics - (such as VSFilter, libass, VLC, mpv, FFmpeg, and various subtitle editors). - """ - - def __init__(self, relativize=True, video_width=1920, video_height=1080, - fit_to_screen=True, frame_rate=25, title='Subtitles'): - """ - Initialize the ASS writer. - - :param relativize: Convert absolute positioning to percentages - :param video_width: Width of generated subtitle images (default: 1920 for Full HD) - :param video_height: Height of generated subtitle images (default: 1080 for Full HD) - :param fit_to_screen: Ensure captions fit within screen bounds - :param frame_rate: Frame rate for timing calculations - :param title: Title to include in ASS metadata - """ - super().__init__(relativize, video_width, video_height, fit_to_screen, frame_rate) - self.title = title - - def save_image(self, tmp_dir, index, img): - """Save subtitle image as optimized PNG with transparency.""" - # PNG with indexed palette: best compatibility with libass/FFmpeg/VSFilter - # 4-color palette compresses very well (~6 KB for Full HD) - img.save( - tmp_dir + '/subtitle%04d.png' % index, - transparency=3, - optimize=True, - compress_level=9 - ) - - def format_ts(self, value): - """ - Format timestamp in ASS format: H:MM:SS.cc (centiseconds) - - ASS uses centiseconds (1/100th of a second) not milliseconds. - - :param value: Time in microseconds - :return: Formatted timestamp string - """ - total_seconds = value / 1_000_000 - hours = int(total_seconds // 3600) - minutes = int((total_seconds % 3600) // 60) - seconds = int(total_seconds % 60) - centiseconds = int((total_seconds * 100) % 100) - - return f"{hours}:{minutes:02d}:{seconds:02d}.{centiseconds:02d}" - - def write( - self, - caption_set: CaptionSet, - position='bottom', - avoid_same_next_start_prev_end=False, - align='center' - ): - """ - Write captions to ASS format with embedded full-screen image-based subtitles. - - Images are generated at the configured resolution (default Full HD 1920x1080) - and positioned at 0,0 to cover the entire frame. ASS-compatible players will - automatically scale the images to match the actual video dimensions based on - PlayResX/PlayResY values. - - :param caption_set: CaptionSet containing the captions to write - :param position: Position of subtitles ('top', 'bottom', 'source') - :param avoid_same_next_start_prev_end: Adjust timing to avoid overlaps - :param align: Text alignment ('left', 'center', 'right') - :return: ASS file contents as string with embedded PNG images - """ - lang = caption_set.get_languages().pop() - caps = caption_set.get_captions(lang) - - with tempfile.TemporaryDirectory() as tmpDir: - caps_final, overlapping = self.write_images( - caps, lang, tmpDir, position, align, avoid_same_next_start_prev_end - ) - - # Build ASS content - # PlayResX/PlayResY define the coordinate system - images will be scaled - # to fit the actual video dimensions by ASS-compatible players - ass_content = HEADER.format( - title=self.title, - play_res_x=self.video_width, - play_res_y=self.video_height - ) - - # Add Graphics section with embedded images - ass_content += GRAPHICS_HEADER - for i in range(1, len(caps_final) + 1): - img_path = tmpDir + '/subtitle%04d.png' % i - with open(img_path, 'rb') as img_file: - img_data = img_file.read() - encoded_data = uuencode_ass(img_data) - ass_content += GRAPHICS_ENTRY.format( - filename='subtitle%04d.png' % i, - data=encoded_data - ) - - # Add Events section - # Picture events positioned at 0,0 with 100% scale to cover full frame - ass_content += EVENTS_HEADER - index = 1 - for cap_list in caps_final: - start_ts = self.format_ts(cap_list[0].start) - end_ts = self.format_ts(cap_list[0].end) - filename = 'subtitle%04d.png' % index - - ass_content += PICTURE_EVENT.format( - start=start_ts, - end=end_ts, - filename=filename - ) - index += 1 - - return ass_content diff --git a/pycaption/filtergraph.py b/pycaption/filtergraph.py new file mode 100644 index 00000000..f27e8909 --- /dev/null +++ b/pycaption/filtergraph.py @@ -0,0 +1,160 @@ +import tempfile +import zipfile +from io import BytesIO + +from pycaption.base import CaptionSet +from pycaption.subtitler_image_based import SubtitleImageBasedWriter + + +class FiltergraphWriter(SubtitleImageBasedWriter): + """ + FFmpeg filtergraph writer for image-based subtitles. + + Generates PNG subtitle images and an FFmpeg filtergraph that can be used + to create a transparent WebM video with subtitle overlays. + + By default, generates Full HD (1920x1080) images. The filtergraph uses + the overlay filter with timing to display each subtitle at the correct time. + + Uses PNG format for images with 4-color indexed palette for optimal + compression (~6 KB per Full HD image). + """ + + def __init__(self, relativize=True, video_width=1920, video_height=1080, + fit_to_screen=True, frame_rate=25): + """ + Initialize the filtergraph writer. + + :param relativize: Convert absolute positioning to percentages + :param video_width: Width of generated subtitle images (default: 1920 for Full HD) + :param video_height: Height of generated subtitle images (default: 1080 for Full HD) + :param fit_to_screen: Ensure captions fit within screen bounds + :param frame_rate: Frame rate for timing calculations + """ + super().__init__(relativize, video_width, video_height, fit_to_screen, frame_rate) + + def save_image(self, tmp_dir, index, img): + """Save subtitle image as optimized PNG with transparency.""" + img.save( + tmp_dir + '/subtitle%04d.png' % index, + transparency=3, + optimize=True, + compress_level=9 + ) + + def format_ts_seconds(self, value): + """ + Format timestamp as seconds with 3 decimal places for FFmpeg. + + :param value: Time in microseconds + :return: Seconds as float string + """ + return f"{value / 1_000_000:.3f}" + + def write( + self, + caption_set: CaptionSet, + position='bottom', + avoid_same_next_start_prev_end=False, + align='center', + output_file='subtitles.webm', + image_dir='images' + ): + """ + Write captions as PNG images with an FFmpeg filtergraph for creating + a transparent WebM video overlay. + + Returns a ZIP file containing: + - PNG subtitle images in the specified image_dir + - filtergraph.txt: FFmpeg filter_complex script + - render.sh: Shell script to run FFmpeg + + Usage after extracting the ZIP: + ./render.sh + + :param caption_set: CaptionSet containing the captions to write + :param position: Position of subtitles ('top', 'bottom', 'source') + :param avoid_same_next_start_prev_end: Adjust timing to avoid overlaps + :param align: Text alignment ('left', 'center', 'right') + :param output_file: Output filename (used in render script) + :param image_dir: Directory name for images in the ZIP + :return: ZIP file contents as bytes + """ + lang = caption_set.get_languages().pop() + caps = caption_set.get_captions(lang) + + buf = BytesIO() + with tempfile.TemporaryDirectory() as tmpDir: + caps_final, overlapping = self.write_images( + caps, lang, tmpDir, position, align, avoid_same_next_start_prev_end + ) + + # Calculate total duration (last end time) + max_end = max(cap_list[0].end for cap_list in caps_final) + duration_seconds = max_end / 1_000_000 + 1 # Add 1 second buffer + + # Build FFmpeg filtergraph + # Start with transparent base + filter_parts = [] + filter_parts.append( + f"color=c=black@0:s={self.video_width}x{self.video_height}:d={duration_seconds:.3f},format=yuva420p[base]" + ) + + # Load each image + for i in range(1, len(caps_final) + 1): + filter_parts.append( + f"movie={image_dir}/subtitle{i:04d}.png,format=yuva420p[s{i}]" + ) + + # Chain overlays + prev_label = "base" + for i, cap_list in enumerate(caps_final, 1): + start_sec = self.format_ts_seconds(cap_list[0].start) + end_sec = self.format_ts_seconds(cap_list[0].end) + next_label = f"v{i}" if i < len(caps_final) else "out" + + filter_parts.append( + f"[{prev_label}][s{i}]overlay=x=0:y=0:enable='between(t,{start_sec},{end_sec})':format=auto[{next_label}]" + ) + prev_label = next_label + + filtergraph = ";\n".join(filter_parts) + + # Create render script + render_script = f'''#!/bin/bash +# Generated by pycaption FiltergraphWriter +# Creates a transparent WebM with subtitle overlays +# Resolution: {self.video_width}x{self.video_height} + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +ffmpeg -/filter_complex filtergraph.txt \\ + -map "[out]" \\ + -c:v libvpx-vp9 \\ + -pix_fmt yuva420p \\ + -cpu-used 8 \\ + -deadline realtime \\ + -row-mt 1 \\ + -tile-columns 2 \\ + -frame-parallel 1 \\ + "{output_file}" + +echo "Created {output_file}" +''' + + # Create ZIP archive + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: + # Add images + for i in range(1, len(caps_final) + 1): + img_path = tmpDir + '/subtitle%04d.png' % i + zf.write(img_path, f'{image_dir}/subtitle{i:04d}.png') + + # Add filtergraph + zf.writestr('filtergraph.txt', filtergraph) + + # Add render script + zf.writestr('render.sh', render_script) + + buf.seek(0) + return buf.read() diff --git a/tests/test_ass.py b/tests/test_ass.py deleted file mode 100644 index 8ba26ed4..00000000 --- a/tests/test_ass.py +++ /dev/null @@ -1,193 +0,0 @@ -import re - -from pycaption import DFXPReader, SRTReader -from pycaption.ass import ASSWriter - - -class TestASSWriterTestCase: - - def setup_method(self): - self.writer = ASSWriter() - - def test_basic_output_structure(self): - """Test that basic ASS structure is correct.""" - srt_content = """1 -00:00:01,000 --> 00:00:04,000 -Hello World -""" - caption_set = SRTReader().read(srt_content) - results = self.writer.write(caption_set) - - # Check for required sections - assert '[Script Info]' in results - assert '[V4+ Styles]' in results - assert '[Graphics]' in results - assert '[Events]' in results - - def test_script_info_section(self): - """Test Script Info section contains correct metadata.""" - srt_content = """1 -00:00:01,000 --> 00:00:04,000 -Test -""" - caption_set = SRTReader().read(srt_content) - results = self.writer.write(caption_set) - - assert 'ScriptType: v4.00+' in results - assert 'PlayResX: 1920' in results - assert 'PlayResY: 1080' in results - - def test_custom_resolution(self): - """Test custom video resolution.""" - writer = ASSWriter(video_width=1280, video_height=720) - srt_content = """1 -00:00:01,000 --> 00:00:04,000 -Test -""" - caption_set = SRTReader().read(srt_content) - results = writer.write(caption_set) - - assert 'PlayResX: 1280' in results - assert 'PlayResY: 720' in results - - def test_custom_title(self): - """Test custom title in Script Info.""" - writer = ASSWriter(title='My Custom Title') - srt_content = """1 -00:00:01,000 --> 00:00:04,000 -Test -""" - caption_set = SRTReader().read(srt_content) - results = writer.write(caption_set) - - assert 'Title: My Custom Title' in results - - def test_graphics_section_contains_images(self): - """Test that Graphics section contains embedded PNG images.""" - srt_content = """1 -00:00:01,000 --> 00:00:04,000 -Hello World -""" - caption_set = SRTReader().read(srt_content) - results = self.writer.write(caption_set) - - # Check for embedded image - assert 'filename: subtitle0001.png' in results - # Check for base64 PNG header (iVBORw0KGgo is base64 for PNG magic bytes) - assert 'iVBORw0KGgo' in results - - def test_events_section_has_picture_events(self): - """Test that Events section contains Picture events.""" - 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) - results = self.writer.write(caption_set) - - # Check for Picture events - assert 'Picture: 0,0:00:01.00,0:00:04.00' in results - assert 'Picture: 0,0:00:05.00,0:00:08.00' in results - assert 'subtitle0001.png' in results - assert 'subtitle0002.png' in results - - def test_timestamp_format(self): - """Test ASS timestamp format (H:MM:SS.cc).""" - srt_content = """1 -01:23:45,670 --> 02:34:56,780 -Test -""" - caption_set = SRTReader().read(srt_content) - results = self.writer.write(caption_set) - - # ASS uses centiseconds, so 670ms -> 67cs, 780ms -> 78cs - assert '1:23:45.67' in results - assert '2:34:56.78' in results - - def test_arabic(self, sample_srt_arabic): - """Test Arabic subtitle rendering.""" - caption_set = SRTReader().read(sample_srt_arabic, lang='ar') - results = self.writer.write(caption_set) - - # Should have 4 images for 4 captions - assert results.count('filename: subtitle') == 4 - assert '[Graphics]' in results - assert '[Events]' in results - - def test_styling(self, sample_dfxp_from_sami_with_positioning): - """Test subtitle with positioning.""" - caption_set = DFXPReader().read(sample_dfxp_from_sami_with_positioning) - results = self.writer.write(caption_set) - - # Should have images for each caption - assert 'filename: subtitle0001.png' in results - assert '[Events]' in results - - def test_multiple_captions_same_start_time(self): - """Test handling of multiple captions with same start time.""" - srt_content = """1 -00:00:01,000 --> 00:00:04,000 -First line - -2 -00:00:01,000 --> 00:00:04,000 -Second line (same time) -""" - caption_set = SRTReader().read(srt_content) - results = self.writer.write(caption_set) - - # Both captions with same time should be combined into one image - # So we should only have one Picture event at 0:00:01.00 - picture_count = results.count('Picture: 0,0:00:01.00') - assert picture_count == 1 - - def test_image_count_matches_caption_groups(self): - """Test that number of images matches number of unique time slots.""" - 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) - results = self.writer.write(caption_set) - - # Should have 3 images - assert results.count('filename: subtitle') == 3 - # Should have 3 Picture events - assert results.count('Picture: 0,') == 3 - - def test_full_screen_positioning(self): - """Test that Picture events have full-screen positioning.""" - srt_content = """1 -00:00:01,000 --> 00:00:04,000 -Test -""" - caption_set = SRTReader().read(srt_content) - results = self.writer.write(caption_set) - - # Picture event should have positioning: x=0, y=0, scalex=100, scaley=100 - # Format: Picture: layer,start,end,style,name,marginL,marginR,marginV,effect,filename,x,y,scalex,scaley,fsp - assert ',0,0,100,100,0' in results - - def test_styles_have_zero_margins(self): - """Test that default style has zero margins for full-screen images.""" - srt_content = """1 -00:00:01,000 --> 00:00:04,000 -Test -""" - caption_set = SRTReader().read(srt_content) - results = self.writer.write(caption_set) - - # Style line should end with 0,0,0,1 (MarginL, MarginR, MarginV, Encoding) - assert ',0,0,0,1' in results diff --git a/tests/test_filtergraph.py b/tests/test_filtergraph.py new file mode 100644 index 00000000..b23c85cd --- /dev/null +++ b/tests/test_filtergraph.py @@ -0,0 +1,170 @@ +import zipfile +from io import BytesIO + +from pycaption import SRTReader +from pycaption.filtergraph import FiltergraphWriter + + +class TestFiltergraphWriterTestCase: + """Tests for the FiltergraphWriter that generates FFmpeg filtergraphs.""" + + 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 'images/subtitle0001.png' in names + assert 'images/subtitle0002.png' in names + assert 'filtergraph.txt' in names + assert 'render.sh' in names + + def test_filtergraph_structure(self): + """Test that filtergraph has correct structure.""" + 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: + filtergraph = zf.read('filtergraph.txt').decode() + + # Should have color source for transparent base + assert 'color=c=black@0:s=1920x1080' in filtergraph + assert 'format=yuva420p' in filtergraph + + # Should have movie input for image + assert 'movie=images/subtitle0001.png' in filtergraph + + # Should have overlay with timing + assert 'overlay=x=0:y=0:enable=' in filtergraph + assert "between(t,1.000,4.000)" in filtergraph + + def test_render_script(self): + """Test that render script has correct FFmpeg command.""" + 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: + script = zf.read('render.sh').decode() + + assert '#!/bin/bash' in script + assert 'ffmpeg' in script + assert '-c:v libvpx-vp9' in script + assert '-pix_fmt yuva420p' in script + assert 'subtitles.webm' in script + # Should read filtergraph from file + assert '-/filter_complex filtergraph.txt' in script + + def test_custom_output_name(self): + """Test custom output filename.""" + 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, output_file='my_subs.webm') + + with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: + script = zf.read('render.sh').decode() + assert 'my_subs.webm' in script + + def test_custom_image_dir(self): + """Test custom image directory.""" + 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, image_dir='subs') + + with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: + names = zf.namelist() + assert 'subs/subtitle0001.png' in names + + filtergraph = zf.read('filtergraph.txt').decode() + assert 'movie=subs/subtitle0001.png' in filtergraph + + def test_multiple_overlays_chained(self): + """Test that multiple subtitles are chained correctly.""" + 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: + filtergraph = zf.read('filtergraph.txt').decode() + + # Should have 3 movie inputs + assert 'movie=images/subtitle0001.png' in filtergraph + assert 'movie=images/subtitle0002.png' in filtergraph + assert 'movie=images/subtitle0003.png' in filtergraph + + # Should have chained overlays + assert '[base][s1]overlay' in filtergraph + assert '[v1][s2]overlay' in filtergraph + assert '[v2][s3]overlay' in filtergraph + + # Last one should output to [out] + assert '[out]' in filtergraph + + def test_duration_calculation(self): + """Test that duration is calculated from last subtitle end time.""" + srt_content = """1 +00:00:01,000 --> 00:00:04,000 +First + +2 +00:01:30,000 --> 00:01:35,500 +Last one at 1:35 +""" + caption_set = SRTReader().read(srt_content) + zip_data = self.writer.write(caption_set) + + with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: + filtergraph = zf.read('filtergraph.txt').decode() + + # Duration should be ~96.5 seconds (95.5 + 1 buffer) + assert 'd=96.500' in filtergraph + + def test_custom_resolution(self): + """Test custom video resolution.""" + 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: + filtergraph = zf.read('filtergraph.txt').decode() + assert 's=1280x720' in filtergraph From 925e266941a8288bdb9885e41af64e93d50c5362 Mon Sep 17 00:00:00 2001 From: Sebastian Annies Date: Wed, 7 Jan 2026 11:25:00 +0200 Subject: [PATCH 03/15] mkd example as ffv1 is only one with brew/ffmpeg transparency --- pycaption/filtergraph.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/pycaption/filtergraph.py b/pycaption/filtergraph.py index f27e8909..4b80bc7b 100644 --- a/pycaption/filtergraph.py +++ b/pycaption/filtergraph.py @@ -56,9 +56,7 @@ def write( caption_set: CaptionSet, position='bottom', avoid_same_next_start_prev_end=False, - align='center', - output_file='subtitles.webm', - image_dir='images' + align='center' ): """ Write captions as PNG images with an FFmpeg filtergraph for creating @@ -129,16 +127,13 @@ def write( SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" cd "$SCRIPT_DIR" -ffmpeg -/filter_complex filtergraph.txt \\ - -map "[out]" \\ - -c:v libvpx-vp9 \\ - -pix_fmt yuva420p \\ - -cpu-used 8 \\ - -deadline realtime \\ - -row-mt 1 \\ - -tile-columns 2 \\ - -frame-parallel 1 \\ - "{output_file}" +ffmpeg \ + -filter_complex_script filtergraph.txt \ + -map "[out]" \ + -c:v ffv1 \ + -pix_fmt yuva420p \ + -an \ + "subtitles.mkv" echo "Created {output_file}" ''' From f8f43a759f6847ea03f1c34057a90b6900fa4767 Mon Sep 17 00:00:00 2001 From: Sebastian Annies Date: Wed, 7 Jan 2026 15:12:07 +0200 Subject: [PATCH 04/15] remove render script - get directory handling right --- pycaption/filtergraph.py | 34 +++---------------------- tests/test_filtergraph.py | 52 +++++++-------------------------------- 2 files changed, 13 insertions(+), 73 deletions(-) diff --git a/pycaption/filtergraph.py b/pycaption/filtergraph.py index 4b80bc7b..108782fc 100644 --- a/pycaption/filtergraph.py +++ b/pycaption/filtergraph.py @@ -56,7 +56,8 @@ def write( caption_set: CaptionSet, position='bottom', avoid_same_next_start_prev_end=False, - align='center' + align='center', + output_dir='embedded_subs' ): """ Write captions as PNG images with an FFmpeg filtergraph for creating @@ -65,17 +66,11 @@ def write( Returns a ZIP file containing: - PNG subtitle images in the specified image_dir - filtergraph.txt: FFmpeg filter_complex script - - render.sh: Shell script to run FFmpeg - - Usage after extracting the ZIP: - ./render.sh :param caption_set: CaptionSet containing the captions to write :param position: Position of subtitles ('top', 'bottom', 'source') :param avoid_same_next_start_prev_end: Adjust timing to avoid overlaps :param align: Text alignment ('left', 'center', 'right') - :param output_file: Output filename (used in render script) - :param image_dir: Directory name for images in the ZIP :return: ZIP file contents as bytes """ lang = caption_set.get_languages().pop() @@ -101,7 +96,7 @@ def write( # Load each image for i in range(1, len(caps_final) + 1): filter_parts.append( - f"movie={image_dir}/subtitle{i:04d}.png,format=yuva420p[s{i}]" + f"movie={output_dir}/subtitle{i:04d}.png,format=yuva420p[s{i}]" ) # Chain overlays @@ -118,38 +113,17 @@ def write( filtergraph = ";\n".join(filter_parts) - # Create render script - render_script = f'''#!/bin/bash -# Generated by pycaption FiltergraphWriter -# Creates a transparent WebM with subtitle overlays -# Resolution: {self.video_width}x{self.video_height} - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$SCRIPT_DIR" - -ffmpeg \ - -filter_complex_script filtergraph.txt \ - -map "[out]" \ - -c:v ffv1 \ - -pix_fmt yuva420p \ - -an \ - "subtitles.mkv" - -echo "Created {output_file}" -''' # Create ZIP archive with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: # Add images for i in range(1, len(caps_final) + 1): img_path = tmpDir + '/subtitle%04d.png' % i - zf.write(img_path, f'{image_dir}/subtitle{i:04d}.png') + zf.write(img_path, f'{output_dir}/subtitle{i:04d}.png') # Add filtergraph zf.writestr('filtergraph.txt', filtergraph) - # Add render script - zf.writestr('render.sh', render_script) buf.seek(0) return buf.read() diff --git a/tests/test_filtergraph.py b/tests/test_filtergraph.py index b23c85cd..b027e2c1 100644 --- a/tests/test_filtergraph.py +++ b/tests/test_filtergraph.py @@ -26,10 +26,9 @@ def test_zip_contents(self): with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: names = zf.namelist() - assert 'images/subtitle0001.png' in names - assert 'images/subtitle0002.png' in names + assert 'embedded_subs/subtitle0001.png' in names + assert 'embedded_subs/subtitle0002.png' in names assert 'filtergraph.txt' in names - assert 'render.sh' in names def test_filtergraph_structure(self): """Test that filtergraph has correct structure.""" @@ -48,53 +47,20 @@ def test_filtergraph_structure(self): assert 'format=yuva420p' in filtergraph # Should have movie input for image - assert 'movie=images/subtitle0001.png' in filtergraph + assert 'movie=embedded_subs/subtitle0001.png' in filtergraph # Should have overlay with timing assert 'overlay=x=0:y=0:enable=' in filtergraph assert "between(t,1.000,4.000)" in filtergraph - def test_render_script(self): - """Test that render script has correct FFmpeg command.""" + def test_custom_output_dir(self): + """Test custom output directory.""" 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: - script = zf.read('render.sh').decode() - - assert '#!/bin/bash' in script - assert 'ffmpeg' in script - assert '-c:v libvpx-vp9' in script - assert '-pix_fmt yuva420p' in script - assert 'subtitles.webm' in script - # Should read filtergraph from file - assert '-/filter_complex filtergraph.txt' in script - - def test_custom_output_name(self): - """Test custom output filename.""" - 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, output_file='my_subs.webm') - - with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: - script = zf.read('render.sh').decode() - assert 'my_subs.webm' in script - - def test_custom_image_dir(self): - """Test custom image directory.""" - 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, image_dir='subs') + zip_data = self.writer.write(caption_set, output_dir='subs') with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: names = zf.namelist() @@ -124,9 +90,9 @@ def test_multiple_overlays_chained(self): filtergraph = zf.read('filtergraph.txt').decode() # Should have 3 movie inputs - assert 'movie=images/subtitle0001.png' in filtergraph - assert 'movie=images/subtitle0002.png' in filtergraph - assert 'movie=images/subtitle0003.png' in filtergraph + assert 'movie=embedded_subs/subtitle0001.png' in filtergraph + assert 'movie=embedded_subs/subtitle0002.png' in filtergraph + assert 'movie=embedded_subs/subtitle0003.png' in filtergraph # Should have chained overlays assert '[base][s1]overlay' in filtergraph From cd8d6b3781d3a8ed0e32410d89e655a5199dd00d Mon Sep 17 00:00:00 2001 From: Sebastian Annies Date: Wed, 7 Jan 2026 15:28:46 +0200 Subject: [PATCH 05/15] dir handling right --- pycaption/filtergraph.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pycaption/filtergraph.py b/pycaption/filtergraph.py index 108782fc..32326650 100644 --- a/pycaption/filtergraph.py +++ b/pycaption/filtergraph.py @@ -21,7 +21,7 @@ class FiltergraphWriter(SubtitleImageBasedWriter): """ def __init__(self, relativize=True, video_width=1920, video_height=1080, - fit_to_screen=True, frame_rate=25): + fit_to_screen=True, frame_rate=25, output_dir=None): """ Initialize the filtergraph writer. @@ -31,6 +31,10 @@ def __init__(self, relativize=True, video_width=1920, video_height=1080, :param fit_to_screen: Ensure captions fit within screen bounds :param frame_rate: Frame rate for timing calculations """ + if output_dir is None: + self.output_dir = 'embedded_subs' + else: + self.output_dir = output_dir super().__init__(relativize, video_width, video_height, fit_to_screen, frame_rate) def save_image(self, tmp_dir, index, img): @@ -56,8 +60,7 @@ def write( caption_set: CaptionSet, position='bottom', avoid_same_next_start_prev_end=False, - align='center', - output_dir='embedded_subs' + align='center' ): """ Write captions as PNG images with an FFmpeg filtergraph for creating From 5c306fe1d328ce97edc2c482037003517fcf07c8 Mon Sep 17 00:00:00 2001 From: Sebastian Annies Date: Wed, 7 Jan 2026 15:37:40 +0200 Subject: [PATCH 06/15] dir handling right --- pycaption/filtergraph.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pycaption/filtergraph.py b/pycaption/filtergraph.py index 32326650..3b1ce3ed 100644 --- a/pycaption/filtergraph.py +++ b/pycaption/filtergraph.py @@ -99,7 +99,7 @@ def write( # Load each image for i in range(1, len(caps_final) + 1): filter_parts.append( - f"movie={output_dir}/subtitle{i:04d}.png,format=yuva420p[s{i}]" + f"movie={self.output_dir}/subtitle{i:04d}.png,format=yuva420p[s{i}]" ) # Chain overlays @@ -122,7 +122,7 @@ def write( # Add images for i in range(1, len(caps_final) + 1): img_path = tmpDir + '/subtitle%04d.png' % i - zf.write(img_path, f'{output_dir}/subtitle{i:04d}.png') + zf.write(img_path, f'{self.output_dir}/subtitle{i:04d}.png') # Add filtergraph zf.writestr('filtergraph.txt', filtergraph) From 91db2a04a12c3eba6fdd69a8dd051f652c1e01db Mon Sep 17 00:00:00 2001 From: Sebastian Annies Date: Wed, 7 Jan 2026 15:56:23 +0200 Subject: [PATCH 07/15] filtergraph in same subdir --- pycaption/filtergraph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pycaption/filtergraph.py b/pycaption/filtergraph.py index 3b1ce3ed..edaa7dd7 100644 --- a/pycaption/filtergraph.py +++ b/pycaption/filtergraph.py @@ -125,7 +125,7 @@ def write( zf.write(img_path, f'{self.output_dir}/subtitle{i:04d}.png') # Add filtergraph - zf.writestr('filtergraph.txt', filtergraph) + zf.writestr(f'{self.output_dir}/filtergraph.txt', filtergraph) buf.seek(0) From 7b8dc4fbea8135553b80a9f3d476e6fc8236b6d1 Mon Sep 17 00:00:00 2001 From: Sebastian Annies Date: Wed, 7 Jan 2026 20:04:49 +0200 Subject: [PATCH 08/15] more debug --- pycaption/filtergraph.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pycaption/filtergraph.py b/pycaption/filtergraph.py index edaa7dd7..f6420438 100644 --- a/pycaption/filtergraph.py +++ b/pycaption/filtergraph.py @@ -127,6 +127,9 @@ def write( # Add filtergraph zf.writestr(f'{self.output_dir}/filtergraph.txt', filtergraph) + buf.seek(0) + with zipfile.ZipFile(buf, 'r') as zf: + print(zf.namelist()) buf.seek(0) return buf.read() From b91423ab8093c7641be5711583b8e4a2bdbafe09 Mon Sep 17 00:00:00 2001 From: Sebastian Annies Date: Wed, 7 Jan 2026 20:36:52 +0200 Subject: [PATCH 09/15] next iteration --- pycaption/filtergraph.py | 6 +----- tests/test_filtergraph.py | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/pycaption/filtergraph.py b/pycaption/filtergraph.py index f6420438..6b447817 100644 --- a/pycaption/filtergraph.py +++ b/pycaption/filtergraph.py @@ -96,7 +96,7 @@ def write( f"color=c=black@0:s={self.video_width}x{self.video_height}:d={duration_seconds:.3f},format=yuva420p[base]" ) - # Load each image + # Load each image (paths relative to where ffmpeg is run) for i in range(1, len(caps_final) + 1): filter_parts.append( f"movie={self.output_dir}/subtitle{i:04d}.png,format=yuva420p[s{i}]" @@ -127,9 +127,5 @@ def write( # Add filtergraph zf.writestr(f'{self.output_dir}/filtergraph.txt', filtergraph) - buf.seek(0) - with zipfile.ZipFile(buf, 'r') as zf: - print(zf.namelist()) - buf.seek(0) return buf.read() diff --git a/tests/test_filtergraph.py b/tests/test_filtergraph.py index b027e2c1..fa6f3145 100644 --- a/tests/test_filtergraph.py +++ b/tests/test_filtergraph.py @@ -28,7 +28,7 @@ def test_zip_contents(self): names = zf.namelist() assert 'embedded_subs/subtitle0001.png' in names assert 'embedded_subs/subtitle0002.png' in names - assert 'filtergraph.txt' in names + assert 'embedded_subs/filtergraph.txt' in names def test_filtergraph_structure(self): """Test that filtergraph has correct structure.""" @@ -40,13 +40,13 @@ def test_filtergraph_structure(self): zip_data = self.writer.write(caption_set) with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: - filtergraph = zf.read('filtergraph.txt').decode() + filtergraph = zf.read('embedded_subs/filtergraph.txt').decode() # Should have color source for transparent base assert 'color=c=black@0:s=1920x1080' in filtergraph assert 'format=yuva420p' in filtergraph - # Should have movie input for image + # Should have movie input for image (path relative to ffmpeg working dir) assert 'movie=embedded_subs/subtitle0001.png' in filtergraph # Should have overlay with timing @@ -55,18 +55,20 @@ def test_filtergraph_structure(self): 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 = self.writer.write(caption_set, output_dir='subs') + 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/filtergraph.txt' in names - filtergraph = zf.read('filtergraph.txt').decode() + filtergraph = zf.read('subs/filtergraph.txt').decode() assert 'movie=subs/subtitle0001.png' in filtergraph def test_multiple_overlays_chained(self): @@ -87,7 +89,7 @@ def test_multiple_overlays_chained(self): zip_data = self.writer.write(caption_set) with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: - filtergraph = zf.read('filtergraph.txt').decode() + filtergraph = zf.read('embedded_subs/filtergraph.txt').decode() # Should have 3 movie inputs assert 'movie=embedded_subs/subtitle0001.png' in filtergraph @@ -116,7 +118,7 @@ def test_duration_calculation(self): zip_data = self.writer.write(caption_set) with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: - filtergraph = zf.read('filtergraph.txt').decode() + filtergraph = zf.read('embedded_subs/filtergraph.txt').decode() # Duration should be ~96.5 seconds (95.5 + 1 buffer) assert 'd=96.500' in filtergraph @@ -132,5 +134,5 @@ def test_custom_resolution(self): zip_data = writer.write(caption_set) with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: - filtergraph = zf.read('filtergraph.txt').decode() + filtergraph = zf.read('embedded_subs/filtergraph.txt').decode() assert 's=1280x720' in filtergraph From a7e4f87786699e1e1539af845eff7315be2a9662 Mon Sep 17 00:00:00 2001 From: Sebastian Annies Date: Thu, 8 Jan 2026 11:04:24 +0200 Subject: [PATCH 10/15] ok, minimum font size is 32px --- pycaption/subtitler_image_based.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pycaption/subtitler_image_based.py b/pycaption/subtitler_image_based.py index d9d423e5..54e3f664 100644 --- a/pycaption/subtitler_image_based.py +++ b/pycaption/subtitler_image_based.py @@ -217,7 +217,8 @@ def write_images( if missing_glyphs: raise ValueError(f'Selected font was missing glyphs: {" ".join(missing_glyphs.keys())}') - font_size = int(self.video_width * 0.05 * 0.6) # rough estimate but should work + min_font_px = 32 + font_size = max(min_font_px, int(self.video_width * 0.05 * 0.6)) # rough estimate but should work fnt = ImageFont.truetype(fnt, font_size) index = 1 From 324354525ded2c6735dcc3ea433683c2eeb7799a Mon Sep 17 00:00:00 2001 From: Sebastian Annies Date: Thu, 8 Jan 2026 11:41:21 +0200 Subject: [PATCH 11/15] ok, minimum font size is 16px store proper transparent pngs --- pycaption/filtergraph.py | 3 ++- pycaption/subtitler_image_based.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pycaption/filtergraph.py b/pycaption/filtergraph.py index 6b447817..5de9fa4d 100644 --- a/pycaption/filtergraph.py +++ b/pycaption/filtergraph.py @@ -39,9 +39,10 @@ def __init__(self, relativize=True, video_width=1920, video_height=1080, def save_image(self, tmp_dir, index, img): """Save subtitle image as optimized PNG with transparency.""" + img = img.convert("RGBA") + img.save( tmp_dir + '/subtitle%04d.png' % index, - transparency=3, optimize=True, compress_level=9 ) diff --git a/pycaption/subtitler_image_based.py b/pycaption/subtitler_image_based.py index 54e3f664..c97ad54a 100644 --- a/pycaption/subtitler_image_based.py +++ b/pycaption/subtitler_image_based.py @@ -217,7 +217,7 @@ def write_images( if missing_glyphs: raise ValueError(f'Selected font was missing glyphs: {" ".join(missing_glyphs.keys())}') - min_font_px = 32 + min_font_px = 16 font_size = max(min_font_px, int(self.video_width * 0.05 * 0.6)) # rough estimate but should work fnt = ImageFont.truetype(fnt, font_size) From 5c9f87b43b8fbe7b203788419726d2d99ed7a513 Mon Sep 17 00:00:00 2001 From: Sebastian Annies Date: Thu, 8 Jan 2026 12:28:37 +0200 Subject: [PATCH 12/15] refactor: update image saving methods to handle RGBA and transparency --- pycaption/filtergraph.py | 4 +--- pycaption/scenarist.py | 11 ++++++++++- pycaption/subtitler_image_based.py | 13 ++++++------- pycaption/ttml_background.py | 10 ++++++++-- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/pycaption/filtergraph.py b/pycaption/filtergraph.py index 5de9fa4d..2ff9290e 100644 --- a/pycaption/filtergraph.py +++ b/pycaption/filtergraph.py @@ -38,9 +38,7 @@ def __init__(self, relativize=True, video_width=1920, video_height=1080, super().__init__(relativize, video_width, video_height, fit_to_screen, frame_rate) def save_image(self, tmp_dir, index, img): - """Save subtitle image as optimized PNG with transparency.""" - img = img.convert("RGBA") - + """Save RGBA image as PNG with transparency.""" img.save( tmp_dir + '/subtitle%04d.png' % index, optimize=True, diff --git a/pycaption/scenarist.py b/pycaption/scenarist.py index a8b10876..f91eaee3 100644 --- a/pycaption/scenarist.py +++ b/pycaption/scenarist.py @@ -4,6 +4,8 @@ from datetime import timedelta from io import BytesIO +from PIL import Image + from pycaption.base import CaptionSet from pycaption.subtitler_image_based import SubtitleImageBasedWriter @@ -87,7 +89,14 @@ def __init__(self, relativize=True, video_width=720, video_height=480, fit_to_sc self.contrast = '(7 7 7 7)' def save_image(self, tmp_dir, index, img): - img.save(tmp_dir + '/subtitle%04d.tif' % index, compression=self.tiff_compression) + """Convert RGBA to paletted image for DVD subtitles.""" + # Replace transparent pixels with green background + background = Image.new('RGB', img.size, self.bgColor) + background.paste(img, mask=img.split()[3]) # Use alpha channel as mask + + # Quantize to 4-color palette + img_quant = background.quantize(palette=self.palette_image, dither=0) + img_quant.save(tmp_dir + '/subtitle%04d.tif' % index, compression=self.tiff_compression) def write( self, diff --git a/pycaption/subtitler_image_based.py b/pycaption/subtitler_image_based.py index c97ad54a..9394fcde 100644 --- a/pycaption/subtitler_image_based.py +++ b/pycaption/subtitler_image_based.py @@ -225,14 +225,13 @@ def write_images( for i, cap_list in enumerate(caps_final): - img = Image.new('RGB', (self.video_width, self.video_height), self.bgColor) + # Create RGBA image with transparent background + img = Image.new('RGBA', (self.video_width, self.video_height), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) self.printLine(draw, cap_list, fnt, position, align) - # quantize the image to our palette - img_quant = img.quantize(palette=self.palette_image, dither=0) - self.save_image(tmpDir, index, img_quant) - + # Pass RGBA image to subclass - each subclass converts as needed + self.save_image(tmpDir, index, img) index = index + 1 @@ -286,8 +285,8 @@ def printLine(self, draw: ImageDraw, caption_list: Caption, fnt: ImageFont, posi else: raise ValueError('Unknown "position": {}'.format(position)) - borderColor = self.e2Color - fontColor = self.paColor + borderColor = (*self.e2Color, 255) # Add alpha for RGBA + fontColor = (*self.paColor, 255) # Add alpha for RGBA for adj in range(2): # move right draw.text((x - adj, y), text, font=fnt, fill=borderColor, align=align) diff --git a/pycaption/ttml_background.py b/pycaption/ttml_background.py index 1be44967..73a3c994 100644 --- a/pycaption/ttml_background.py +++ b/pycaption/ttml_background.py @@ -52,8 +52,14 @@ def __init__(self, relativize=True, video_width=720, video_height=480, fit_to_sc self.frame_rate = frame_rate def save_image(self, tmp_dir, index, img): - # Jetzt speichern mit Transparenz - img.save(tmp_dir + '/subtitle%04d.png' % index, transparency=3) + """Convert RGBA to paletted PNG with transparency.""" + # Replace transparent pixels with green background + background = Image.new('RGB', img.size, self.bgColor) + background.paste(img, mask=img.split()[3]) # Use alpha channel as mask + + # Quantize to 4-color palette + img_quant = background.quantize(palette=self.palette_image, dither=0) + img_quant.save(tmp_dir + '/subtitle%04d.png' % index, transparency=3) def to_ttml_timestamp(self, ms: int) -> str: hours = ms // 3_600_000 From f11cf481764679564ad471c3a0a6a3e786758741 Mon Sep 17 00:00:00 2001 From: Sebastian Annies Date: Thu, 8 Jan 2026 13:44:02 +0200 Subject: [PATCH 13/15] Separate out according to different aspects --- pycaption/scenarist.py | 10 ++ pycaption/subtitler_image_based.py | 36 +++---- pycaption/ttml_background.py | 10 ++ tests/test_ttml_background.py | 153 +++++++++++++++++------------ 4 files changed, 126 insertions(+), 83 deletions(-) diff --git a/pycaption/scenarist.py b/pycaption/scenarist.py index f91eaee3..b442bd4e 100644 --- a/pycaption/scenarist.py +++ b/pycaption/scenarist.py @@ -75,12 +75,22 @@ class ScenaristDVDWriter(SubtitleImageBasedWriter): tiff_compression = None + # DVD subtitle palette colors + paColor = (255, 255, 255) # letter body (white) + e1Color = (190, 190, 190) # antialiasing color (gray) + e2Color = (0, 0, 0) # border color (black) + bgColor = (0, 255, 0) # background color (green - will be transparent on DVD) + def __init__(self, relativize=True, video_width=720, video_height=480, fit_to_screen=True, tape_type='NON_DROP', frame_rate=25, compat=False): super().__init__(relativize, video_width, video_height, fit_to_screen, frame_rate) self.tape_type = tape_type self.frame_rate = frame_rate + # Create palette image for quantization (4 colors only - smaller output) + self.palette_image = Image.new("P", (1, 1)) + self.palette_image.putpalette([*self.paColor, *self.e1Color, *self.e2Color, *self.bgColor]) + if compat: self.color = '(1 2 3 4)' self.contrast = '(15 15 15 0)' diff --git a/pycaption/subtitler_image_based.py b/pycaption/subtitler_image_based.py index 9394fcde..88eba71f 100644 --- a/pycaption/subtitler_image_based.py +++ b/pycaption/subtitler_image_based.py @@ -30,22 +30,14 @@ def get_sst_pixel_display_params(video_width, video_height): class SubtitleImageBasedWriter(BaseWriter): VALID_POSITION = ['top', 'bottom', 'source'] - paColor = (255, 255, 255) # letter body - e1Color = (190, 190, 190) # antialiasing color - e2Color = (0, 0, 0) # border color - bgColor = (0, 255, 0) # background color - - palette_image = Image.new("P", (1, 1)) - palette_image.putpalette([*paColor, *e1Color, *e2Color, *bgColor] + [0, 0, 0] * 252) + # Default colors for RGBA rendering + fontColor = (255, 255, 255) # white text + borderColor = (0, 0, 0) # black border def __init__(self, relativize=True, video_width=720, video_height=480, fit_to_screen=True, frame_rate=25): super().__init__(relativize, video_width, video_height, fit_to_screen) - self.palette = [self.paColor, self.e1Color, self.e2Color, self.bgColor] self.frame_rate = frame_rate - palette_image = Image.new("P", (1, 1)) - palette_image.putpalette([*self.paColor, *self.e1Color, *self.e2Color, *self.bgColor] + [0, 0, 0] * 252) - self.font_langs = { Language.get('en'): {'fontfile': f"{os.path.dirname(__file__)}/NotoSansDisplay-Regular-Note-Math.ttf"}, Language.get('ru'): {'fontfile': f"{os.path.dirname(__file__)}/NotoSansDisplay-Regular-Note-Math.ttf"}, @@ -285,25 +277,25 @@ def printLine(self, draw: ImageDraw, caption_list: Caption, fnt: ImageFont, posi else: raise ValueError('Unknown "position": {}'.format(position)) - borderColor = (*self.e2Color, 255) # Add alpha for RGBA - fontColor = (*self.paColor, 255) # Add alpha for RGBA + border = (*self.borderColor, 255) # Add alpha for RGBA + font = (*self.fontColor, 255) # Add alpha for RGBA for adj in range(2): # move right - draw.text((x - adj, y), text, font=fnt, fill=borderColor, align=align) + draw.text((x - adj, y), text, font=fnt, fill=border, align=align) # move left - draw.text((x + adj, y), text, font=fnt, fill=borderColor, align=align) + draw.text((x + adj, y), text, font=fnt, fill=border, align=align) # move up - draw.text((x, y + adj), text, font=fnt, fill=borderColor, align=align) + draw.text((x, y + adj), text, font=fnt, fill=border, align=align) # move down - draw.text((x, y - adj), text, font=fnt, fill=borderColor, align=align) + draw.text((x, y - adj), text, font=fnt, fill=border, align=align) # diagnal left up - draw.text((x - adj, y + adj), text, font=fnt, fill=borderColor, align=align) + draw.text((x - adj, y + adj), text, font=fnt, fill=border, align=align) # diagnal right up - draw.text((x + adj, y + adj), text, font=fnt, fill=borderColor, align=align) + draw.text((x + adj, y + adj), text, font=fnt, fill=border, align=align) # diagnal left down - draw.text((x - adj, y - adj), text, font=fnt, fill=borderColor, align=align) + draw.text((x - adj, y - adj), text, font=fnt, fill=border, align=align) # diagnal right down - draw.text((x + adj, y - adj), text, font=fnt, fill=borderColor, align=align) + draw.text((x + adj, y - adj), text, font=fnt, fill=border, align=align) - draw.text((x, y), text, font=fnt, fill=fontColor, align=align) + draw.text((x, y), text, font=fnt, fill=font, align=align) lines_written += 1 diff --git a/pycaption/ttml_background.py b/pycaption/ttml_background.py index 73a3c994..155341a2 100644 --- a/pycaption/ttml_background.py +++ b/pycaption/ttml_background.py @@ -45,12 +45,22 @@ class TTMLBackgroundWriter(SubtitleImageBasedWriter): + # Palette colors for TTML background images + paColor = (255, 255, 255) # letter body (white) + e1Color = (190, 190, 190) # antialiasing color (gray) + e2Color = (0, 0, 0) # border color (black) + bgColor = (0, 255, 0) # background color (green - index 3 = transparent) + def __init__(self, relativize=True, video_width=720, video_height=480, fit_to_screen=True, tape_type='NON_DROP', frame_rate=25, compat=False): super().__init__(relativize, video_width, video_height, fit_to_screen, frame_rate) self.tape_type = tape_type self.frame_rate = frame_rate + # Create palette image for quantization (4 colors only - smaller output) + self.palette_image = Image.new("P", (1, 1)) + self.palette_image.putpalette([*self.paColor, *self.e1Color, *self.e2Color, *self.bgColor]) + def save_image(self, tmp_dir, index, img): """Convert RGBA to paletted PNG with transparency.""" # Replace transparent pixels with green background diff --git a/tests/test_ttml_background.py b/tests/test_ttml_background.py index c3920dce..2f7fa5ea 100644 --- a/tests/test_ttml_background.py +++ b/tests/test_ttml_background.py @@ -1,3 +1,6 @@ +import base64 +import re + from pycaption import (DFXPReader, SRTReader) from pycaption.ttml_background import TTMLBackgroundWriter @@ -7,71 +10,99 @@ class TestTTMLBackgroundWriterTestCase: def setup_class(self): self.writer = TTMLBackgroundWriter() + def _validate_ttml_structure(self, results, expected_num_images, expected_timings): + """Validate TTML structure and content.""" + # Check XML header and namespaces + assert "" in results + assert 'xmlns:smpte="http://www.smpte-ra.org/schemas/2052-1/2010/smpte-tt"' in results + assert 'ttp:profile="http://www.w3.org/ns/ttml/profile/imsc1/image"' in results + + # Check for correct number of images + image_pattern = r'(.*?)' + images = re.findall(image_pattern, results, re.DOTALL) + assert len(images) == expected_num_images, f"Expected {expected_num_images} images, got {len(images)}" + + # Verify each image is valid base64 PNG + for img_id, img_data in images: + try: + decoded = base64.b64decode(img_data) + # Check PNG magic bytes + assert decoded[:8] == b'\x89PNG\r\n\x1a\n', f"Image {img_id} is not a valid PNG" + except Exception as e: + raise AssertionError(f"Image {img_id} is not valid base64: {e}") + + # Check for correct div elements with timing + div_pattern = r'
' + divs = re.findall(div_pattern, results) + assert len(divs) == expected_num_images, f"Expected {expected_num_images} divs, got {len(divs)}" + + # Verify timings + for i, (begin, end, img_id) in enumerate(divs): + expected_begin, expected_end = expected_timings[i] + assert begin == expected_begin, f"Div {i+1}: expected begin={expected_begin}, got {begin}" + assert end == expected_end, f"Div {i+1}: expected end={expected_end}, got {end}" + assert img_id == str(i + 1), f"Div {i+1}: expected img_id={i+1}, got {img_id}" + def test_arabic(self, sample_srt_arabic): caption_set = SRTReader().read(sample_srt_arabic, lang='ar') results = self.writer.write(caption_set) - print(results) - assert results == """ - - - - - - -iVBORw0KGgoAAAANSUhEUgAAAtAAAAHgCAMAAAC7G6qeAAADAFBMVEX///++vr4AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACDvjs2AAAABHRSTlP///8AQCqp9AAACXBJREFUeJzt3Qly2kgAQFEk3//OE6MFgdkEsafy/V5lsoDUTqq+242Meg4fEHL4v/8C8DcJmhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJ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kyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImpRi0NPD79joR627+n17/GsEg56mcRyGb5byQxutm65ZTqu3y8frrdtfx3H+8J8/f2SXvFQMenjP5RLKZ8Pv042+uL9xWLKchndzrI99jsuH70+W91v/uWGa3v82b/L48JPj258JXz6eL933N/48+q2KQb/HeR1sp+H/j4VPN/ri/oZD0OP4GOnfP37Pef5oXP6ctqAff5kOB/Xy+OZPfHb4H7f4Nfe6n2LQwzwcXm78JOhnG72672m6/Z7rsAX9mE+sw+94nHCM88g8VzxPOg7HJOhfpBj0s+/V5bYnU5Kvf4On9zL3ifI/kS7bTvMsfP5jGobjTpapxprxZbYt6F8lGPTT79U16G8P4aeNx8PZ3PuGT4LeZ9bL3c5Bf+WYD594fXQf58KCrvlS0Ne7bFE+OZ28PtZpJeN9FrHMOZaheZ5UjJcBevxC0E/O1D4N+tunC9+51/38rUG/COdtuo7dpxumbf3iOIXezu62OfT7gsZpEXDp+fVq99Ltx2W8c9CX7R87E/TR3xX0eT342UbbTGF77WOfOrwPsMsnx/22x3C8BT3MJ5cfgh4/OeVcgz4taR8+sT3dhsMXN68LDtPh7h+//MtXut5T0PexxXu57bgePF6XnbcxcNwW3ZbReJ07LMvK73/fh8VlLWO+z7psN46XoOcJ9DeOedqeAcegp3kHW7mPO70f57R8QeeH2p6RlyF9flFH0LdxGdS2LuaF4ce0YB9Jzxvtyx2HjvfUx+Pc4m29cY54XdhYF5lPQZ8n1C+PeS36Ue66q0PQ8+r1afo+Bz2uX9C6xeEZcdj3/L/g2+smd1IOel5yGLcuhmUwHdeF4WPS2zd4HW2vN6zPicvEZBjWeca+y/Wep4n2l455Wh9g2Ef98fLf8zbzQ+8r3dPyw+g93MsXsp2ZDoK+lcNwtg996wC2D7jTNBwmpHu/2xbnG/bx8DxqT9ugvE0zDq9uLw/+6evM+z72R/2Q8mXiMg37Eb2tU6Jpe+JN4+ULeQzkjweeJkHfyDbajfty8LT1vP9wH18FPZwmmcsNx+WM04x7+XOfN0/rGHl6l9KXjnncpsrrwt9lyjEezme3KdS6i/mM8bH3+Qg+PDOH7Zki6Ps4LaKtDQzbULr/ZbysDZw/cb3hcQK2bLw9K7ZY1qDXn/3Hl74/vMry7JiH/SjnZ9ryAJfZ+WPCvgY6juu5wHbCewx6+fFxmDuNwzp8C/o+9iWBdX47zm97m7//j9Oi6znh29Lrcp/lzUSXG9YHO77/6DjR3t6cNC85j8Oa3zR8GvTjbG1+/OEx35+PcVh+XIzbjh/v31u3mtadHk5yj+e/h2NdjnFY9/PZaepNhYMelzbG/Xu4v5fzw5tFl1em17FxGfeONxyqXpfIDhPYx3B6+uw61I5fGqHXEXbd9Xro62Ntz6vr9GV/k+ph2W494n3t4/iJ5UfNr/h//cdJBv34wbtNMl75uNGHO5w+3M7U9q2PU5btdcD9xu2jj7t7etCH7a/H+fK4D3s5f/nXY93W8149TkMx6Le39QWEtx/93j3Zbgn6j/0dk/PxPnoezvFWp80nzaC3l4jPb4z7xgN83G66rJL9aU7Hu/xG2XT6Qv7cY/+FokFvc8/TW5e/8QBPtttejvtFB/mLnY533JbdD1+IoG9rWk/5fnS2+HS7+c1Jf2oT5xnHetJ7mtJ/+gJ8QDTof+cbd59zqafHmV17PmoGzVOCJuXPXaL5dQT9F7nNjOknCJoUQZMi6J55ZnF+Bf7F/X7nYf0egu65/nrLiwsnNE8RBd3zlaDXX2/JEXTP50FP03eu5Xcrgu75NOhpf6N1jqB7Pg/6s8ve3Jigez4POvv7KoK+r9dvJHwZ9LrFFC5a0De1z4M//m7Ni6APV1PqFi3oe5qW3wGepj3T42XPngR9uL5YuGhB39N6Ua/xcNGv42XPPgZ9vL7Y8tF/d/j/HkHf03KNpccVCa4X/XoR9PnfKfr8ygr3JOibms8J53/fZb/63uugT9cXe+v++oqg72maLwm9Bj3uk+kt70PQ86+rH64vdhyrWwR9S9uVkcbDRb/2qzudr2i+XGZsv77Yck2x5FmhoG9pHWhPF/0a58uerUtze9DbhRy3S6Md/r3bGEHf0nrJ3+tFvw4XSjpeA2y5dZo+3CtH0Ld0mACvF/364cuetQj6lq7zieknLnvWIuhb2ssdh5+97FmLoG/peGHqn73sWYugb+n4TwX99Q2fCPqWqi+L/DxB35OgXxD0PZkuvyBoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNyv8AqpC7gjE/2R8AAAAASUVORK5CYII=iVBORw0KGgoAAAANSUhEUgAAAtAAAAHgCAMAAAC7G6qeAAADAFBMVEX///++vr4AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACDvjs2AAAABHRSTlP///8AQCqp9AAACfJJREFUeJzt3YtO20gAQFHb/P8/b/ErjpNUoQKRvTlHK5UFZ+JKl2HshOnwASHDb58AfCdBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQp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JkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmC/o7x6k85Cy8J+mvXDofhHUG/5Sy8JOgvXXO+XN4S9CjojxP0F6acrz1/meI35gkjx+cJ+rVlef5GiOPXRxmiP0/Qr117XkMdZ48HrFu+nJBvk/jr6M+vsO759mP+qwn6tfFyORb7FO04Hnr/4lTz7PLyiJMrbHu+/Zj/aoL+iWWtPK7Qw1bveFiXl0Z/urjuJ7weOZwVvd3zcIXDznXP851+5f+rTtCnbu/UzSWP2xq9Jzu9jbcXPd28zRPDq8imA8dD0OO2tK9PmpMnxfLOyMmo85Y3XnoEfWZ+KXhZWtqmjq3nyz48rI1O2y4vkp6Ou1V9t2E+27Cfbdz+muJeZ429/uWH9YnEPUGfWNbMJb59qlgTvy3Fe+VzWdPiOrxIerrH7Y/jhvX5si3z0xXmZXlqeXkQ16OWhzDvWf4F+ehv4f9J0CduYd76XGeNLeQlt3nnwxI93RwPr/seyp5eE16OQU8Vz5PIeH+FqeLjkr49uaZt0wMzc5wS9LMpy0Oz63K8FDivnMeg9yF7OM4i+xixDArXKftuhp6W88va7XaFOd9xvNuxjifbNkGfEvSz2zp5WWaO2TxJLH8NW+3jcc443FrPst1YD70ffJdhY7nUcHeh9VXpvmO5/7D9IOgzgn42ZXfr7PbfbVW9DMu4sN0ahiXGYd4wH3Y3QB+DXo64/07IuL6O3C/1cIXpj+3y6zWG/QH8/t/NH0/Qz8Z1IR7WFpcfh209XdbNYV9fL4/v2h1H62F9NtwdcLvP/aUOV1g2TR+1rFu2w779efzfR9DPlldeRw8/LmPxyaaHsyw39+HlsH8dI87O/S2/8Vfy/yHoZ++ZTueIf2wf0jzs9b7bZwj62fHTj39xlvUV3ngy777tO9Y8EPSz9wS9j84n8+5oBv4QQT9716fK0+I8v1Z8+ibGy6998OsE/ex9b/H+9NWbnD9F0Cc+HzSfIugTb/s6/c+/sc+HCPrE21bVs2/s81GC/iQTx28naFIETYqgSRE0KYImRdCkCJoUQZMiaFL+AaVuyauPhcHlAAAAAElFTkSuQmCC - - - -
-
-
-
- -""" + + expected_timings = [ + ("00:00:40.000", "00:00:43.250"), + ("00:00:44.542", "00:00:49.500"), + ("00:00:51.125", "00:00:54.417"), + ("00:00:55.292", "00:00:58.917"), + ] + self._validate_ttml_structure(results, 4, expected_timings) def test_styling(self, sample_dfxp_from_sami_with_positioning): caption_set = DFXPReader().read(sample_dfxp_from_sami_with_positioning) results = self.writer.write(caption_set) - print(results) - assert results == """ - - - - - - -iVBORw0KGgoAAAANSUhEUgAAAtAAAAHgCAMAAAC7G6qeAAADAFBMVEX///++vr4AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACDvjs2AAAABHRSTlP///8AQCqp9AAAB0NJREFUeJzt29FymzgAQFGb/P8/b2IMFjFulGyn7dw55yFjI4HzcEMFuJc3CLn87V8AfidBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgv6xZVm+ObK8nPXqSHyXoKcsz40uy+X6otsXI8t1PMg46zgy9/mcEvSU5Xr5P0Hfcnw/xvU86E8jZ4f8agJ3gp5xFu83gl6uH++Xl2fo5esz9PsflKJnCHrGsaf13/89yHE5cHt9GvQyLJqfZo0jb5+3rW+coucIesIhp/fQPvpc7hu3t4ehj5FleWy9rDusG05m7T+W6zBp3+PNKXqaoCeMNb2Hdnl33YPc3o5Dt5FtYbxu3TafzboXf9lGbn9Ct1fDqsQpeoqgJ4yXhGuBjzPs+natcB/atn06Q7+atQV9/7lsr6+XYVUi6CmCnnC4wXaP8+0Q5McpfB9aR7ae97Pr4ZR8mLUHvWxH2Q87BP10n4UTgp5wDPre6Rb0foG4D93ePXre9t+Dfpq1BX39dKhD0M83Djkh6AnLWVd7eduMfehj5HK4LfIp6M+zxqDHQy2nH8yvCHrCcvYv/33JsAV92V/vV3Sf9r8HfTLrOeiLoH9I0BN+cIY+3GZ7eYY+rFicoX8HQU/4yRp6GRYdr9fQ66zToK2hf0TQE4aYxockjzvI612Ox9DjVt6603Db7mzWWdDPdznctpsh6AmHByvvkX0Y7kO/2+8wr0OHByuPe32vZj0FvW7ejns7hKDnCHrC8WsXs08KD9ePL58Urnefn4K+TRqfFHr0PUfQM8anGvuXMvYvXYzf2jh+Z2M57nPcY5i1/Xjbd3usSp5/A35B0DP+0vnxsWqx4pgl6CnrN5r/4OfdT9D7okXPkwQ9ZVg+/JnPWy81h2tCK+g5gv4nLdfbReFX/5GFJ4L+J42XmnyHoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCn/AS7ayVFsyVPGAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAtAAAAHgCAMAAAC7G6qeAAADAFBMVEX///++vr4AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACDvjs2AAAABHRSTlP///8AQCqp9AAACwtJREFUeJzt3et2mzAChVGQ3/+dpzaSEOALbchk5mTvH62DgXp1fWZxVaYbBJl++gPAlQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEEfbFyt3/55gXXEvTFyjTNLeh5mkp7tUzs7/6ZIujvIOiL/Ul2Wja+5fGyvepBL+8K+psI+mJlmteNcK12efXoeKppL0Hb77icoC/2J+i5bY3nNehSO35MKzXoUmZFX0zQF1uTLVPdryj3mucW9JL2EvTc97e5iKAvtkm2Bn0vvFZ+T/v+tqC/iaAvtiRbhhf3HedSauV//lx+bPvQgr6WoC9WO74X3Ku9V7xW/tguO8vxTQR9sWUbvBRbI55b0OXW2xb0NxH0xXrMfTN8P1X3MC9B14NEQX8LQV+shTy3HeW6x7FUvj9e5GqCvtiS73IKup3KKDXouQV9v3Lowsq3EPTFSj0p187N1YbbsWG/Tji5sPItBH2xHnQp7QT03O9Q6kEvt3k4D309QV9sPd+8XlGp78ztmstStKC/g6AvtlwrWf8crp2MP42zcSFBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0P/k6it8m/UdVu164nmC/qhdnx7/LtfehLFZ3+EWvKv/tWiC/qisg8Ssf1+WWL2nY13f8Y4lQf8FQX9UC+vjeQ33NV+x8nkftC30Vwj6o/W5qanHVx+rWmcZd4E/7PFuZ57v+W7Wt7k9b/gAnCHozx73Lt/Taw9xl/oIbG3w/njV3F+u0/tu9+6W0jbz44f6wOGwvuHW02VKqY/WOjY8QdCfrSMQ9MdQHg8NTm2TPbyc1h/WJeepfRPGmW/1Uaxp2eK36S3fdU2lPQVjO/2ZoD+rA2vU503qU7D1z1JLq+mO02+3NcXlGzGVMsx822yh+3I96O2UZcGf/Y/4fyDozzYDx5QyhDb30tYM2/Rl0foV6N+EYea+6ttmueOa1r32n/xf+D8h6BMecc1j1L3DuU1Yx+AYj+LqV6A/LTvOXFc9LNC+LHM5TtHzOYI+oY+E1IY86k9v143u3bSbvi45t6CX3ZM28zLD5htwCLpPaTsxfCDoE5bntduB4T63aWl0np8FXYeG7t+EYeZlhnNBr8eRvCfoE9pISH0o/t0Wuj/dfQi6fQX6N2GYub9/ZgvdjyN5T9BnlPp7JtovSzkE3eY7Bn3/Ciw7zf2c327N54IudjpOEfQZw1XCaXoW4Ost9PKbgx67wetB3nYLvZ72eBe009CnCPqMMq83Ju23qP1A79k+dL8DpKy//Wq7D705j/cmaBdWzhH0GcvIXbfxRqUh6H6970nQ7R69luMwc1/zNJ8KukxG4P1M0Ke0fYS2szD8eBtH/NpMH5csmzUMd2WUcULdb6krHadsV8pLgiaKoIkiaKIImiiCJoqgiSJoogiaKIImiqCv54reDxL05Qyj8ZMEfTlB/yRBH31xl+G/FbQ9m2cEffT+N3Bv76R7OsO0Hyfs78s7sZBfFP6MoI+W8eZevNdH8nqMRnC49/PWBzdYpy4LlPEm0CfBj2+0hV4u8+lj/l6CPnrzbEi7P7+Mw3gNw+0u8zwbJ6yNrTQMC7b+c6/GC3u5zIeP+YsJ+qi8HgWjPrW9PDDbKq0Pura+no4TNjxI1YYF6+t8OV7Y/HKZDx/zFxP0UZnL/GLbtwxgtD6dUqvro5I+5qmjeI1Df/Xuh2HBNit9Nl5YHd7m6TLvP+YvJuij+/7vi8f3NkMmDtWWobf1ccCp7Y4MY4atw4KNK928MS70apn3H/MXE/RRmZedimdvPYY+WkeFntfBFOdD0G38r9swgMcwLNjw7x3GC+sLvVrm/cf8xQR99K6U5XitjZy7BN2LfBJ0O1Cc6j7HMCzYsM5pP17YutCLZT58zN9L0EdvS3ns205tt7gftQ1XU/a7HI9pbSd4WocFG9Y5HcYLa5NfLiPo5wS9VwfO7XX2IQX6aeA+XvM6Tsc0z/ugh93r9UTGZliwdWf86XhhfQXPl9l8TBpB7yyjfa3tzvUCSnvZjwTHoc3rgF/rGtpb7aphO5+8GRasH16+GC9srmfwni2z/Zh0gt5pw9j1n9rxXj8v3Hc55lL3ofuAX8syy2m2uZ+H3s9VNqMs3fZv3MaFXi2z/Zh0gt65R1peB90u4dWjw3ncX27L1N/3M5fNlcJ1rrIeSbZFno4Xtqzg+TLbj0kn6L3tLRPbwbm62/jjbXdZenzjtn/dJm2vfo9vbBd6tcz+zg4Wgr7Cv9wx+i83mbrT+iNBf10p4+7D6aX+YZfBHaMfCfrL+qHhXy72b3dJ//Uyv4ygv2w54aC0/w2C/rL1oI+fJ2iiCJoogiaKoIkiaKIImiiCJoqgiSJoogiaKP8Bsr26xif3IycAAAAASUVORK5CYII=iVBORw0KGgoAAAANSUhEUgAAAtAAAAHgCAMAAAC7G6qeAAADAFBMVEX///++vr4AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACDvjs2AAAABHRSTlP///8AQCqp9AAACOBJREFUeJzt2ttu27gCQFFb/v9/PrF1o2TFczIzwKA7az20iClKKrzD0EpvDwi5/dc3AP8mQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0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NCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2K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ETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF0EQRNFEETRRBE0XQRBE0UQRNFEETRdBEETRRBE0UQRNF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FEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTIujPmL6cXx6HnQYtI+4/8gmC/ozp8Xiu8T4fj2l9tQzc3v42aG/824jfBiv6kwT9Gd+KfcxpTu+X66s96PntIehvtX+zTsZnCPozpsdz3wYv0c6v5pePpe1xC/2c3lvpb8PteHyMoD9j+trcTuurLejpMQ6cxqBf8xb7a4x3279nvXME/Rl7sdNjifbd6l753PYQ9DzdO+htX5tfJejPOBS7BP1V+LLP8W77vXNxDvop6I8S9GfMxU7Di6/95mla9jne2+H3z8eg3wOPB/P4JYL+jKXjr4KXaNe948cW9LIxPgT9dJDjswT9GfMmeA52Cfq5Br1st4f3tqnsa3yaoD9ji3nbCn8dqXt7LkEv3xKHoOfmf+NaBwn6M9aQn+t+8rLHMe171uMXxnmaSc8fJ+jPmPOdD0FP6x/TfuZkDvrr1OFwHHrr2XfCjxH0Z0zLMbn10Nx6eGPO9rUe7Hiup8Vf+yZ8cmLlgwT9GVvQ07QegF4v4xjy3q/zmK9XmjkO/UGC/oxp/+73Oh7NmL/57dvrIeiFoD9I0J8xnxrZ/xxOlRx+PF02Pfg9650jaFIETYqgSRE0KYImRdCkCJoUQZMiaFL++KDXc3vfeeff4X5Vfmr1/jWf5W9WDPp65vlH4y7XfV7GuR34ewyrMpwq/6lL9P6Y6/mSQc+X/CzbtfN91qdxfzXo/2c7/rPTjEFvt8H88Iqm8xL+nMufkkGvt/M9//6g1wdv/NT6/ew0x6DXa05/sNG9LOHPueK6GPRyL/XX/9X90S6nPY/96rdL0NP2jLrn+Vmhd4s6lDKOc9lKbtfZPe/uVJm+P+wQ9HPb5Riu2zut43UJNyM3dYM+3NQ3b6/HKzef66Wex6D3t5b7Xbem1hlM+5B9IdfZ7q+3Gc8z2Kc5znNaS10nO63KMpv9r946j3Ghz+dhrU5rfV2rnm7Qz31fc1p/2q+5327HPge9vTXfIbiNtryctt3R5bbYdYzztNNzeGeYwT7WMGge/Xmc7Lgql6DXNR/m8tzuF7hZwnHkrG7Q42ZqGbBXuz0x4xr0cxt9bmK9q2r9GrYEvc5+3ELfT7uu0vp3a/inYxv0eC5zGyY7zO6yy7Gt+TbF9iG/s4SbtQqKB70OmLet477ma32Q7WkfentrqWR/muK0bey3oE/fHC/TjjcWrqGt0wyDltFfp8nG2Z2/FB6n3G7zmj/k7RJu1qqoG/Rhd/P8RJfpUNchy2ls4PWaH1G3PVn0eQz6dQj6PO0+33XbfHpIx/aXZC34ONl5VV6Hw3bnBQ0f8n4Jl7Uq+kOCfm/Yxg30++H5N0Hvb22VDbe8/jjom2nHLfTyvP4ht3XQKejtKR7n2a1rcNjl2Hfo9w95v4TLWhX9GUEvX5j2Q3WP7wQ9vHUIev0lE/st2teg76bd5rs/s2DLbXiMwV3QN7Mb53gNeviQ90sQ9H/VNej35m77LSj7d7NL0OPXtuMux/vtH22hb6c9zXcactsH3Qd9nd3rx0HvH/J+CYL+r7oJ+jXumu5fmS5Bj9+mDrunl33o5yXoy7SnoF/nEafpuGbHoK+ze/1F0Nss7pdwqf+j/9H/JeJBD6c8piHo/cv+Jej9oMHx9X6UY3u9Bj3dTXvT2biF3ufz3S30dXavYR/6utEdZnW/hPP+SbLoYtDbtRxbbMuWdd/l+PrlJ7dB7299Dfz6YTsOvUwx/87B7YdhIcdpj/+4r1/QxsNpz8cP96Gvs3sN+8U3Qe8f8n4J19n/k/9X/iHJoJcTZtvvUnvt5/neP03zb7y8C3p/a1rOqz3nnY19DuPkr3Ehh2nPQR/PTg7n8R7fC/o6u+2znQ/bDactl1W8W4Kg/6PWf9vXf+K3IdNxhGm7tv/2rWmc6u71dsXFccdm2s/h3Cxzfz0MGuawT3ad3XX9xin+agnTdfZ/y3/+36sYNH8wQZMiaFIETYqgSRE0KYImRdCkCJoUQZPyP4tZub1NYHASAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAtAAAAHgCAMAAAC7G6qeAAADAFBMVEX///++vr4AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACDvjs2AAAABHRSTlP///8AQCqp9AAACJ9JREFUeJzt2u1yqsgCQFEh7//ON8pXg+bk1K2pmprNWj/mBJXGTDbYtj6+IOTxbz8B+CcJmhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUu4c9Pztw9bnH+Zl63zndvMfdxnG/3S88d+fxzkN8ukYH57iLd066Mc0Bjatm/vN2w/z911P0zPd053z6/bXv+tDp8fbLvvwz4fO4/FeW/Nj+Pdy6O9hpmGY6+ZyiGmpeL3n+D3uSdDbxhble1XTs5pvj/egX/UsYc/rY9922Yafnreegp7m8bjPkc6HnrdxltEvm6/T6FX485q83jOvz/+2F+mbBr28PJ+C/u5rzeZa1VLM+np+uXPd5/HY43/bZRt+nk8Xz3kfYTvMe9Cv02XJdZ6vm8spsJ5T+13rQNNdi75l0K8K3oOej7KWm5bt80Thh6DXi/Ea9GmXffhz0K9HPe+Yln1Oz+hy6GXHy+bphLu8FszjbOdWbhj0fEwULtfL6UPQ5+x/DvrYvu5yjL/NsNeb9jnMevm9Bn30v+09bh5Tok9Br5fsf/B/23/E7YIe/tanoKetih+r2m8b5itD0NP66B/elj1vvAy1PI/1XNj/c4w+VnqJdrm4D3OVo99tDn3TpO8W9OlPPwb92C6Q70Fva2Hz8vZvWlyDXkZYgx532Q782N7anS6l24m0rZoMo48FT+egpy3obR6zTMXH9wD7C9G93C7oYXI5BL2/Tv8Y9LYo9thdg562ScVll+0AS9DP92vHc3kuxB1RX0b/PehllWNaht3W847nP8/jsso93C7o/Up2Dnragv4wkZ23926P66TgFPQW3XWX11DLKvN2Dd8Pup5IrxnLcc39MOX4Iei3RerjrcE8bwsot3K3oJe/+9scej4ujHvQ83XKsQW97nEJ+mvv8rLL69HrqfIYL5qvs2h/YzidLq5/O4c+z2qmYzY+j6fujdwu6G1yOQ1xbjOO82re+5T6z0G/Nq6Tlsf4w2s17TzP2ZfsPgf9N6sc42/2eorrzPqOOd8y6K/3dej1ArrOg49P/c6Lwb8G/er1bZfhhzWz/Xk858DLKOM4x+h/sw59+sVeT3G55t/v/eDLLYPeViD2Gevporx/6rZHeHw+98egl+9rzNddvoZBptP3OYZPCddjvY3+2yeFp99neydwmojczE2DftkXyYYX8+XKvX5jYtquh+sXM96CvnxEc4Q57vK13rOsxo1LafP4rYxPQc+/fJdjeA+wHe7yceTt3DzoxbHysK92DN+U27bP8+5t3fgxZr5f1cdd1kG2Mc+T6Mc4h7gG/eu37cZVmv37gIK+q3l03Hbc8fbAr///+9CfDnb+LvR199Nul1HODzof7r7Tjac7B02QoEkRNCmCJkXQpAiaFEGTImhSBE2KoEn5H/VQwYklqsiwAAAAAElFTkSuQmCC - - - -
-
-
-
-
-
-
- -""" + + expected_timings = [ + ("00:00:09.209", "00:00:12.312"), + ("00:00:14.848", "00:00:17.000"), + ("00:00:17.000", "00:00:18.752"), + ("00:00:18.752", "00:00:20.887"), + ("00:00:20.887", "00:00:26.760"), + ("00:00:26.760", "00:00:32.200"), + ("00:00:32.200", "00:00:36.200"), + ] + self._validate_ttml_structure(results, 7, expected_timings) + + def test_simple_srt(self): + """Test basic SRT conversion to TTML with images.""" + 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) + results = self.writer.write(caption_set) + + expected_timings = [ + ("00:00:01.000", "00:00:04.000"), + ("00:00:05.000", "00:00:08.000"), + ] + self._validate_ttml_structure(results, 2, expected_timings) + + def test_image_transparency(self): + """Test that generated images have proper transparency (index 3).""" + srt_content = """1 +00:00:01,000 --> 00:00:04,000 +Test +""" + caption_set = SRTReader().read(srt_content) + results = self.writer.write(caption_set) + + # Extract image + image_pattern = r']*>(.*?)' + match = re.search(image_pattern, results, re.DOTALL) + assert match is not None + + # Decode and verify it's a PNG + decoded = base64.b64decode(match.group(1)) + assert decoded[:8] == b'\x89PNG\r\n\x1a\n' From 4e976ec73a527e3a40c4fb52413108ce2f75b900 Mon Sep 17 00:00:00 2001 From: Sebastian Annies Date: Mon, 12 Jan 2026 17:20:11 +0200 Subject: [PATCH 14/15] use yuva444p in the filtergraph --- pycaption/filtergraph.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pycaption/filtergraph.py b/pycaption/filtergraph.py index 2ff9290e..1de1a7d0 100644 --- a/pycaption/filtergraph.py +++ b/pycaption/filtergraph.py @@ -92,13 +92,13 @@ def write( # Start with transparent base filter_parts = [] filter_parts.append( - f"color=c=black@0:s={self.video_width}x{self.video_height}:d={duration_seconds:.3f},format=yuva420p[base]" + f"color=c=black@0:s={self.video_width}x{self.video_height}:d={duration_seconds:.3f},format=yuva444p[base]" ) # Load each image (paths relative to where ffmpeg is run) for i in range(1, len(caps_final) + 1): filter_parts.append( - f"movie={self.output_dir}/subtitle{i:04d}.png,format=yuva420p[s{i}]" + f"movie={self.output_dir}/subtitle{i:04d}.png,format=yuva444p[s{i}]" ) # Chain overlays From 1640cbc81908120fcb4086b874ca771b9a6e5a4a Mon Sep 17 00:00:00 2001 From: Sebastian Annies Date: Mon, 12 Jan 2026 17:22:34 +0200 Subject: [PATCH 15/15] use yuva444p in the filtergraph --- test.py | 5 +++-- tests/test_filtergraph.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/test.py b/test.py index f05ff39a..b51b922f 100644 --- a/test.py +++ b/test.py @@ -4,6 +4,7 @@ srtReader = SRTReader() -c = srtReader.read(content=open("a.srt", "rb").read().decode('UTF-8-SIG'), lang='zh-Hans') +c = srtReader.read(content=open("cookoff-1080p-h264-tidpix.srt", "rb").read().decode('UTF-8-SIG'), lang='zh-Hans') w = ScenaristDVDWriter() -w.write(c) +open("cookoff-1080p-h264-tidpix.zip", "wb").write(w.write(c)) + diff --git a/tests/test_filtergraph.py b/tests/test_filtergraph.py index fa6f3145..d1fae66e 100644 --- a/tests/test_filtergraph.py +++ b/tests/test_filtergraph.py @@ -44,7 +44,7 @@ def test_filtergraph_structure(self): # Should have color source for transparent base assert 'color=c=black@0:s=1920x1080' in filtergraph - assert 'format=yuva420p' in filtergraph + assert 'format=yuva444p' in filtergraph # Should have movie input for image (path relative to ffmpeg working dir) assert 'movie=embedded_subs/subtitle0001.png' in filtergraph