diff --git a/.gitignore b/.gitignore index 046e581..c624ec6 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ tools/ansible/inventory/group_vars/all.yml ffx_test_report.log bin/conversiontest.py +tests/assets/ + build/ dist/ *.egg-info/ diff --git a/SCRATCHPAD.md b/SCRATCHPAD.md index eb9465a..667e3e4 100644 --- a/SCRATCHPAD.md +++ b/SCRATCHPAD.md @@ -75,7 +75,9 @@ Detect ffmpeg warning "Timestamps are unset in a packet for stream 0. This is deprecated and will stop working in the future. Fix your code to set the timestamps properly" and try autofix by -fflags +genpts -> Warning if fails -> Error. Check if flags collide with anything. + --map 0:v -c:v copy -bsf:v hevc_mp4toannexb out.h265 diff --git a/docs/file_formats.md b/docs/file_formats.md new file mode 100644 index 0000000..25f827c --- /dev/null +++ b/docs/file_formats.md @@ -0,0 +1,170 @@ +# File Formats + +This document captures source-file-format notes that complement the normative +requirements in `requirements/source_file_formats.md`. + +The first documented format is a Matroska source that carries styled ASS/SSA +subtitle streams together with embedded font attachments. + +## Styled ASS In Matroska With Embedded Fonts + +These files are typically `.mkv` releases where subtitle rendering quality +depends on keeping both parts of the subtitle package together: + +- one or more subtitle streams with codec `ass` +- one or more attachment streams that embed font files used by those subtitles + +This matters because ASS subtitles are not plain text subtitles in the narrow +WebVTT sense. They can carry layout, styling, positioning, karaoke, signs, and +other typesetting effects. If the matching embedded fonts are lost, consumers +can still see subtitle text but the intended styling and sometimes glyph +coverage can be degraded. + +For FFX this format is special because the ASS subtitle streams should remain +normally editable and mappable, while the related font attachments should be +transported unchanged. + +## Observed Sample + +Assessment date: `2026-04-17` + +Observed sample file: + +- `tests/assets/boruto_s01e283_ssa.mkv` + +Commands used for assessment: + +```bash +ffprobe tests/assets/boruto_s01e283_ssa.mkv +ffprobe -hide_banner -show_format -show_streams -of json tests/assets/boruto_s01e283_ssa.mkv +``` + +Observed stream layout: + +| Stream index | Kind | Key details | +| --- | --- | --- | +| `0` | video | `codec_name=h264` | +| `1` | audio | `codec_name=aac`, `language=jpn` | +| `2` | subtitle | `codec_name=ass`, `language=ger`, default | +| `3` | subtitle | `codec_name=ass`, `language=eng` | +| `4`-`13` | attachment | `tags.mimetype=font/ttf`, `.ttf` filenames | + +Observed attachment filenames: + +- `AmazonEmberTanuki-Italic.ttf` +- `AmazonEmberTanuki-Regular.ttf` +- `Arial.ttf` +- `Arial Bold.ttf` +- `Georgia.ttf` +- `Times New Roman.ttf` +- `Times New Roman Bold.ttf` +- `Trebuchet MS.ttf` +- `Verdana.ttf` +- `Verdana Bold.ttf` + +Important probe behavior from the real sample: + +- Plain `ffprobe` lists the font streams as `Attachment: none`. +- Plain `ffprobe` also prints warnings such as `Could not find codec + parameters for stream 4 (Attachment: none): unknown codec` and later + `Unsupported codec with id 0 for input stream ...`. +- The JSON produced by `FileProperties.FFPROBE_COMMAND_TOKENS` + (`ffprobe -hide_banner -show_format -show_streams -of json`) still exposes + the attachment streams clearly through `codec_type="attachment"` and the + attachment tags. +- In that JSON, the attachment streams do not expose `codec_name`. + +This last point is important for FFX: robust detection must not depend on +attachment `codec_name` being present. + +## Detection Guidance + +Current known indicators for this format are: + +- one or more subtitle streams with `codec_type="subtitle"` and + `codec_name="ass"` +- one or more attachment streams with `codec_type="attachment"` +- attachment tags that identify embedded fonts, especially + `tags.mimetype="font/ttf"` +- attachment filenames that end in `.ttf` + +The pattern can vary. FFX should therefore treat the above as a cluster of +signals rather than an exact signature tied to one file. + +Inference from the observed sample plus FFmpeg documentation: + +- MIME matching should not be limited to `font/ttf` alone. +- The Boruto sample uses `font/ttf`. +- FFmpeg's Matroska attachment example uses + `mimetype=application/x-truetype-font` for a `.ttf` attachment. +- Detection should therefore normalize multiple TTF-like MIME values rather + than depend on a single exact string. + +## Processing Expectations In FFX + +The format-specific requirements live in +`requirements/source_file_formats.md`. In practical terms, FFX should: + +- recognize the ASS-plus-font-attachment pattern even when attachment probe + data is incomplete +- tell the operator that the pattern was detected and that special handling is + being used +- reject sidecar subtitle import for such sources, because converting or + replacing these subtitle tracks with ordinary external text subtitles would + break the intended subtitle package +- continue to allow normal manipulation of the ASS subtitle tracks themselves +- preserve the font attachment streams unchanged + +## FFmpeg Notes + +Relevant FFmpeg documentation confirms several behaviors that line up with +FFX's needs: + +- FFmpeg documents `-attach` as adding an attachment stream to the output, and + explicitly names Matroska fonts used in subtitle rendering as an example. +- FFmpeg documents attachment streams as regular streams that are created after + the mapped media streams. +- FFmpeg documents `-dump_attachment` for extracting attachment streams, which + is useful for debugging or validating a source file's embedded fonts. +- FFmpeg's Matroska example requires a `mimetype` metadata tag for attached + fonts, which is consistent with using attachment tags as detection signals. +- FFmpeg also notes that attachments are implemented as codec extradata. That + helps explain why probe output for attachment streams can look different from + ordinary audio, video, and subtitle streams. + +Implication for FFX: + +- Attachment preservation is not an optional cosmetic feature for this format. + It is part of preserving the subtitle package correctly. + +## Jellyfin Notes + +Jellyfin's documentation also supports keeping this format intact: + +- Jellyfin's subtitle compatibility table lists `ASS/SSA` as supported in + `MKV` and not supported in `MP4`. +- Jellyfin notes that when subtitles must be transcoded, they are either + converted to a supported format or burned into the video, and burning them in + is the most CPU-intensive path. +- Jellyfin's subtitle-extraction example for `SSA/ASS` first dumps attachment + streams and then extracts the ASS subtitle stream, which reflects the real + relationship between ASS subtitles and embedded fonts in MKV releases. +- Jellyfin's font documentation says text-based subtitles require fonts to + render properly. +- Jellyfin's configuration documentation says the web client uses configured + fallback fonts for ASS subtitles when other fonts such as MKV attachments or + client-side fonts are not available. + +Inference from the Jellyfin compatibility tables: + +- Keeping this subtitle format in Matroska is the safest interoperability + choice for Jellyfin consumers. +- Converting the subtitle payload to WebVTT would lose styled ASS behavior. +- Dropping the attachment streams would force client or fallback font + substitution and can change appearance or glyph coverage. + +## References + +- FFmpeg documentation: https://ffmpeg.org/ffmpeg.html +- Jellyfin codec support: https://jellyfin.org/docs/general/clients/codec-support/ +- Jellyfin configuration and fonts: https://jellyfin.org/docs/general/administration/configuration/ diff --git a/requirements/source_file_formats.md b/requirements/source_file_formats.md new file mode 100644 index 0000000..d0c3ec8 --- /dev/null +++ b/requirements/source_file_formats.md @@ -0,0 +1,90 @@ +# Source File Formats + +This file defines source-file-format-specific processing requirements for FFX. +It is intended to grow as additional relevant source file types are identified. + +The first covered format is Matroska media that contains styled ASS/SSA +subtitle streams together with embedded font attachments. + +## Scope + +- Detecting source files that use ASS subtitle streams together with embedded + font attachments needed for correct rendering. +- Defining the required `ffx convert` behavior when this format is present. +- Preserving the required attachment streams during conversion. +- Keeping normal subtitle-track manipulation behavior for the ASS subtitle + tracks themselves. + +## Out Of Scope + +- General subtitle behavior for sources that do not carry this pattern. +- A complete catalog of all source file formats FFX may support later. + +## Terms + +- `styled ASS source`: a source media file that contains one or more subtitle + streams with `codec_type="subtitle"` and `codec_name="ass"` together with + one or more font-bearing attachment streams. +- `font attachment`: an attachment stream whose metadata identifies a font + payload, commonly through `tags.mimetype` and attachment filename metadata. +- `external subtitle feed`: subtitle tracks supplied from separate subtitle + files through the existing subtitle-import path. +- `special attachment subtracks`: the embedded font attachment streams that + belong to the styled ASS source pattern. + +## Rules + +- `SOURCE_FILE_FORMATS-0001`: The system shall recognize the styled ASS source + pattern. +- `SOURCE_FILE_FORMATS-0002`: Recognition shall not depend on fixed stream + counts, fixed stream indices, or one exact attachment count. +- `SOURCE_FILE_FORMATS-0003`: Recognition shall use the best available ffprobe + signals. For known subtitle streams this includes + `codec_type="subtitle"` together with `codec_name="ass"`. +- `SOURCE_FILE_FORMATS-0004`: Recognition of the special attachment subtracks + shall use attachment-oriented signals such as `codec_type="attachment"` and + font-identifying metadata such as `tags.mimetype="font/ttf"` when present. +- `SOURCE_FILE_FORMATS-0005`: Recognition shall tolerate known ffprobe + variation in attachment reporting, including files where attachment streams + do not expose a `codec_name` but do expose `codec_type="attachment"` and + font-identifying tags. +- `SOURCE_FILE_FORMATS-0006`: When attachment metadata varies across files, + detection shall not depend on one exact MIME string alone. Detection shall + be written so the known pattern can vary while still recognizing font + attachments. +- `SOURCE_FILE_FORMATS-0007`: When the styled ASS source pattern is detected, + `ffx convert` shall emit an operator-facing message that reports the + detection and hints that special subtitle preservation handling is being + applied. +- `SOURCE_FILE_FORMATS-0008`: When the styled ASS source pattern is present on + the source file, `ffx convert` shall not process an external subtitle feed. + The command shall stop before conversion and report an error that explains + that separate subtitle-file import is incompatible with this source format. +- `SOURCE_FILE_FORMATS-0009`: Normal manipulation of the ASS subtitle streams + themselves shall continue to work through the usual selection, ordering, + metadata, language, title, and disposition handling paths. +- `SOURCE_FILE_FORMATS-0010`: The special attachment subtracks shall be + preserved in the target media file as-is rather than transcoded, + regenerated, or replaced from external sources. +- `SOURCE_FILE_FORMATS-0011`: Preserving the special attachment subtracks + as-is includes retaining the attachment payload and the attachment metadata + required by consumers, especially attachment filename and mimetype + information. +- `SOURCE_FILE_FORMATS-0012`: This file shall remain the extension point for + additional source-file-format contracts as FFX adds support for more special + source formats. + +## Acceptance + +- A source file matching the observed pattern of embedded ASS subtitles plus + font attachments is recognized even when the attachment streams do not carry + a `codec_name`. +- `ffx convert` output contains a clear detection message before the actual + conversion work proceeds. +- If external subtitle import is requested for such a source file, the command + fails fast with an explicit error instead of mixing sidecar subtitles into + the job. +- Existing manipulation of the ASS subtitle tracks still works for metadata, + titles, languages, ordering, and dispositions. +- The output media preserves the required font attachment streams and their + identifying metadata needed by downstream media players. diff --git a/src/ffx/attachment_format.py b/src/ffx/attachment_format.py new file mode 100644 index 0000000..2c1a95f --- /dev/null +++ b/src/ffx/attachment_format.py @@ -0,0 +1,67 @@ +from enum import Enum +import os + + +class AttachmentFormat(Enum): + + TTF = {'identifier': 'ttf', 'format': None, 'extension': 'ttf', 'label': 'TTF'} + PNG = {'identifier': 'png', 'format': None, 'extension': 'png', 'label': 'PNG'} + + UNKNOWN = {'identifier': 'unknown', 'format': None, 'extension': None, 'label': 'UNKNOWN'} + + def identifier(self): + return str(self.value['identifier']) + + def label(self): + return str(self.value['label']) + + def format(self): + return self.value['format'] + + def extension(self): + return str(self.value['extension']) + + @staticmethod + def identify(identifier: str): + formats = [f for f in AttachmentFormat if f.value['identifier'] == str(identifier)] + if formats: + return formats[0] + return AttachmentFormat.UNKNOWN + + @staticmethod + def identifyFfprobeStream(streamObj: dict): + identifier = streamObj.get("codec_name") + identifiedFormat = AttachmentFormat.identify(identifier) + if identifiedFormat != AttachmentFormat.UNKNOWN: + return identifiedFormat + + if str(streamObj.get("codec_type", "")).strip() != "attachment": + return AttachmentFormat.UNKNOWN + + tags = streamObj.get("tags", {}) or {} + mimetype = str(tags.get("mimetype", "")).strip().lower() + filename = str(tags.get("filename", "")).strip().lower() + filenameExtension = os.path.splitext(filename)[1] + + if ( + mimetype in { + "font/ttf", + "application/x-truetype-font", + "application/x-font-ttf", + } + or "truetype" in mimetype + or filenameExtension == ".ttf" + ): + return AttachmentFormat.TTF + + if mimetype in {"image/png", "image/x-png"} or filenameExtension == ".png": + return AttachmentFormat.PNG + + return AttachmentFormat.UNKNOWN + + @staticmethod + def fromTrackCodec(trackCodec): + identifier = getattr(trackCodec, "identifier", None) + if callable(identifier): + return AttachmentFormat.identify(trackCodec.identifier()) + return AttachmentFormat.UNKNOWN diff --git a/src/ffx/cli.py b/src/ffx/cli.py index 7f4d9ca..ee764a7 100755 --- a/src/ffx/cli.py +++ b/src/ffx/cli.py @@ -639,6 +639,7 @@ def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix, trackType = trackDescriptor.getType() trackCodec = trackDescriptor.getCodec() + trackFormat = trackDescriptor.getFormatDescriptor() targetPathBase = os.path.join(targetDirectory, targetPrefix) if targetDirectory else targetPrefix @@ -651,12 +652,12 @@ def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix, commandTokens += ['-c', 'copy'] # output format - codecFormat = trackCodec.format() + codecFormat = trackFormat.format() if codecFormat is not None: commandTokens += ['-f', codecFormat] # output filename - commandTokens += [f"{targetPathBase}.{trackCodec.extension()}"] + commandTokens += [f"{targetPathBase}.{trackFormat.extension()}"] return commandTokens @@ -771,7 +772,7 @@ def unmux(ctx, if not ctx.obj['dry_run']: #TODO #425: Codec Enum - ctx.obj['logger'].info(f"Unmuxing stream {trackDescriptor.getIndex()} into file {targetPrefix}.{trackDescriptor.getCodec().extension()}") + ctx.obj['logger'].info(f"Unmuxing stream {trackDescriptor.getIndex()} into file {targetPrefix}.{trackDescriptor.getFormatDescriptor().extension()}") ctx.obj['logger'].debug(f"Executing unmuxing sequence") @@ -1313,10 +1314,12 @@ def convert(ctx, sourceMediaDescriptor = mediaFileProperties.getMediaDescriptor() + from ffx.attachment_format import AttachmentFormat + if ([smd for smd in sourceMediaDescriptor.getSubtitleTracks() if smd.getCodec() == TrackCodec.ASS] and [amd for amd in sourceMediaDescriptor.getAttachmentTracks() - if amd.getCodec() == TrackCodec.TTF]): + if amd.getAttachmentFormat() == AttachmentFormat.TTF]): targetFormat = '' targetExtension = 'mkv' diff --git a/src/ffx/media_descriptor.py b/src/ffx/media_descriptor.py index a09ba1f..ad9cf62 100644 --- a/src/ffx/media_descriptor.py +++ b/src/ffx/media_descriptor.py @@ -2,6 +2,7 @@ import os, re, click from typing import List, Self +from ffx.attachment_format import AttachmentFormat from ffx.track_type import TrackType from ffx.iso_language import IsoLanguage @@ -421,11 +422,11 @@ class MediaDescriptor: if sourceMediaDescriptor: fontDescriptors = [ftd for ftd in sourceMediaDescriptor.getAttachmentTracks() - if ftd.getCodec() == TrackCodec.TTF] + if ftd.getAttachmentFormat() == AttachmentFormat.TTF] else: fontDescriptors = [ftd for ftd in self.__trackDescriptors if ftd.getType() == TrackType.ATTACHMENT - and ftd.getCodec() == TrackCodec.TTF] + and ftd.getAttachmentFormat() == AttachmentFormat.TTF] for ad in sorted(fontDescriptors, key=lambda d: d.getIndex()): inputMappingTokens += ["-map", f"0:{ad.getIndex()}"] diff --git a/src/ffx/media_workflow_screen_base.py b/src/ffx/media_workflow_screen_base.py index 755f228..fb2efcd 100644 --- a/src/ffx/media_workflow_screen_base.py +++ b/src/ffx/media_workflow_screen_base.py @@ -221,7 +221,7 @@ class MediaWorkflowScreenBase(Screen): trackDescriptor.getIndex(), t(trackType.label()), typeCounter[trackType], - trackDescriptor.getCodec().label(), + trackDescriptor.getFormatDescriptor().label(), t(audioLayout.label()) if trackType == TrackType.AUDIO and audioLayout != AudioLayout.LAYOUT_UNDEFINED diff --git a/src/ffx/model/track.py b/src/ffx/model/track.py index 485e7b1..b4b195f 100644 --- a/src/ffx/model/track.py +++ b/src/ffx/model/track.py @@ -4,6 +4,7 @@ from sqlalchemy.orm import relationship, declarative_base, sessionmaker from .show import Base +from ffx.attachment_format import AttachmentFormat from ffx.track_type import TrackType from ffx.iso_language import IsoLanguage @@ -132,9 +133,16 @@ class Track(Base): if trackType in [t.label() for t in TrackType]: + if trackType == TrackType.ATTACHMENT.label(): + storedFormatIdentifier = AttachmentFormat.identifyFfprobeStream(streamObj).identifier() + else: + storedFormatIdentifier = TrackCodec.identify( + streamObj.get(TrackDescriptor.FFPROBE_CODEC_KEY) + ).identifier() + return cls(pattern_id = patternId, track_type = trackType, - codec_name = streamObj[TrackDescriptor.FFPROBE_CODEC_NAME_KEY], + codec_name = storedFormatIdentifier, disposition_flags = sum([2**t.index() for (k,v) in streamObj[TrackDescriptor.FFPROBE_DISPOSITION_KEY].items() if v and (t := TrackDisposition.find(k)) is not None]), audio_layout = AudioLayout.identify(streamObj)) @@ -153,8 +161,20 @@ class Track(Base): return TrackType.fromIndex(self.track_type) def getCodec(self) -> TrackCodec: + if self.getType() == TrackType.ATTACHMENT: + return TrackCodec.UNKNOWN return TrackCodec.identify(self.codec_name) + def getAttachmentFormat(self) -> AttachmentFormat: + if self.getType() != TrackType.ATTACHMENT: + return AttachmentFormat.UNKNOWN + return AttachmentFormat.identify(self.codec_name) + + def getFormatDescriptor(self): + if self.getType() == TrackType.ATTACHMENT: + return self.getAttachmentFormat() + return self.getCodec() + def getIndex(self): return int(self.index) if self.index is not None else -1 @@ -206,7 +226,10 @@ class Track(Base): kwargs[TrackDescriptor.SUB_INDEX_KEY] = subIndex kwargs[TrackDescriptor.TRACK_TYPE_KEY] = self.getType() - kwargs[TrackDescriptor.CODEC_KEY] = self.getCodec() + if self.getType() == TrackType.ATTACHMENT: + kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = self.getAttachmentFormat() + else: + kwargs[TrackDescriptor.CODEC_KEY] = self.getCodec() kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = self.getDispositionSet() kwargs[TrackDescriptor.TAGS_KEY] = self.getTags() diff --git a/src/ffx/pattern_controller.py b/src/ffx/pattern_controller.py index b0886ee..66ae97f 100644 --- a/src/ffx/pattern_controller.py +++ b/src/ffx/pattern_controller.py @@ -134,7 +134,7 @@ class PatternController: def _build_track_row(self, trackDescriptor: TrackDescriptor) -> Track: track = Track( track_type=int(trackDescriptor.getType().index()), - codec_name=str(trackDescriptor.getCodec().identifier()), + codec_name=str(trackDescriptor.getFormatDescriptor().identifier()), index=int(trackDescriptor.getIndex()), source_index=int(trackDescriptor.getSourceIndex()), disposition_flags=int( diff --git a/src/ffx/pattern_details_screen.py b/src/ffx/pattern_details_screen.py index 55955d9..ffd541a 100644 --- a/src/ffx/pattern_details_screen.py +++ b/src/ffx/pattern_details_screen.py @@ -175,7 +175,7 @@ class PatternDetailsScreen(Screen): row = (td.getIndex(), t(trackType.label()), typeCounter[trackType], - td.getCodec().label(), + td.getFormatDescriptor().label(), t(audioLayout.label()) if trackType == TrackType.AUDIO and audioLayout != AudioLayout.LAYOUT_UNDEFINED else ' ', trackLanguage.label() if trackLanguage != IsoLanguage.UNDEFINED else ' ', diff --git a/src/ffx/track_codec.py b/src/ffx/track_codec.py index 386f4a1..5bdb9b3 100644 --- a/src/ffx/track_codec.py +++ b/src/ffx/track_codec.py @@ -19,7 +19,6 @@ class TrackCodec(Enum): WEBVTT = {'identifier': 'webvtt', 'format': 'webvtt', 'extension': 'vtt' , 'label': 'WebVTT'} SRT = {'identifier': 'subrip', 'format': 'srt', 'extension': 'srt' , 'label': 'SRT'} ASS = {'identifier': 'ass', 'format': 'ass', 'extension': 'ass' , 'label': 'ASS'} - TTF = {'identifier': 'ttf', 'format': None, 'extension': 'ttf' , 'label': 'TTF'} PGS = {'identifier': 'hdmv_pgs_subtitle', 'format': 'sup', 'extension': 'sup' , 'label': 'PGS'} VOBSUB = {'identifier': 'dvd_subtitle', 'format': None, 'extension': 'mkv' , 'label': 'VobSub'} diff --git a/src/ffx/track_controller.py b/src/ffx/track_controller.py index 3288dd8..209af40 100644 --- a/src/ffx/track_controller.py +++ b/src/ffx/track_controller.py @@ -43,7 +43,7 @@ class TrackController(): s = self.Session() track = Track(pattern_id = patId, track_type = int(trackDescriptor.getType().index()), - codec_name = str(trackDescriptor.getCodec().identifier()), + codec_name = str(trackDescriptor.getFormatDescriptor().identifier()), index = int(trackDescriptor.getIndex()), source_index = int(trackDescriptor.getSourceIndex()), disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet())), @@ -82,7 +82,7 @@ class TrackController(): track.index = int(trackDescriptor.getIndex()) track.track_type = int(trackDescriptor.getType().index()) - track.codec_name = str(trackDescriptor.getCodec().identifier()) + track.codec_name = str(trackDescriptor.getFormatDescriptor().identifier()) track.audio_layout = int(trackDescriptor.getAudioLayout().index()) track.disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet())) diff --git a/src/ffx/track_descriptor.py b/src/ffx/track_descriptor.py index d29d85e..7dd5f3b 100644 --- a/src/ffx/track_descriptor.py +++ b/src/ffx/track_descriptor.py @@ -1,5 +1,6 @@ from typing import Self +from .attachment_format import AttachmentFormat from .iso_language import IsoLanguage from .track_type import TrackType from .audio_layout import AudioLayout @@ -26,6 +27,7 @@ class TrackDescriptor: TRACK_TYPE_KEY = "track_type" CODEC_KEY = "codec_name" + ATTACHMENT_FORMAT_KEY = "attachment_format" AUDIO_LAYOUT_KEY = "audio_layout" FFPROBE_INDEX_KEY = "index" @@ -110,15 +112,6 @@ class TrackDescriptor: else: self.__trackType = TrackType.UNKNOWN - if TrackDescriptor.CODEC_KEY in kwargs.keys(): - if type(kwargs[TrackDescriptor.CODEC_KEY]) is not TrackCodec: - raise TypeError( - f"TrackDesciptor.__init__(): Argument {TrackDescriptor.CODEC_KEY} is required to be of type TrackCodec" - ) - self.__trackCodec = kwargs[TrackDescriptor.CODEC_KEY] - else: - self.__trackCodec = TrackCodec.UNKNOWN - if TrackDescriptor.TAGS_KEY in kwargs.keys(): if type(kwargs[TrackDescriptor.TAGS_KEY]) is not dict: raise TypeError( @@ -151,6 +144,34 @@ class TrackDescriptor: else: self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED + self.__trackCodec = TrackCodec.UNKNOWN + self.__attachmentFormat = AttachmentFormat.UNKNOWN + + if self.__trackType == TrackType.ATTACHMENT: + if TrackDescriptor.ATTACHMENT_FORMAT_KEY in kwargs.keys(): + if type(kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY]) is not AttachmentFormat: + raise TypeError( + f"TrackDesciptor.__init__(): Argument {TrackDescriptor.ATTACHMENT_FORMAT_KEY} is required to be of type AttachmentFormat" + ) + self.__attachmentFormat = kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] + elif TrackDescriptor.CODEC_KEY in kwargs.keys(): + legacyCodec = kwargs[TrackDescriptor.CODEC_KEY] + if type(legacyCodec) is AttachmentFormat: + self.__attachmentFormat = legacyCodec + elif type(legacyCodec) is TrackCodec: + self.__attachmentFormat = AttachmentFormat.fromTrackCodec(legacyCodec) + else: + raise TypeError( + f"TrackDesciptor.__init__(): Argument {TrackDescriptor.CODEC_KEY} is required to be of type TrackCodec for legacy attachment compatibility" + ) + else: + if TrackDescriptor.CODEC_KEY in kwargs.keys(): + if type(kwargs[TrackDescriptor.CODEC_KEY]) is not TrackCodec: + raise TypeError( + f"TrackDesciptor.__init__(): Argument {TrackDescriptor.CODEC_KEY} is required to be of type TrackCodec" + ) + self.__trackCodec = kwargs[TrackDescriptor.CODEC_KEY] + @classmethod def fromFfprobe(cls, streamObj, subIndex: int = -1): """Processes ffprobe stream data as array with elements according to the following example @@ -215,7 +236,12 @@ class TrackDescriptor: kwargs[TrackDescriptor.TRACK_TYPE_KEY] = trackType - kwargs[TrackDescriptor.CODEC_KEY] = TrackCodec.identify(streamObj[TrackDescriptor.FFPROBE_CODEC_KEY]) + if trackType == TrackType.ATTACHMENT: + kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = AttachmentFormat.identifyFfprobeStream(streamObj) + else: + kwargs[TrackDescriptor.CODEC_KEY] = TrackCodec.identify( + streamObj.get(TrackDescriptor.FFPROBE_CODEC_KEY) + ) kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = ( { @@ -277,6 +303,14 @@ class TrackDescriptor: def getCodec(self) -> TrackCodec: return self.__trackCodec + def getAttachmentFormat(self) -> AttachmentFormat: + return self.__attachmentFormat + + def getFormatDescriptor(self): + if self.__trackType == TrackType.ATTACHMENT: + return self.__attachmentFormat + return self.__trackCodec + def getLanguage(self): if "language" in self.__trackTags.keys(): return IsoLanguage.findThreeLetter(self.__trackTags["language"]) @@ -353,12 +387,16 @@ class TrackDescriptor: TrackDescriptor.SOURCE_INDEX_KEY: int(self.__sourceIndex), TrackDescriptor.SUB_INDEX_KEY: int(self.__subIndex), TrackDescriptor.TRACK_TYPE_KEY: self.__trackType, - TrackDescriptor.CODEC_KEY: self.__trackCodec, TrackDescriptor.TAGS_KEY: dict(self.__trackTags), TrackDescriptor.DISPOSITION_SET_KEY: set(self.__dispositionSet), TrackDescriptor.AUDIO_LAYOUT_KEY: self.__audioLayout, } + if self.__trackType == TrackType.ATTACHMENT: + kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = self.__attachmentFormat + else: + kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec + if context is not None: kwargs[TrackDescriptor.CONTEXT_KEY] = context elif self.__context: diff --git a/src/ffx/track_details_screen.py b/src/ffx/track_details_screen.py index cce5d25..c68e3fc 100644 --- a/src/ffx/track_details_screen.py +++ b/src/ffx/track_details_screen.py @@ -5,6 +5,7 @@ from textual.widgets import Header, Footer, Static, Button, SelectionList, Selec from textual.containers import Grid from textual.widgets._data_table import CellDoesNotExist +from .attachment_format import AttachmentFormat from .audio_layout import AudioLayout from .iso_language import IsoLanguage from .tag_delete_screen import TagDeleteScreen @@ -141,6 +142,7 @@ class TrackDetailsScreen(Screen): if self.__isNew: self.__trackType = trackType self.__trackCodec = TrackCodec.UNKNOWN + self.__attachmentFormat = AttachmentFormat.UNKNOWN self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED self.__index = index self.__subIndex = subIndex @@ -150,6 +152,7 @@ class TrackDetailsScreen(Screen): else: self.__trackType = trackDescriptor.getType() self.__trackCodec = trackDescriptor.getCodec() + self.__attachmentFormat = trackDescriptor.getAttachmentFormat() self.__audioLayout = trackDescriptor.getAudioLayout() self.__index = trackDescriptor.getIndex() self.__subIndex = trackDescriptor.getSubIndex() @@ -433,7 +436,10 @@ class TrackDetailsScreen(Screen): if not isinstance(selectedTrackType, TrackType): selectedTrackType = TrackType.UNKNOWN kwargs[TrackDescriptor.TRACK_TYPE_KEY] = selectedTrackType - kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec + if selectedTrackType == TrackType.ATTACHMENT: + kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = self.__attachmentFormat + else: + kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec if selectedTrackType == TrackType.AUDIO: selectedAudioLayout = self.query_one("#audio_layout_select", Select).value diff --git a/tests/unit/test_tag_table_screen_state.py b/tests/unit/test_tag_table_screen_state.py index 16a72fa..d00ab3e 100644 --- a/tests/unit/test_tag_table_screen_state.py +++ b/tests/unit/test_tag_table_screen_state.py @@ -14,6 +14,7 @@ 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.iso_language import IsoLanguage # noqa: E402 from ffx.logging_utils import get_ffx_logger # noqa: E402 @@ -200,6 +201,32 @@ class TagTableScreenStateTests(unittest.TestCase): self.assertEqual("German Audio", descriptor.getTitle()) self.assertEqual("value", descriptor.getTags()["KEEP"]) + def test_track_details_screen_preserves_attachment_format_for_attachment_tracks(self): + screen = object.__new__(TrackDetailsScreen) + screen.context = {"logger": get_ffx_logger()} + screen._TrackDetailsScreen__trackDescriptor = None + screen._TrackDetailsScreen__patternId = 5 + screen._TrackDetailsScreen__index = 4 + screen._TrackDetailsScreen__subIndex = 0 + screen._TrackDetailsScreen__trackCodec = TrackCodec.UNKNOWN + screen._TrackDetailsScreen__attachmentFormat = AttachmentFormat.TTF + screen._TrackDetailsScreen__draftTrackTags = {"filename": "font.ttf", "mimetype": "font/ttf"} + + widgets = { + "#type_select": FakeValueWidget(TrackType.ATTACHMENT), + "#audio_layout_select": FakeValueWidget(AudioLayout.LAYOUT_UNDEFINED), + "#language_select": FakeValueWidget(Select.NULL), + "#title_input": FakeInputWidget(""), + "#dispositions_selection_list": FakeSelectionListWidget(set()), + } + screen.query_one = lambda selector, _widget_type=None: widgets[selector] + + descriptor = screen.getTrackDescriptorFromInput() + + self.assertEqual(TrackType.ATTACHMENT, descriptor.getType()) + self.assertEqual(AttachmentFormat.TTF, descriptor.getAttachmentFormat()) + self.assertEqual(TrackCodec.UNKNOWN, descriptor.getCodec()) + def test_track_details_screen_auto_sets_localized_title_from_selected_language(self): set_current_language("de") screen = object.__new__(TrackDetailsScreen) diff --git a/tests/unit/test_track_descriptor_probe.py b/tests/unit/test_track_descriptor_probe.py new file mode 100644 index 0000000..25abfad --- /dev/null +++ b/tests/unit/test_track_descriptor_probe.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import json +from pathlib import Path +import sys +import unittest + + +SRC_ROOT = Path(__file__).resolve().parents[2] / "src" + +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + + +from ffx.attachment_format import AttachmentFormat # noqa: E402 +from ffx.media_descriptor import MediaDescriptor # noqa: E402 +from ffx.track_codec import TrackCodec # noqa: E402 +from ffx.track_descriptor import TrackDescriptor # noqa: E402 +from ffx.track_type import TrackType # noqa: E402 + + +ASSETS_ROOT = Path(__file__).resolve().parents[1] / "assets" + + +class TrackDescriptorProbeTests(unittest.TestCase): + def test_attachment_without_codec_name_uses_font_metadata_to_identify_ttf(self): + descriptor = TrackDescriptor.fromFfprobe( + { + "index": 4, + "codec_type": "attachment", + "disposition": {"default": 0}, + "tags": { + "filename": "AmazonEmberTanuki-Italic.ttf", + "mimetype": "font/ttf", + }, + }, + subIndex=0, + ) + + self.assertIsNotNone(descriptor) + self.assertEqual(TrackType.ATTACHMENT, descriptor.getType()) + self.assertEqual(AttachmentFormat.TTF, descriptor.getAttachmentFormat()) + self.assertEqual(AttachmentFormat.TTF, descriptor.getFormatDescriptor()) + self.assertEqual(TrackCodec.UNKNOWN, descriptor.getCodec()) + + def test_attachment_without_codec_name_still_probes_as_unknown_when_not_font(self): + descriptor = TrackDescriptor.fromFfprobe( + { + "index": 9, + "codec_type": "attachment", + "disposition": {"default": 0}, + "tags": { + "filename": "cover.bin", + "mimetype": "application/octet-stream", + }, + }, + subIndex=0, + ) + + self.assertIsNotNone(descriptor) + self.assertEqual(TrackType.ATTACHMENT, descriptor.getType()) + self.assertEqual(AttachmentFormat.UNKNOWN, descriptor.getAttachmentFormat()) + self.assertEqual(TrackCodec.UNKNOWN, descriptor.getCodec()) + + def test_media_descriptor_from_boruto_probe_json_handles_attachment_streams_without_codec_name(self): + probe_payload = json.loads( + (ASSETS_ROOT / "ffprobe.out.json").read_text(encoding="utf-8") + ) + + descriptor = MediaDescriptor.fromFfprobe( + {"logger": None}, + probe_payload["format"], + probe_payload["streams"], + ) + + track_descriptors = descriptor.getTrackDescriptors() + attachment_tracks = descriptor.getAttachmentTracks() + + self.assertEqual(14, len(track_descriptors)) + self.assertEqual(10, len(attachment_tracks)) + self.assertTrue( + all(track.getAttachmentFormat() == AttachmentFormat.TTF for track in attachment_tracks) + ) + + +if __name__ == "__main__": + unittest.main()