From 87568989fee47ca84d036bcc7688c9b9dd559c24 Mon Sep 17 00:00:00 2001 From: Javanaut Date: Fri, 22 May 2026 21:04:50 +0200 Subject: [PATCH] fix styled ASS --- src/ffx/cli.py | 6 +- src/ffx/media_descriptor.py | 47 ++++++++++ src/ffx/media_descriptor_change_set.py | 20 ++++- src/ffx/media_workflow_screen_base.py | 21 ++++- src/ffx/model/pattern.py | 3 + src/ffx/pattern_controller.py | 13 ++- src/ffx/track_controller.py | 4 + .../unit/test_media_descriptor_change_set.py | 42 +++++++++ tests/unit/test_pattern_management.py | 30 +++++++ tests/unit/test_tag_table_screen_state.py | 86 ++++++++++++++++++- 10 files changed, 262 insertions(+), 10 deletions(-) diff --git a/src/ffx/cli.py b/src/ffx/cli.py index bd8dcb1..1c339a9 100755 --- a/src/ffx/cli.py +++ b/src/ffx/cli.py @@ -1139,6 +1139,7 @@ def convert(ctx, from ffx.tmdb_controller import TmdbController from ffx.track_codec import TrackCodec from ffx.track_disposition import TrackDisposition + from ffx.track_type import TrackType from ffx.video_encoder import VideoEncoder startTime = time.perf_counter() @@ -1436,7 +1437,8 @@ def convert(ctx, else: targetMediaDescriptor = currentPattern.getMediaDescriptor(ctx.obj) if styledAssSourceDetected: - targetMediaDescriptor = targetMediaDescriptor.withoutAttachmentTracks( + targetMediaDescriptor = targetMediaDescriptor.withSourceAttachmentTracks( + sourceMediaDescriptor, AttachmentFormat.TTF, context=ctx.obj, ) @@ -1449,6 +1451,8 @@ def convert(ctx, targetTrackDescriptorList = targetMediaDescriptor.getTrackDescriptors() for ttd in targetTrackDescriptorList: + if ttd.getType() == TrackType.ATTACHMENT: + continue tti = ttd.getIndex() ttsi = ttd.getSourceIndex() diff --git a/src/ffx/media_descriptor.py b/src/ffx/media_descriptor.py index 5281398..1dde796 100644 --- a/src/ffx/media_descriptor.py +++ b/src/ffx/media_descriptor.py @@ -372,6 +372,53 @@ class MediaDescriptor: filteredMediaDescriptor.reindexSubIndices() return filteredMediaDescriptor + def withoutAttachmentsForComparison(self): + return self.withoutAttachmentTracks(context=self.__context) + + def withSourceAttachmentTracks( + self, + sourceMediaDescriptor: Self, + attachmentFormat: AttachmentFormat | None = None, + context: dict | None = None, + ): + trackDescriptors = [] + for trackDescriptor in self.__trackDescriptors: + if trackDescriptor.getType() == TrackType.ATTACHMENT and ( + attachmentFormat is None + or trackDescriptor.getAttachmentFormat() == attachmentFormat + ): + continue + trackDescriptors.append( + trackDescriptor.clone( + context=context if context is not None else self.__context + ) + ) + + for sourceTrackDescriptor in sourceMediaDescriptor.getAttachmentTracks(): + if ( + attachmentFormat is not None + and sourceTrackDescriptor.getAttachmentFormat() != attachmentFormat + ): + continue + attachmentClone = sourceTrackDescriptor.clone( + context=context if context is not None else self.__context + ) + attachmentClone.setIndex(len(trackDescriptors)) + trackDescriptors.append(attachmentClone) + + kwargs = { + MediaDescriptor.TAGS_KEY: dict(self.__mediaTags), + MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY: trackDescriptors, + } + if context is not None: + kwargs[MediaDescriptor.CONTEXT_KEY] = context + elif self.__context: + kwargs[MediaDescriptor.CONTEXT_KEY] = self.__context + + mergedMediaDescriptor = MediaDescriptor(**kwargs) + mergedMediaDescriptor.reindexSubIndices() + return mergedMediaDescriptor + def getImportFileTokens(self, use_sub_index: bool = True): """Generate ffmpeg import options for external stream files""" diff --git a/src/ffx/media_descriptor_change_set.py b/src/ffx/media_descriptor_change_set.py index 7151b4a..30597bb 100644 --- a/src/ffx/media_descriptor_change_set.py +++ b/src/ffx/media_descriptor_change_set.py @@ -56,8 +56,24 @@ class MediaDescriptorChangeSet(): and 'ignore' in metadataConfiguration['streams'].keys() else []) - self.__targetTrackDescriptors = targetMediaDescriptor.getTrackDescriptors() if targetMediaDescriptor is not None else [] - self.__sourceTrackDescriptors = sourceMediaDescriptor.getTrackDescriptors() if sourceMediaDescriptor is not None else [] + self.__targetTrackDescriptors = ( + [ + trackDescriptor + for trackDescriptor in targetMediaDescriptor.getTrackDescriptors() + if trackDescriptor.getType() != TrackType.ATTACHMENT + ] + if targetMediaDescriptor is not None + else [] + ) + self.__sourceTrackDescriptors = ( + [ + trackDescriptor + for trackDescriptor in sourceMediaDescriptor.getTrackDescriptors() + if trackDescriptor.getType() != TrackType.ATTACHMENT + ] + if sourceMediaDescriptor is not None + else [] + ) self.__targetTrackDescriptorsByIndex = { trackDescriptor.getIndex(): trackDescriptor for trackDescriptor in self.__targetTrackDescriptors diff --git a/src/ffx/media_workflow_screen_base.py b/src/ffx/media_workflow_screen_base.py index 7a2b79e..7968276 100644 --- a/src/ffx/media_workflow_screen_base.py +++ b/src/ffx/media_workflow_screen_base.py @@ -166,10 +166,9 @@ class MediaWorkflowScreenBase(Screen): self._baselineMediaDescriptor = probedMediaDescriptor self._sourceMediaDescriptor = probedMediaDescriptor self._currentPattern = self._mediaFileProperties.getPattern() - self._targetMediaDescriptor = ( - self._currentPattern.getMediaDescriptor(self.context) - if self._currentPattern is not None - else None + self._targetMediaDescriptor = self._resolve_target_media_descriptor( + self._currentPattern, + self._sourceMediaDescriptor, ) self.rebuildChangeSet() @@ -205,6 +204,20 @@ class MediaWorkflowScreenBase(Screen): def getTrackEditSourceDescriptor(self) -> TrackDescriptor | None: return self.getSelectedTrackDescriptor() + def _resolve_target_media_descriptor(self, currentPattern, sourceMediaDescriptor): + if currentPattern is None: + return None + + targetMediaDescriptor = currentPattern.getMediaDescriptor(self.context) + if sourceMediaDescriptor.hasStyledAssSubtitlesWithFontAttachments(): + targetMediaDescriptor = targetMediaDescriptor.withSourceAttachmentTracks( + sourceMediaDescriptor, + AttachmentFormat.TTF, + context=self.context, + ) + + return targetMediaDescriptor + def updateMediaTags(self): displayedMediaDescriptor = self.getDisplayedMediaDescriptor() self._sourceMediaTagRowData = populate_tag_table( diff --git a/src/ffx/model/pattern.py b/src/ffx/model/pattern.py index e3a4986..1aaf771 100644 --- a/src/ffx/model/pattern.py +++ b/src/ffx/model/pattern.py @@ -7,6 +7,7 @@ from .show import Base, Show from ffx.media_descriptor import MediaDescriptor from ffx.show_descriptor import ShowDescriptor +from ffx.track_type import TrackType class Pattern(Base): @@ -76,6 +77,8 @@ class Pattern(Base): subIndexCounter = {} for track in self.tracks: trackType = track.getType() + if trackType == TrackType.ATTACHMENT: + continue if not trackType in subIndexCounter.keys(): subIndexCounter[trackType] = 0 kwargs[MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY].append(track.getDescriptor(context, subIndex = subIndexCounter[trackType])) diff --git a/src/ffx/pattern_controller.py b/src/ffx/pattern_controller.py index 66ae97f..384bea9 100644 --- a/src/ffx/pattern_controller.py +++ b/src/ffx/pattern_controller.py @@ -8,6 +8,7 @@ from ffx.model.track import Track from ffx.model.track_tag import TrackTag from ffx.track_descriptor import TrackDescriptor from ffx.track_disposition import TrackDisposition +from ffx.track_type import TrackType class DuplicatePatternMatchError(click.ClickException): @@ -86,12 +87,16 @@ class PatternController: ) normalized_descriptors = [] + filtered_attachments = False for trackDescriptor in trackDescriptors: if type(trackDescriptor) is not TrackDescriptor: raise TypeError( "PatternController: All track descriptors are required to be of type TrackDescriptor" ) - normalized_descriptors.append(trackDescriptor) + if trackDescriptor.getType() == TrackType.ATTACHMENT: + filtered_attachments = True + continue + normalized_descriptors.append(trackDescriptor.clone()) if not normalized_descriptors: raise InvalidPatternSchemaError( @@ -102,6 +107,10 @@ class PatternController: normalized_descriptors, key=lambda descriptor: descriptor.getIndex() ) + if filtered_attachments: + for index, descriptor in enumerate(normalized_descriptors): + descriptor.setIndex(index) + index_set = {descriptor.getIndex() for descriptor in normalized_descriptors} expected_indexes = set(range(len(normalized_descriptors))) if index_set != expected_indexes: @@ -170,7 +179,7 @@ class PatternController: pattern.tracks.append(self._build_track_row(trackDescriptor)) def _validate_persisted_pattern(self, pattern: Pattern): - if not pattern.tracks: + if not any(track.getType() != TrackType.ATTACHMENT for track in pattern.tracks): raise InvalidPatternSchemaError( f"Pattern #{pattern.getId()} ({pattern.getPattern()!r}) is invalid because it has no tracks." ) diff --git a/src/ffx/track_controller.py b/src/ffx/track_controller.py index 209af40..3af9730 100644 --- a/src/ffx/track_controller.py +++ b/src/ffx/track_controller.py @@ -35,6 +35,8 @@ class TrackController(): def addTrack(self, trackDescriptor : TrackDescriptor, patternId = None): + if trackDescriptor.getType() == TrackType.ATTACHMENT: + return False # option to override pattern id in case track descriptor has not set it patId = int(trackDescriptor.getPatternId() if patternId is None else patternId) @@ -72,6 +74,8 @@ class TrackController(): if type(trackDescriptor) is not TrackDescriptor: raise TypeError('TrackController.updateTrack(): Argument trackDescriptor is required to be of type TrackDescriptor') + if trackDescriptor.getType() == TrackType.ATTACHMENT: + return False try: s = self.Session() diff --git a/tests/unit/test_media_descriptor_change_set.py b/tests/unit/test_media_descriptor_change_set.py index 9e475c1..f9cc7fc 100644 --- a/tests/unit/test_media_descriptor_change_set.py +++ b/tests/unit/test_media_descriptor_change_set.py @@ -13,6 +13,7 @@ if str(SRC_ROOT) not in sys.path: from ffx.media_descriptor import MediaDescriptor # noqa: E402 from ffx.media_descriptor_change_set import MediaDescriptorChangeSet # noqa: E402 +from ffx.attachment_format import AttachmentFormat # noqa: E402 from ffx.track_descriptor import TrackDescriptor # noqa: E402 from ffx.track_type import TrackType # noqa: E402 from ffx.i18n import set_current_language # noqa: E402 @@ -436,6 +437,47 @@ class MediaDescriptorChangeSetTests(unittest.TestCase): self.assertNotIn("creation_time=", metadata_tokens) self.assertNotIn("BPS=", metadata_tokens) + def test_attachment_tracks_are_ignored_for_pattern_comparison(self): + context = { + "logger": get_ffx_logger(), + "config": StaticConfig({}), + } + + source_track = TrackDescriptor( + index=0, + source_index=0, + sub_index=0, + track_type=TrackType.ATTACHMENT, + attachment_format=AttachmentFormat.TTF, + tags={"filename": "current.ttf", "mimetype": "font/ttf"}, + ) + target_track = TrackDescriptor( + index=0, + source_index=0, + sub_index=0, + track_type=TrackType.ATTACHMENT, + attachment_format=AttachmentFormat.TTF, + tags={"filename": "stored.ttf", "mimetype": "font/ttf"}, + ) + stale_target_track = TrackDescriptor( + index=1, + source_index=1, + sub_index=1, + track_type=TrackType.ATTACHMENT, + attachment_format=AttachmentFormat.TTF, + tags={"filename": "missing.ttf", "mimetype": "font/ttf"}, + ) + + change_set = MediaDescriptorChangeSet( + context, + MediaDescriptor(track_descriptors=[target_track, stale_target_track]), + MediaDescriptor(track_descriptors=[source_track]), + ) + + self.assertEqual({}, change_set.getChangeSetObj()) + self.assertEqual([], change_set.generateMetadataTokens()) + self.assertEqual([], change_set.generateDispositionTokens()) + def test_normalization_can_be_disabled_per_context(self): context = { "logger": get_ffx_logger(), diff --git a/tests/unit/test_pattern_management.py b/tests/unit/test_pattern_management.py index eb5ef60..3a1aadd 100644 --- a/tests/unit/test_pattern_management.py +++ b/tests/unit/test_pattern_management.py @@ -193,6 +193,36 @@ class PatternManagementTests(unittest.TestCase): self.assertIn("at least one track", str(caught.exception)) + def test_save_pattern_schema_does_not_persist_attachment_tracks(self): + pattern_id = self.save_pattern( + 1, + r"^noattachments_(s[0-9]+e[0-9]+)\.mkv$", + tracks=[ + make_track_descriptor(0, track_type=TrackType.VIDEO), + make_track_descriptor(1, track_type=TrackType.ATTACHMENT), + ], + ) + + Session = self.context["database"]["session"] + session = Session() + try: + tracks = session.query(Pattern).filter(Pattern.id == pattern_id).first().tracks + self.assertEqual(1, len(tracks)) + self.assertEqual(TrackType.VIDEO, tracks[0].getType()) + finally: + session.close() + + def test_track_controller_does_not_add_attachment_tracks_to_patterns(self): + pattern_id = self.save_pattern(1, r"^skipadd_(s[0-9]+e[0-9]+)\.mkv$") + + added = self.track_controller.addTrack( + make_track_descriptor(1, track_type=TrackType.ATTACHMENT), + patternId=pattern_id, + ) + + self.assertFalse(added) + self.assertEqual(1, len(self.track_controller.findTracks(pattern_id))) + def test_match_filename_rejects_existing_trackless_pattern_rows(self): self.insert_trackless_pattern_row(1, r"^invalid_(s[0-9]+e[0-9]+)\.mkv$") diff --git a/tests/unit/test_tag_table_screen_state.py b/tests/unit/test_tag_table_screen_state.py index 3bf0b71..819a991 100644 --- a/tests/unit/test_tag_table_screen_state.py +++ b/tests/unit/test_tag_table_screen_state.py @@ -15,12 +15,13 @@ if str(SRC_ROOT) not in sys.path: from ffx.audio_layout import AudioLayout # noqa: E402 from ffx.attachment_format import AttachmentFormat # noqa: E402 -from ffx.helper import DIFF_ADDED_KEY # noqa: E402 +from ffx.helper import DIFF_ADDED_KEY, DIFF_REMOVED_KEY # noqa: E402 from ffx.iso_language import IsoLanguage # noqa: E402 from ffx.logging_utils import get_ffx_logger # noqa: E402 from ffx.inspect_details_screen import InspectDetailsScreen # noqa: E402 from ffx.i18n import set_current_language # noqa: E402 from ffx.media_descriptor import MediaDescriptor # noqa: E402 +from ffx.media_descriptor_change_set import MediaDescriptorChangeSet # noqa: E402 from ffx.media_edit_screen import MediaEditScreen # noqa: E402 from ffx.pattern_details_screen import PatternDetailsScreen # noqa: E402 from ffx.show_descriptor import ShowDescriptor # noqa: E402 @@ -822,6 +823,89 @@ class TagTableScreenStateTests(unittest.TestCase): self.assertEqual("unknown", row[3]) self.assertEqual(" ", row[5]) + def test_inspect_details_screen_uses_source_font_attachments_for_styled_ass(self): + class _Config: + def getData(self): + return {} + + class _Pattern: + def __init__(self, media_descriptor): + self._media_descriptor = media_descriptor + + def getMediaDescriptor(self, _context): + return self._media_descriptor + + source_descriptor = MediaDescriptor( + track_descriptors=[ + TrackDescriptor( + index=0, + source_index=0, + sub_index=0, + track_type=TrackType.SUBTITLE, + codec_name=TrackCodec.ASS, + tags={"title": "Styled Subtitle"}, + ), + TrackDescriptor( + index=1, + source_index=1, + sub_index=0, + track_type=TrackType.ATTACHMENT, + attachment_format=AttachmentFormat.TTF, + tags={"filename": "current.ttf", "mimetype": "font/ttf"}, + ), + ] + ) + pattern_descriptor = MediaDescriptor( + track_descriptors=[ + TrackDescriptor( + index=0, + source_index=0, + sub_index=0, + track_type=TrackType.SUBTITLE, + codec_name=TrackCodec.ASS, + tags={"title": "Styled Subtitle"}, + ), + TrackDescriptor( + index=1, + source_index=1, + sub_index=0, + track_type=TrackType.ATTACHMENT, + attachment_format=AttachmentFormat.TTF, + tags={"filename": "old.ttf", "mimetype": "font/ttf"}, + ), + TrackDescriptor( + index=2, + source_index=2, + sub_index=1, + track_type=TrackType.ATTACHMENT, + attachment_format=AttachmentFormat.TTF, + tags={"filename": "missing.ttf", "mimetype": "font/ttf"}, + ), + ] + ) + + screen = object.__new__(InspectDetailsScreen) + screen.context = {"logger": get_ffx_logger(), "config": _Config()} + + resolved_descriptor = screen._resolve_target_media_descriptor( + _Pattern(pattern_descriptor), + source_descriptor, + ) + attachment_tracks = resolved_descriptor.getAttachmentTracks() + + self.assertEqual(1, len(attachment_tracks)) + self.assertEqual({"filename": "current.ttf", "mimetype": "font/ttf"}, attachment_tracks[0].getTags()) + + change_set = MediaDescriptorChangeSet( + screen.context, + resolved_descriptor, + source_descriptor, + ).getChangeSetObj() + self.assertNotIn( + 1, + change_set.get("tracks", {}).get(DIFF_REMOVED_KEY, {}), + ) + def test_inspect_details_screen_maps_target_selection_back_to_source_track(self): source_track = TrackDescriptor( index=3,