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/README.md b/README.md index ded5a49..1c2a8c6 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,13 @@ TMDB-backed metadata enrichment requires `TMDB_API_KEY` to be set in the environ ## Version History +### 0.3.1 + +- debug mode screen titles now append the active Textual screen class name, making screen-specific troubleshooting easier during inspect and edit flows +- `--cut` again works as a combined flag/option: omitted disables cutting, bare `--cut` applies the default `60,180`, and explicit duration or `START,DURATION` values stay supported +- H.265 unmux commands no longer force an invalid `-f h265` output format, keeping ffmpeg copy extraction aligned with the required Annex B bitstream filter +- H.264 encoding now falls back from `libx264` to `libopenh264` with a warning when needed, and the test fixtures use the same encoder fallback so the suite remains portable across ffmpeg builds + ### 0.3.0 - inspect and edit screens now refresh nested track and pattern changes more reliably, with inspect-mode tables aligned to the target pattern view shown in the differences pane 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/pyproject.toml b/pyproject.toml index 5a08d3f..59d1b34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ffx" description = "FFX recoding and metadata managing tool" -version = "0.3.0" +version = "0.3.1" license = {file = "LICENSE.md"} dependencies = [ "requests", diff --git a/requirements/project.md b/requirements/project.md index c292bfc..61e0cf3 100644 --- a/requirements/project.md +++ b/requirements/project.md @@ -98,7 +98,7 @@ - Intended for local execution, not server deployment. - Stores default state in `~/.local/etc/ffx.json`, `~/.local/var/ffx/ffx.db`, and `~/.local/var/log/ffx.log`. - Timeline constraints: - - The current implemented scope reflects a compact alpha release stream up to version `0.3.0`. + - The current implemented scope reflects a compact alpha release stream up to version `0.3.1`. - Team capacity assumptions: - Maintained as a small codebase where simple patterns and direct controller logic are preferred over framework-heavy abstractions. - Third-party dependencies: 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 803e871..defd3d8 100755 --- a/src/ffx/cli.py +++ b/src/ffx/cli.py @@ -647,6 +647,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 @@ -659,12 +660,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 @@ -779,7 +780,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") @@ -968,7 +969,6 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor): metavar="DURATION|START,DURATION", is_flag=False, flag_value=DEFAULT_CUT_OPTION_VALUE, - default=None, callback=normalizeCutOption, help=CUT_OPTION_HELP, ) @@ -1347,10 +1347,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/confirm_screen.py b/src/ffx/confirm_screen.py index 94ae8c8..88517e6 100644 --- a/src/ffx/confirm_screen.py +++ b/src/ffx/confirm_screen.py @@ -62,6 +62,13 @@ class ConfirmScreen(Screen): yield build_screen_log_pane() yield Footer() + + def on_mount(self): + + if getattr(self, 'context', {}).get('debug', False): + self.title = f"{self.app.title} - {self.__class__.__name__}" + + def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "confirm_button": self.dismiss(True) diff --git a/src/ffx/constants.py b/src/ffx/constants.py index 6b0c8d5..3ce659c 100644 --- a/src/ffx/constants.py +++ b/src/ffx/constants.py @@ -1,4 +1,4 @@ -VERSION='0.3.0' +VERSION='0.3.1' DATABASE_VERSION = 3 DEFAULT_QUALITY = 32 diff --git a/src/ffx/ffx_controller.py b/src/ffx/ffx_controller.py index 37c6861..ea5142a 100644 --- a/src/ffx/ffx_controller.py +++ b/src/ffx/ffx_controller.py @@ -1,4 +1,5 @@ -import os, click +import os, click, subprocess +from functools import lru_cache from logging import Logger from ffx.media_descriptor_change_set import MediaDescriptorChangeSet @@ -61,6 +62,41 @@ class FfxController(): sourceMediaDescriptor) self.__logger: Logger = context['logger'] + self.__warnedH264Fallback = False + + + @staticmethod + @lru_cache(maxsize=None) + def isFfmpegEncoderAvailable(encoderName: str) -> bool: + completed = subprocess.run( + ["ffmpeg", "-encoders"], + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + return False + + resolvedEncoderName = str(encoderName).strip() + + for line in completed.stdout.splitlines(): + if not line.startswith(" "): + continue + + tokens = line.split(maxsplit=2) + if len(tokens) >= 2 and tokens[1] == resolvedEncoderName: + return True + + return False + + + @classmethod + def getSupportedSoftwareH264Encoder(cls) -> str | None: + if cls.isFfmpegEncoderAvailable("libx264"): + return "libx264" + if cls.isFfmpegEncoderAvailable("libopenh264"): + return "libopenh264" + return None def executeCommandSequence(self, commandSequence): @@ -79,10 +115,27 @@ class FfxController(): # -c:v libx264 -preset slow -crf 17 def generateH264Tokens(self, quality, subIndex : int = 0): + h264Encoder = self.getSupportedSoftwareH264Encoder() - return [f"-c:v:{int(subIndex)}", 'libx264', - "-preset", "slow", - '-crf', str(quality)] + if h264Encoder == "libx264": + return [f"-c:v:{int(subIndex)}", 'libx264', + "-preset", "slow", + '-crf', str(quality)] + + if h264Encoder == "libopenh264": + if not self.__warnedH264Fallback: + self.__logger.warning( + "libx264 encoder unavailable; falling back to libopenh264 for H.264 encoding." + ) + self.__warnedH264Fallback = True + + return [f"-c:v:{int(subIndex)}", 'libopenh264', + '-pix_fmt', 'yuv420p'] + + raise click.ClickException( + "H.264 encoding requested but no supported software H.264 encoder is available. " + + "Tried libx264 and libopenh264." + ) # -c:v:0 libvpx-vp9 -row-mt 1 -crf 32 -pass 1 -speed 4 -frame-parallel 0 -g 9999 -aq-mode 0 diff --git a/src/ffx/help_screen.py b/src/ffx/help_screen.py index 5475b84..1d78e1c 100644 --- a/src/ffx/help_screen.py +++ b/src/ffx/help_screen.py @@ -20,5 +20,12 @@ class HelpScreen(Screen): yield build_screen_log_pane() yield Footer() + + def on_mount(self): + + if getattr(self, 'context', {}).get('debug', False): + self.title = f"{self.app.title} - {self.__class__.__name__}" + + def action_back(self): go_back_or_exit(self) diff --git a/src/ffx/inspect_details_screen.py b/src/ffx/inspect_details_screen.py index 24c54de..a4d41e6 100644 --- a/src/ffx/inspect_details_screen.py +++ b/src/ffx/inspect_details_screen.py @@ -39,8 +39,8 @@ class InspectDetailsScreen(MediaWorkflowScreenBase): CSS = f""" Grid {{ - grid-size: 6 11; - grid-rows: 9 2 2 2 2 8 2 2 2 8 8; + grid-size: 6 8; + grid-rows: 9 2 2 2 2 10 2 10; grid-columns: {GRID_COLUMN_LABEL_MIN} {GRID_COLUMN_2} {GRID_COLUMN_3} {GRID_COLUMN_4} {GRID_COLUMN_5} {GRID_COLUMN_6}; height: 100%; width: 100%; @@ -88,6 +88,10 @@ class InspectDetailsScreen(MediaWorkflowScreenBase): #differences-table {{ row-span: 10; }} + + .yellow {{ + tint: yellow 40%; + }} """ @classmethod @@ -157,6 +161,7 @@ class InspectDetailsScreen(MediaWorkflowScreenBase): yield Static(" ") yield self.differencesTable + # Row 2 yield Static(" ", classes="five") @@ -165,29 +170,26 @@ class InspectDetailsScreen(MediaWorkflowScreenBase): yield Button(t("Substitute"), id="pattern_button") yield Static(" ", classes="three") + # Row 4 yield Static(t("Pattern")) yield Input(type="text", id="pattern_input", classes="three") yield Static(" ") + # Row 5 yield Static(" ", classes="five") # Row 6 yield Static(t("Media Tags")) yield self.mediaTagsTable - yield Static(" ", classes="two") + yield Static(" ") + # Row 7 yield Static(" ", classes="five") # Row 8 - yield Static(" ") - yield Button(t("Set Default"), id="select_default_button") - yield Button(t("Set Forced"), id="select_forced_button") - yield Static(" ", classes="two") - - # Row 9 yield Static(t("Streams")) yield self.tracksTable yield Static(" ") @@ -314,6 +316,10 @@ class InspectDetailsScreen(MediaWorkflowScreenBase): self._update_show_header_labels() def on_mount(self): + + if getattr(self, 'context', {}).get('debug', False): + self.title = f"{self.app.title} - {self.__class__.__name__}" + self._update_grid_layout() if self._currentPattern is None: 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_edit_screen.py b/src/ffx/media_edit_screen.py index 5e27b3d..3e89205 100644 --- a/src/ffx/media_edit_screen.py +++ b/src/ffx/media_edit_screen.py @@ -178,6 +178,10 @@ class MediaEditScreen(MediaWorkflowScreenBase): yield Footer() def on_mount(self): + + if getattr(self, 'context', {}).get('debug', False): + self.title = f"{self.app.title} - {self.__class__.__name__}" + self._update_grid_layout() self.updateMediaTags() self.updateTracks() diff --git a/src/ffx/media_workflow_screen_base.py b/src/ffx/media_workflow_screen_base.py index 755f228..38274d7 100644 --- a/src/ffx/media_workflow_screen_base.py +++ b/src/ffx/media_workflow_screen_base.py @@ -125,6 +125,24 @@ class MediaWorkflowScreenBase(Screen): add_auto_table_column(self.differencesTable, t(self.DIFFERENCES_COLUMN_LABEL)) self.differencesTable.cursor_type = "row" + def _track_codec_cell_value(self, trackDescriptor: TrackDescriptor) -> str: + if trackDescriptor.getType() == TrackType.ATTACHMENT: + return " " + return trackDescriptor.getFormatDescriptor().label() + + def _track_disposition_cell_value( + self, + trackDescriptor: TrackDescriptor, + disposition: TrackDisposition, + ) -> str: + if trackDescriptor.getType() == TrackType.ATTACHMENT: + return " " + return ( + t("Yes") + if disposition in trackDescriptor.getDispositionSet() + else t("No") + ) + def reloadProperties(self, reset_draft: bool = True): self._mediaFileProperties = FileProperties(self.context, self._mediaFilename) probedMediaDescriptor = self._mediaFileProperties.getMediaDescriptor() @@ -221,15 +239,21 @@ class MediaWorkflowScreenBase(Screen): trackDescriptor.getIndex(), t(trackType.label()), typeCounter[trackType], - trackDescriptor.getCodec().label(), + self._track_codec_cell_value(trackDescriptor), t(audioLayout.label()) if trackType == TrackType.AUDIO and audioLayout != AudioLayout.LAYOUT_UNDEFINED else " ", trackDescriptor.getLanguage().label(), trackTitle, - t("Yes") if TrackDisposition.DEFAULT in dispositionSet else t("No"), - t("Yes") if TrackDisposition.FORCED in dispositionSet else t("No"), + self._track_disposition_cell_value( + trackDescriptor, + TrackDisposition.DEFAULT, + ), + self._track_disposition_cell_value( + trackDescriptor, + TrackDisposition.FORCED, + ), ) row_key = self.tracksTable.add_row(*map(str, row)) 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_delete_screen.py b/src/ffx/pattern_delete_screen.py index d786978..ab25dc5 100644 --- a/src/ffx/pattern_delete_screen.py +++ b/src/ffx/pattern_delete_screen.py @@ -68,6 +68,10 @@ class PatternDeleteScreen(Screen): def on_mount(self): + + if getattr(self, 'context', {}).get('debug', False): + self.title = f"{self.app.title} - {self.__class__.__name__}" + if self.__showDescriptor: self.query_one("#showlabel", Static).update(f"{self.__showDescriptor.getId()} - {self.__showDescriptor.getName()} ({self.__showDescriptor.getYear()})") if not self.__pattern is None: diff --git a/src/ffx/pattern_details_screen.py b/src/ffx/pattern_details_screen.py index bd9781f..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 ' ', @@ -326,6 +326,9 @@ class PatternDetailsScreen(Screen): def on_mount(self): + if getattr(self, 'context', {}).get('debug', False): + self.title = f"{self.app.title} - {self.__class__.__name__}" + if not self.__showDescriptor is None: self.query_one("#showlabel", Static).update(f"{self.__showDescriptor.getId()} - {self.__showDescriptor.getName()} ({self.__showDescriptor.getYear()})") diff --git a/src/ffx/settings_screen.py b/src/ffx/settings_screen.py index 99131e3..b82f251 100644 --- a/src/ffx/settings_screen.py +++ b/src/ffx/settings_screen.py @@ -20,5 +20,12 @@ class SettingsScreen(Screen): yield build_screen_log_pane() yield Footer() + + def on_mount(self): + + if getattr(self, 'context', {}).get('debug', False): + self.title = f"{self.app.title} - {self.__class__.__name__}" + + def action_back(self): go_back_or_exit(self) diff --git a/src/ffx/shifted_season_delete_screen.py b/src/ffx/shifted_season_delete_screen.py index 704182c..4139684 100644 --- a/src/ffx/shifted_season_delete_screen.py +++ b/src/ffx/shifted_season_delete_screen.py @@ -67,6 +67,9 @@ class ShiftedSeasonDeleteScreen(Screen): def on_mount(self): + if getattr(self, 'context', {}).get('debug', False): + self.title = f"{self.app.title} - {self.__class__.__name__}" + shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId) ownerLabel = ( diff --git a/src/ffx/shifted_season_details_screen.py b/src/ffx/shifted_season_details_screen.py index 43047b3..24ad0bd 100644 --- a/src/ffx/shifted_season_details_screen.py +++ b/src/ffx/shifted_season_details_screen.py @@ -109,6 +109,9 @@ class ShiftedSeasonDetailsScreen(Screen): def on_mount(self): + if getattr(self, 'context', {}).get('debug', False): + self.title = f"{self.app.title} - {self.__class__.__name__}" + if self.__shiftedSeasonId is not None: shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId) diff --git a/src/ffx/show_delete_screen.py b/src/ffx/show_delete_screen.py index 77bf1d9..8262491 100644 --- a/src/ffx/show_delete_screen.py +++ b/src/ffx/show_delete_screen.py @@ -109,5 +109,12 @@ class ShowDeleteScreen(Screen): if event.button.id == "cancel_button": self.app.pop_screen() + + def on_mount(self): + + if getattr(self, 'context', {}).get('debug', False): + self.title = f"{self.app.title} - {self.__class__.__name__}" + + def action_back(self): go_back_or_exit(self) diff --git a/src/ffx/show_details_screen.py b/src/ffx/show_details_screen.py index 85ed8d2..b5e99a9 100644 --- a/src/ffx/show_details_screen.py +++ b/src/ffx/show_details_screen.py @@ -175,6 +175,9 @@ class ShowDetailsScreen(Screen): def on_mount(self): + if getattr(self, 'context', {}).get('debug', False): + self.title = f"{self.app.title} - {self.__class__.__name__}" + if self.__showDescriptor is not None: showId = int(self.__showDescriptor.getId()) diff --git a/src/ffx/shows_screen.py b/src/ffx/shows_screen.py index ca0e91a..76758ab 100644 --- a/src/ffx/shows_screen.py +++ b/src/ffx/shows_screen.py @@ -244,6 +244,10 @@ class ShowsScreen(Screen): def on_mount(self) -> None: + + if getattr(self, 'context', {}).get('debug', False): + self.title = f"{self.app.title} - {self.__class__.__name__}" + for show in self.__sc.getAllShows(): self._add_show_row(show.getDescriptor(self.context)) diff --git a/src/ffx/tag_delete_screen.py b/src/ffx/tag_delete_screen.py index 5382e17..38aefab 100644 --- a/src/ffx/tag_delete_screen.py +++ b/src/ffx/tag_delete_screen.py @@ -64,6 +64,9 @@ class TagDeleteScreen(Screen): def on_mount(self): + if getattr(self, 'context', {}).get('debug', False): + self.title = f"{self.app.title} - {self.__class__.__name__}" + self.query_one("#keylabel", Static).update(str(self.__key)) self.query_one("#valuelabel", Static).update(str(self.__value)) diff --git a/src/ffx/tag_details_screen.py b/src/ffx/tag_details_screen.py index ccdda79..7bc948b 100644 --- a/src/ffx/tag_details_screen.py +++ b/src/ffx/tag_details_screen.py @@ -87,6 +87,9 @@ class TagDetailsScreen(Screen): def on_mount(self): + if getattr(self, 'context', {}).get('debug', False): + self.title = f"{self.app.title} - {self.__class__.__name__}" + if self.__key is not None: self.query_one("#key_input", Input).value = str(self.__key) diff --git a/src/ffx/track_codec.py b/src/ffx/track_codec.py index d6ec091..5bdb9b3 100644 --- a/src/ffx/track_codec.py +++ b/src/ffx/track_codec.py @@ -4,7 +4,7 @@ from enum import Enum class TrackCodec(Enum): VP9 = {'identifier': 'vp9', 'format': 'ivf', 'extension': 'ivf' , 'label': 'VP9'} - H265 = {'identifier': 'hevc', 'format': 'h265', 'extension': 'h265' ,'label': 'H.265'} + H265 = {'identifier': 'hevc', 'format': None, 'extension': 'h265' ,'label': 'H.265'} H264 = {'identifier': 'h264', 'format': 'h264', 'extension': 'h264' ,'label': 'H.264'} MPEG4 = {'identifier': 'mpeg4', 'format': 'm4v', 'extension': 'm4v' ,'label': 'MPEG-4'} MPEG2 = {'identifier': 'mpeg2video', 'format': 'mpeg2video', 'extension': 'mpg' ,'label': 'MPEG-2'} @@ -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_delete_screen.py b/src/ffx/track_delete_screen.py index edd8dd2..5937981 100644 --- a/src/ffx/track_delete_screen.py +++ b/src/ffx/track_delete_screen.py @@ -67,6 +67,9 @@ class TrackDeleteScreen(Screen): def on_mount(self): + if getattr(self, 'context', {}).get('debug', False): + self.title = f"{self.app.title} - {self.__class__.__name__}" + self.query_one("#subindexlabel", Static).update(str(self.__trackDescriptor.getSubIndex())) self.query_one("#patternlabel", Static).update(str(self.__trackDescriptor.getPatternId())) self.query_one("#languagelabel", Static).update(str(self.__trackDescriptor.getLanguage().label())) 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 aca5071..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() @@ -236,6 +239,9 @@ class TrackDetailsScreen(Screen): def on_mount(self): + if getattr(self, 'context', {}).get('debug', False): + self.title = f"{self.app.title} - {self.__class__.__name__}" + self.query_one("#index_label", Static).update( str(self.__index) if self.__index is not None else "-" ) @@ -430,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/support/ffx_bundle.py b/tests/support/ffx_bundle.py index 1fa5942..13d0ff7 100644 --- a/tests/support/ffx_bundle.py +++ b/tests/support/ffx_bundle.py @@ -7,6 +7,7 @@ import os from pathlib import Path import subprocess import sys +from functools import lru_cache from typing import Mapping @@ -95,8 +96,69 @@ def write_vtt(path: Path, lines: tuple[str, ...]) -> Path: return path -def create_source_fixture(workdir: Path, filename: str, tracks: list[SourceTrackSpec], duration_seconds: int = 1) -> Path: +@lru_cache(maxsize=None) +def _ffmpeg_encoder_is_available(encoder_name: str) -> bool: + completed = subprocess.run( + ["ffmpeg", "-encoders"], + capture_output=True, + text=True, + ) + if completed.returncode != 0: + return False + + encoder_label = str(encoder_name).strip() + for line in completed.stdout.splitlines(): + if not line.startswith(" "): + continue + + tokens = line.split(maxsplit=2) + if len(tokens) >= 2 and tokens[1] == encoder_label: + return True + + return False + + +def _resolve_fixture_video_encoder( + video_encoder: str, + video_encoder_options: tuple[str, ...], +) -> tuple[str, tuple[str, ...]]: + if video_encoder != "libx264": + return video_encoder, video_encoder_options + + if _ffmpeg_encoder_is_available("libx264"): + return video_encoder, video_encoder_options + + if _ffmpeg_encoder_is_available("libopenh264"): + # Keep fixture generation software-based when libx264 is missing. + return "libopenh264", ("-pix_fmt", "yuv420p") + + return video_encoder, video_encoder_options + + +def create_source_fixture( + workdir: Path, + filename: str, + tracks: list[SourceTrackSpec], + duration_seconds: int = 1, + *, + video_encoder: str = "libx264", + video_encoder_options: tuple[str, ...] = ( + "-preset", + "ultrafast", + "-crf", + "35", + "-pix_fmt", + "yuv420p", + ), + audio_encoder: str = "aac", + audio_encoder_options: tuple[str, ...] = ("-b:a", "48k"), + subtitle_encoder: str = "webvtt", +) -> Path: output_path = workdir / filename + video_encoder, video_encoder_options = _resolve_fixture_video_encoder( + video_encoder, + video_encoder_options, + ) has_video = any(track.track_type == TrackType.VIDEO for track in tracks) has_audio = any(track.track_type == TrackType.AUDIO for track in tracks) @@ -189,21 +251,16 @@ def create_source_fixture(workdir: Path, filename: str, tracks: list[SourceTrack command += map_tokens command += metadata_tokens command += disposition_tokens + if has_video: + command += ["-c:v", video_encoder] + list(video_encoder_options) + + if has_audio: + command += ["-c:a", audio_encoder] + list(audio_encoder_options) + + if subtitle_input_indices: + command += ["-c:s", subtitle_encoder] + command += [ - "-c:v", - "libx264", - "-preset", - "ultrafast", - "-crf", - "35", - "-pix_fmt", - "yuv420p", - "-c:a", - "aac", - "-b:a", - "48k", - "-c:s", - "webvtt", "-t", str(duration_seconds), "-shortest", diff --git a/tests/unit/test_cli_unmux_sequence.py b/tests/unit/test_cli_unmux_sequence.py index b858755..9d53b89 100644 --- a/tests/unit/test_cli_unmux_sequence.py +++ b/tests/unit/test_cli_unmux_sequence.py @@ -18,7 +18,7 @@ from ffx.track_type import TrackType # noqa: E402 class UnmuxSequenceTests(unittest.TestCase): - def test_h265_video_unmux_uses_annex_b_bitstream_filter(self): + def test_h265_video_unmux_uses_annex_b_bitstream_filter_without_forced_format(self): track_descriptor = TrackDescriptor( index=0, sub_index=0, @@ -46,8 +46,6 @@ class UnmuxSequenceTests(unittest.TestCase): "copy", "-bsf:v", "hevc_mp4toannexb", - "-f", - "h265", "episode_0_eng.h265", ], sequence, diff --git a/tests/unit/test_ffx_controller.py b/tests/unit/test_ffx_controller.py index 5d4a0dd..0af5db1 100644 --- a/tests/unit/test_ffx_controller.py +++ b/tests/unit/test_ffx_controller.py @@ -1,5 +1,6 @@ from __future__ import annotations +import click from pathlib import Path import sys import unittest @@ -33,6 +34,9 @@ class StaticConfig: class FfxControllerTests(unittest.TestCase): + def tearDown(self): + FfxController.isFfmpegEncoderAvailable.cache_clear() + def make_context(self, video_encoder: VideoEncoder) -> dict: return { "logger": get_ffx_logger(), @@ -318,6 +322,61 @@ class FfxControllerTests(unittest.TestCase): self.assertNotIn("libopus", commands[0]) self.assertFalse(any(token.startswith("-b:a") for token in commands[0])) self.assertFalse(any(token.startswith("-filter:a") for token in commands[0])) + def test_generate_h264_tokens_prefers_libx264_when_available(self): + context = self.make_context(VideoEncoder.H264) + target_descriptor, source_descriptor = self.make_media_descriptors() + controller = FfxController(context, target_descriptor, source_descriptor) + + with patch.object( + FfxController, + "getSupportedSoftwareH264Encoder", + return_value="libx264", + ): + tokens = controller.generateH264Tokens(23) + + self.assertEqual( + ["-c:v:0", "libx264", "-preset", "slow", "-crf", "23"], + tokens, + ) + + def test_generate_h264_tokens_falls_back_to_libopenh264_and_logs_warning(self): + context = self.make_context(VideoEncoder.H264) + target_descriptor, source_descriptor = self.make_media_descriptors() + controller = FfxController(context, target_descriptor, source_descriptor) + + with ( + patch.object( + FfxController, + "getSupportedSoftwareH264Encoder", + return_value="libopenh264", + ), + patch.object(context["logger"], "warning") as mocked_warning, + ): + tokens = controller.generateH264Tokens(23) + + self.assertEqual( + ["-c:v:0", "libopenh264", "-pix_fmt", "yuv420p"], + tokens, + ) + mocked_warning.assert_called_once_with( + "libx264 encoder unavailable; falling back to libopenh264 for H.264 encoding." + ) + + def test_generate_h264_tokens_raises_when_no_supported_software_encoder_exists(self): + context = self.make_context(VideoEncoder.H264) + target_descriptor, source_descriptor = self.make_media_descriptors() + controller = FfxController(context, target_descriptor, source_descriptor) + + with patch.object( + FfxController, + "getSupportedSoftwareH264Encoder", + return_value=None, + ): + with self.assertRaisesRegex( + click.ClickException, + "no supported software H.264 encoder is available", + ): + controller.generateH264Tokens(23) if __name__ == "__main__": diff --git a/tests/unit/test_file_properties_asset_probe.py b/tests/unit/test_file_properties_asset_probe.py index 367d26d..83052d4 100644 --- a/tests/unit/test_file_properties_asset_probe.py +++ b/tests/unit/test_file_properties_asset_probe.py @@ -2,6 +2,7 @@ from __future__ import annotations from pathlib import Path import sys +import tempfile import unittest @@ -16,6 +17,7 @@ from ffx.i18n import set_current_language # noqa: E402 from ffx.logging_utils import get_ffx_logger # noqa: E402 from ffx.track_codec import TrackCodec # noqa: E402 from ffx.track_type import TrackType # noqa: E402 +from tests.support.ffx_bundle import SourceTrackSpec, create_source_fixture # noqa: E402 class StaticConfig: @@ -39,25 +41,41 @@ class FilePropertiesAssetProbeTests(unittest.TestCase): } set_current_language("de") - media_path = ( - Path(__file__).resolve().parents[1] - / "assets" - / "Boruto; Naruto Next Generations (2017) - 0069 Super-Chochos Liebestaumel - S01E0069.webm" - ) + with tempfile.TemporaryDirectory() as tmpdir: + media_path = create_source_fixture( + Path(tmpdir), + "fixture.webm", + [ + SourceTrackSpec(TrackType.VIDEO, identity="video-0"), + SourceTrackSpec(TrackType.AUDIO, identity="audio-1", language="eng"), + SourceTrackSpec( + TrackType.SUBTITLE, + identity="subtitle-2", + language="eng", + subtitle_lines=("Lorem ipsum dolor sit amet.",), + ), + ], + duration_seconds=3, + video_encoder="libvpx-vp9", + video_encoder_options=("-b:v", "0", "-crf", "45"), + audio_encoder="libopus", + audio_encoder_options=("-b:a", "48k"), + subtitle_encoder="webvtt", + ) - file_properties = FileProperties(context, str(media_path)) - tracks = file_properties.getMediaDescriptor().getTrackDescriptors() + file_properties = FileProperties(context, str(media_path)) + tracks = file_properties.getMediaDescriptor().getTrackDescriptors() - subtitle_codecs = [ - track.getCodec() - for track in tracks - if track.getType() == TrackType.SUBTITLE - ] + subtitle_codecs = [ + track.getCodec() + for track in tracks + if track.getType() == TrackType.SUBTITLE + ] - self.assertIn(TrackCodec.VP9, [track.getCodec() for track in tracks]) - self.assertIn(TrackCodec.OPUS, [track.getCodec() for track in tracks]) - self.assertTrue(subtitle_codecs) - self.assertTrue(all(codec == TrackCodec.WEBVTT for codec in subtitle_codecs)) + self.assertIn(TrackCodec.VP9, [track.getCodec() for track in tracks]) + self.assertIn(TrackCodec.OPUS, [track.getCodec() for track in tracks]) + self.assertTrue(subtitle_codecs) + self.assertTrue(all(codec == TrackCodec.WEBVTT for codec in subtitle_codecs)) if __name__ == "__main__": diff --git a/tests/unit/test_metadata_editor.py b/tests/unit/test_metadata_editor.py index 7b65a1a..4858d00 100644 --- a/tests/unit/test_metadata_editor.py +++ b/tests/unit/test_metadata_editor.py @@ -15,6 +15,7 @@ if str(SRC_ROOT) not in sys.path: from ffx.logging_utils import get_ffx_logger # noqa: E402 +from ffx.helper import LogLevel # noqa: E402 from ffx.media_descriptor import MediaDescriptor # noqa: E402 from ffx.metadata_editor import ( # noqa: E402 apply_metadata_edits, @@ -33,6 +34,16 @@ class StaticConfig: return {} +class NotificationCollector: + def __init__(self) -> None: + self.messages: list[str] = [] + self.levels: list[LogLevel | None] = [] + + def __call__(self, message: str, level: LogLevel | None = None) -> None: + self.messages.append(message) + self.levels.append(level) + + def make_context(*, dry_run: bool = False) -> dict: return { "logger": get_ffx_logger(), @@ -151,7 +162,7 @@ class MetadataEditorTests(unittest.TestCase): context = make_context(dry_run=True) baseline_descriptor = make_descriptor() draft_descriptor = baseline_descriptor.clone(context=context) - notifications = [] + notifications = NotificationCollector() expected_command = build_metadata_edit_command( build_metadata_edit_context(context), "/tmp/example.mkv", @@ -170,12 +181,13 @@ class MetadataEditorTests(unittest.TestCase): "/tmp/example.mkv", baseline_descriptor, draft_descriptor, - loggingHandler = notifications.append, + loggingHandler = notifications, ) mocked_execute.assert_not_called() mocked_replace.assert_not_called() - self.assertEqual(["ffmpeg dry-run prepared."], notifications) + self.assertEqual(["ffmpeg dry-run prepared."], notifications.messages) + self.assertEqual([None], notifications.levels) self.assertEqual( { "applied": False, @@ -204,7 +216,7 @@ class MetadataEditorTests(unittest.TestCase): context["verbosity"] = 1 baseline_descriptor = make_descriptor() draft_descriptor = baseline_descriptor.clone(context=context) - notifications = [] + notifications = NotificationCollector() with ( patch("ffx.metadata_editor.create_temporary_output_path", return_value="/tmp/.edit.mkv"), @@ -216,11 +228,12 @@ class MetadataEditorTests(unittest.TestCase): "/tmp/example.mkv", baseline_descriptor, draft_descriptor, - loggingHandler = notifications.append, + loggingHandler = notifications, ) - self.assertEqual(1, len(notifications)) - self.assertTrue(notifications[0].startswith("ffmpeg: ffmpeg ")) + self.assertEqual(1, len(notifications.messages)) + self.assertTrue(notifications.messages[0].startswith("ffmpeg: ffmpeg ")) + self.assertEqual([LogLevel.DEBUG], notifications.levels) if __name__ == "__main__": diff --git a/tests/unit/test_tag_table_screen_state.py b/tests/unit/test_tag_table_screen_state.py index 16a72fa..a7c8660 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) @@ -695,6 +722,33 @@ class TagTableScreenStateTests(unittest.TestCase): self.assertIn("English Full", screen.tracksTable.rows["row-0"]) self.assertIs(target_track, screen.getSelectedTrackDescriptor()) + def test_inspect_details_screen_update_tracks_blanks_irrelevant_attachment_fields(self): + attachment_track = TrackDescriptor( + index=4, + source_index=4, + sub_index=0, + track_type=TrackType.ATTACHMENT, + attachment_format=AttachmentFormat.TTF, + tags={"filename": "font.ttf", "mimetype": "font/ttf"}, + ) + + screen = object.__new__(InspectDetailsScreen) + screen.tracksTable = FakeTagTable() + screen._sourceMediaDescriptor = FakeMediaDescriptor([attachment_track]) + screen._targetMediaDescriptor = None + screen._currentPattern = None + screen._trackRowData = {} + screen._applyNormalization = False + + screen.updateTracks() + + row = screen.tracksTable.rows["row-0"] + + self.assertEqual("4", row[0]) + self.assertEqual(" ", row[3]) + self.assertEqual(" ", row[7]) + self.assertEqual(" ", row[8]) + def test_inspect_details_screen_maps_target_selection_back_to_source_track(self): source_track = TrackDescriptor( index=3, 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() diff --git a/tools/merge_dev_into_main.sh b/tools/merge_dev_into_main.sh index 0340a11..e540635 100755 --- a/tools/merge_dev_into_main.sh +++ b/tools/merge_dev_into_main.sh @@ -172,20 +172,84 @@ fetch_remote_state() { git fetch "${ORIGIN_REMOTE}" "${DEV_BRANCH}" "${MAIN_BRANCH}" --tags >/dev/null } -require_branch_matches_remote() { +branch_divergence_counts() { local branch="$1" - local local_sha="" - local remote_sha="" + local remote_only="" + local local_only="" if ! git show-ref --verify --quiet "refs/remotes/${ORIGIN_REMOTE}/${branch}"; then fail "Remote branch '${ORIGIN_REMOTE}/${branch}' does not exist." fi - local_sha="$(git rev-parse "refs/heads/${branch}")" - remote_sha="$(git rev-parse "refs/remotes/${ORIGIN_REMOTE}/${branch}")" + read -r remote_only local_only < <( + git rev-list --left-right --count \ + "refs/remotes/${ORIGIN_REMOTE}/${branch}...refs/heads/${branch}" + ) - if [ "${local_sha}" != "${remote_sha}" ]; then - fail "Local branch '${branch}' is not up to date with '${ORIGIN_REMOTE}/${branch}'. Pull, rebase, or push first." + printf '%s %s\n' "${remote_only}" "${local_only}" +} + +fast_forward_branch_to_remote() { + local branch="$1" + local remote_ref="refs/remotes/${ORIGIN_REMOTE}/${branch}" + local current_head="" + + current_head="$(git rev-parse --abbrev-ref HEAD)" + + printf "Fast-forwarding local branch '%s' to '%s/%s'...\n" \ + "${branch}" \ + "${ORIGIN_REMOTE}" \ + "${branch}" + + if [ "${current_head}" = "${branch}" ]; then + git merge --ff-only "${remote_ref}" >/dev/null + return 0 + fi + + git branch -f "${branch}" "${remote_ref}" >/dev/null +} + +sync_release_source_branch() { + local branch="$1" + local remote_only="" + local local_only="" + + read -r remote_only local_only < <(branch_divergence_counts "${branch}") + + if [ "${remote_only}" -ne 0 ] && [ "${local_only}" -ne 0 ]; then + fail "Local branch '${branch}' has diverged from '${ORIGIN_REMOTE}/${branch}' (${local_only} local-only commit(s), ${remote_only} remote-only commit(s)). Reconcile the branches first." + fi + + if [ "${remote_only}" -ne 0 ]; then + fast_forward_branch_to_remote "${branch}" + fi + + if [ "${local_only}" -ne 0 ]; then + printf "Notice: local branch '%s' is ahead of '%s/%s' by %s commit(s); release will use the local tip.\n" \ + "${branch}" \ + "${ORIGIN_REMOTE}" \ + "${branch}" \ + "${local_only}" + fi +} + +sync_release_target_branch() { + local branch="$1" + local remote_only="" + local local_only="" + + read -r remote_only local_only < <(branch_divergence_counts "${branch}") + + if [ "${remote_only}" -ne 0 ] && [ "${local_only}" -ne 0 ]; then + fail "Local branch '${branch}' has diverged from '${ORIGIN_REMOTE}/${branch}' (${local_only} local-only commit(s), ${remote_only} remote-only commit(s)). Reconcile the branches first." + fi + + if [ "${local_only}" -ne 0 ]; then + fail "Local branch '${branch}' is ahead of '${ORIGIN_REMOTE}/${branch}' by ${local_only} commit(s). Push or reconcile first so the release starts from the published ${branch} tip." + fi + + if [ "${remote_only}" -ne 0 ]; then + fast_forward_branch_to_remote "${branch}" fi } @@ -249,13 +313,11 @@ print_release_plan() { printf 'Dry run only. Planned steps:\n' printf '1. Ensure current branch is %s and the worktree is clean.\n' "${DEV_BRANCH}" - printf '2. Fetch %s and verify local %s and %s exactly match %s/%s and %s/%s.\n' \ + printf '2. Fetch %s, fast-forward local %s and %s from %s when safe, and fail on divergence or unpublished local %s commits.\n' \ "${ORIGIN_REMOTE}" \ "${DEV_BRANCH}" \ "${MAIN_BRANCH}" \ "${ORIGIN_REMOTE}" \ - "${DEV_BRANCH}" \ - "${ORIGIN_REMOTE}" \ "${MAIN_BRANCH}" if [ "${SKIP_TESTS}" -eq 1 ]; then printf '3. Skip the pre-release test gate.\n' @@ -304,8 +366,8 @@ require_repo_state require_dev_checkout require_clean_worktree fetch_remote_state -require_branch_matches_remote "${DEV_BRANCH}" -require_branch_matches_remote "${MAIN_BRANCH}" +sync_release_source_branch "${DEV_BRANCH}" +sync_release_target_branch "${MAIN_BRANCH}" RELEASE_VERSION="$(resolve_release_version)" RELEASE_TAG="v${RELEASE_VERSION}" @@ -341,8 +403,8 @@ fi run_pre_release_tests require_clean_worktree fetch_remote_state -require_branch_matches_remote "${DEV_BRANCH}" -require_branch_matches_remote "${MAIN_BRANCH}" +sync_release_source_branch "${DEV_BRANCH}" +sync_release_target_branch "${MAIN_BRANCH}" require_release_tag_available "${RELEASE_VERSION}" git switch "${MAIN_BRANCH}" >/dev/null