Merge branch 'dev' of gitea.maveno.de:Javanaut/ffx into dev

This commit is contained in:
Javanaut
2026-04-23 16:31:19 +02:00
42 changed files with 1005 additions and 103 deletions

2
.gitignore vendored
View File

@@ -10,6 +10,8 @@ tools/ansible/inventory/group_vars/all.yml
ffx_test_report.log ffx_test_report.log
bin/conversiontest.py bin/conversiontest.py
tests/assets/
build/ build/
dist/ dist/
*.egg-info/ *.egg-info/

View File

@@ -99,6 +99,13 @@ TMDB-backed metadata enrichment requires `TMDB_API_KEY` to be set in the environ
## Version History ## 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 ### 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 - 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

View File

@@ -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. 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.
<!--
## .265 export ## Source Formats
-->
-map 0:v -c:v copy -bsf:v hevc_mp4toannexb out.h265

170
docs/file_formats.md Normal file
View File

@@ -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/

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "ffx" name = "ffx"
description = "FFX recoding and metadata managing tool" description = "FFX recoding and metadata managing tool"
version = "0.3.0" version = "0.3.1"
license = {file = "LICENSE.md"} license = {file = "LICENSE.md"}
dependencies = [ dependencies = [
"requests", "requests",

View File

@@ -98,7 +98,7 @@
- Intended for local execution, not server deployment. - 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`. - Stores default state in `~/.local/etc/ffx.json`, `~/.local/var/ffx/ffx.db`, and `~/.local/var/log/ffx.log`.
- Timeline constraints: - 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: - Team capacity assumptions:
- Maintained as a small codebase where simple patterns and direct controller logic are preferred over framework-heavy abstractions. - Maintained as a small codebase where simple patterns and direct controller logic are preferred over framework-heavy abstractions.
- Third-party dependencies: - Third-party dependencies:

View File

@@ -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.

View File

@@ -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

View File

@@ -647,6 +647,7 @@ def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix,
trackType = trackDescriptor.getType() trackType = trackDescriptor.getType()
trackCodec = trackDescriptor.getCodec() trackCodec = trackDescriptor.getCodec()
trackFormat = trackDescriptor.getFormatDescriptor()
targetPathBase = os.path.join(targetDirectory, targetPrefix) if targetDirectory else targetPrefix targetPathBase = os.path.join(targetDirectory, targetPrefix) if targetDirectory else targetPrefix
@@ -659,12 +660,12 @@ def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix,
commandTokens += ['-c', 'copy'] commandTokens += ['-c', 'copy']
# output format # output format
codecFormat = trackCodec.format() codecFormat = trackFormat.format()
if codecFormat is not None: if codecFormat is not None:
commandTokens += ['-f', codecFormat] commandTokens += ['-f', codecFormat]
# output filename # output filename
commandTokens += [f"{targetPathBase}.{trackCodec.extension()}"] commandTokens += [f"{targetPathBase}.{trackFormat.extension()}"]
return commandTokens return commandTokens
@@ -779,7 +780,7 @@ def unmux(ctx,
if not ctx.obj['dry_run']: if not ctx.obj['dry_run']:
#TODO #425: Codec Enum #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") ctx.obj['logger'].debug(f"Executing unmuxing sequence")
@@ -968,7 +969,6 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
metavar="DURATION|START,DURATION", metavar="DURATION|START,DURATION",
is_flag=False, is_flag=False,
flag_value=DEFAULT_CUT_OPTION_VALUE, flag_value=DEFAULT_CUT_OPTION_VALUE,
default=None,
callback=normalizeCutOption, callback=normalizeCutOption,
help=CUT_OPTION_HELP, help=CUT_OPTION_HELP,
) )
@@ -1347,10 +1347,12 @@ def convert(ctx,
sourceMediaDescriptor = mediaFileProperties.getMediaDescriptor() sourceMediaDescriptor = mediaFileProperties.getMediaDescriptor()
from ffx.attachment_format import AttachmentFormat
if ([smd for smd in sourceMediaDescriptor.getSubtitleTracks() if ([smd for smd in sourceMediaDescriptor.getSubtitleTracks()
if smd.getCodec() == TrackCodec.ASS] if smd.getCodec() == TrackCodec.ASS]
and [amd for amd in sourceMediaDescriptor.getAttachmentTracks() and [amd for amd in sourceMediaDescriptor.getAttachmentTracks()
if amd.getCodec() == TrackCodec.TTF]): if amd.getAttachmentFormat() == AttachmentFormat.TTF]):
targetFormat = '' targetFormat = ''
targetExtension = 'mkv' targetExtension = 'mkv'

View File

@@ -62,6 +62,13 @@ class ConfirmScreen(Screen):
yield build_screen_log_pane() yield build_screen_log_pane()
yield Footer() 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: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "confirm_button": if event.button.id == "confirm_button":
self.dismiss(True) self.dismiss(True)

View File

@@ -1,4 +1,4 @@
VERSION='0.3.0' VERSION='0.3.1'
DATABASE_VERSION = 3 DATABASE_VERSION = 3
DEFAULT_QUALITY = 32 DEFAULT_QUALITY = 32

View File

@@ -1,4 +1,5 @@
import os, click import os, click, subprocess
from functools import lru_cache
from logging import Logger from logging import Logger
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
@@ -61,6 +62,41 @@ class FfxController():
sourceMediaDescriptor) sourceMediaDescriptor)
self.__logger: Logger = context['logger'] 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): def executeCommandSequence(self, commandSequence):
@@ -79,11 +115,28 @@ class FfxController():
# -c:v libx264 -preset slow -crf 17 # -c:v libx264 -preset slow -crf 17
def generateH264Tokens(self, quality, subIndex : int = 0): def generateH264Tokens(self, quality, subIndex : int = 0):
h264Encoder = self.getSupportedSoftwareH264Encoder()
if h264Encoder == "libx264":
return [f"-c:v:{int(subIndex)}", 'libx264', return [f"-c:v:{int(subIndex)}", 'libx264',
"-preset", "slow", "-preset", "slow",
'-crf', str(quality)] '-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 # -c:v:0 libvpx-vp9 -row-mt 1 -crf 32 -pass 1 -speed 4 -frame-parallel 0 -g 9999 -aq-mode 0
def generateVP9Pass1Tokens(self, quality, subIndex : int = 0): def generateVP9Pass1Tokens(self, quality, subIndex : int = 0):

View File

@@ -20,5 +20,12 @@ class HelpScreen(Screen):
yield build_screen_log_pane() yield build_screen_log_pane()
yield Footer() 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): def action_back(self):
go_back_or_exit(self) go_back_or_exit(self)

View File

@@ -39,8 +39,8 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
CSS = f""" CSS = f"""
Grid {{ Grid {{
grid-size: 6 11; grid-size: 6 8;
grid-rows: 9 2 2 2 2 8 2 2 2 8 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}; grid-columns: {GRID_COLUMN_LABEL_MIN} {GRID_COLUMN_2} {GRID_COLUMN_3} {GRID_COLUMN_4} {GRID_COLUMN_5} {GRID_COLUMN_6};
height: 100%; height: 100%;
width: 100%; width: 100%;
@@ -88,6 +88,10 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
#differences-table {{ #differences-table {{
row-span: 10; row-span: 10;
}} }}
.yellow {{
tint: yellow 40%;
}}
""" """
@classmethod @classmethod
@@ -157,6 +161,7 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
yield Static(" ") yield Static(" ")
yield self.differencesTable yield self.differencesTable
# Row 2 # Row 2
yield Static(" ", classes="five") yield Static(" ", classes="five")
@@ -165,29 +170,26 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
yield Button(t("Substitute"), id="pattern_button") yield Button(t("Substitute"), id="pattern_button")
yield Static(" ", classes="three") yield Static(" ", classes="three")
# Row 4 # Row 4
yield Static(t("Pattern")) yield Static(t("Pattern"))
yield Input(type="text", id="pattern_input", classes="three") yield Input(type="text", id="pattern_input", classes="three")
yield Static(" ") yield Static(" ")
# Row 5 # Row 5
yield Static(" ", classes="five") yield Static(" ", classes="five")
# Row 6 # Row 6
yield Static(t("Media Tags")) yield Static(t("Media Tags"))
yield self.mediaTagsTable yield self.mediaTagsTable
yield Static(" ", classes="two") yield Static(" ")
# Row 7 # Row 7
yield Static(" ", classes="five") yield Static(" ", classes="five")
# Row 8 # 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 Static(t("Streams"))
yield self.tracksTable yield self.tracksTable
yield Static(" ") yield Static(" ")
@@ -314,6 +316,10 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
self._update_show_header_labels() self._update_show_header_labels()
def on_mount(self): 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._update_grid_layout()
if self._currentPattern is None: if self._currentPattern is None:

View File

@@ -2,6 +2,7 @@ import os, re, click
from typing import List, Self from typing import List, Self
from ffx.attachment_format import AttachmentFormat
from ffx.track_type import TrackType from ffx.track_type import TrackType
from ffx.iso_language import IsoLanguage from ffx.iso_language import IsoLanguage
@@ -421,11 +422,11 @@ class MediaDescriptor:
if sourceMediaDescriptor: if sourceMediaDescriptor:
fontDescriptors = [ftd for ftd in sourceMediaDescriptor.getAttachmentTracks() fontDescriptors = [ftd for ftd in sourceMediaDescriptor.getAttachmentTracks()
if ftd.getCodec() == TrackCodec.TTF] if ftd.getAttachmentFormat() == AttachmentFormat.TTF]
else: else:
fontDescriptors = [ftd for ftd in self.__trackDescriptors fontDescriptors = [ftd for ftd in self.__trackDescriptors
if ftd.getType() == TrackType.ATTACHMENT 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()): for ad in sorted(fontDescriptors, key=lambda d: d.getIndex()):
inputMappingTokens += ["-map", f"0:{ad.getIndex()}"] inputMappingTokens += ["-map", f"0:{ad.getIndex()}"]

View File

@@ -178,6 +178,10 @@ class MediaEditScreen(MediaWorkflowScreenBase):
yield Footer() yield Footer()
def on_mount(self): 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._update_grid_layout()
self.updateMediaTags() self.updateMediaTags()
self.updateTracks() self.updateTracks()

View File

@@ -125,6 +125,24 @@ class MediaWorkflowScreenBase(Screen):
add_auto_table_column(self.differencesTable, t(self.DIFFERENCES_COLUMN_LABEL)) add_auto_table_column(self.differencesTable, t(self.DIFFERENCES_COLUMN_LABEL))
self.differencesTable.cursor_type = "row" 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): def reloadProperties(self, reset_draft: bool = True):
self._mediaFileProperties = FileProperties(self.context, self._mediaFilename) self._mediaFileProperties = FileProperties(self.context, self._mediaFilename)
probedMediaDescriptor = self._mediaFileProperties.getMediaDescriptor() probedMediaDescriptor = self._mediaFileProperties.getMediaDescriptor()
@@ -221,15 +239,21 @@ class MediaWorkflowScreenBase(Screen):
trackDescriptor.getIndex(), trackDescriptor.getIndex(),
t(trackType.label()), t(trackType.label()),
typeCounter[trackType], typeCounter[trackType],
trackDescriptor.getCodec().label(), self._track_codec_cell_value(trackDescriptor),
t(audioLayout.label()) t(audioLayout.label())
if trackType == TrackType.AUDIO if trackType == TrackType.AUDIO
and audioLayout != AudioLayout.LAYOUT_UNDEFINED and audioLayout != AudioLayout.LAYOUT_UNDEFINED
else " ", else " ",
trackDescriptor.getLanguage().label(), trackDescriptor.getLanguage().label(),
trackTitle, trackTitle,
t("Yes") if TrackDisposition.DEFAULT in dispositionSet else t("No"), self._track_disposition_cell_value(
t("Yes") if TrackDisposition.FORCED in dispositionSet else t("No"), trackDescriptor,
TrackDisposition.DEFAULT,
),
self._track_disposition_cell_value(
trackDescriptor,
TrackDisposition.FORCED,
),
) )
row_key = self.tracksTable.add_row(*map(str, row)) row_key = self.tracksTable.add_row(*map(str, row))

View File

@@ -4,6 +4,7 @@ from sqlalchemy.orm import relationship, declarative_base, sessionmaker
from .show import Base from .show import Base
from ffx.attachment_format import AttachmentFormat
from ffx.track_type import TrackType from ffx.track_type import TrackType
from ffx.iso_language import IsoLanguage from ffx.iso_language import IsoLanguage
@@ -132,9 +133,16 @@ class Track(Base):
if trackType in [t.label() for t in TrackType]: 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, return cls(pattern_id = patternId,
track_type = trackType, 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() 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]), if v and (t := TrackDisposition.find(k)) is not None]),
audio_layout = AudioLayout.identify(streamObj)) audio_layout = AudioLayout.identify(streamObj))
@@ -153,8 +161,20 @@ class Track(Base):
return TrackType.fromIndex(self.track_type) return TrackType.fromIndex(self.track_type)
def getCodec(self) -> TrackCodec: def getCodec(self) -> TrackCodec:
if self.getType() == TrackType.ATTACHMENT:
return TrackCodec.UNKNOWN
return TrackCodec.identify(self.codec_name) 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): def getIndex(self):
return int(self.index) if self.index is not None else -1 return int(self.index) if self.index is not None else -1
@@ -206,6 +226,9 @@ class Track(Base):
kwargs[TrackDescriptor.SUB_INDEX_KEY] = subIndex kwargs[TrackDescriptor.SUB_INDEX_KEY] = subIndex
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = self.getType() kwargs[TrackDescriptor.TRACK_TYPE_KEY] = self.getType()
if self.getType() == TrackType.ATTACHMENT:
kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = self.getAttachmentFormat()
else:
kwargs[TrackDescriptor.CODEC_KEY] = self.getCodec() kwargs[TrackDescriptor.CODEC_KEY] = self.getCodec()
kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = self.getDispositionSet() kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = self.getDispositionSet()

View File

@@ -134,7 +134,7 @@ class PatternController:
def _build_track_row(self, trackDescriptor: TrackDescriptor) -> Track: def _build_track_row(self, trackDescriptor: TrackDescriptor) -> Track:
track = Track( track = Track(
track_type=int(trackDescriptor.getType().index()), track_type=int(trackDescriptor.getType().index()),
codec_name=str(trackDescriptor.getCodec().identifier()), codec_name=str(trackDescriptor.getFormatDescriptor().identifier()),
index=int(trackDescriptor.getIndex()), index=int(trackDescriptor.getIndex()),
source_index=int(trackDescriptor.getSourceIndex()), source_index=int(trackDescriptor.getSourceIndex()),
disposition_flags=int( disposition_flags=int(

View File

@@ -68,6 +68,10 @@ class PatternDeleteScreen(Screen):
def on_mount(self): def on_mount(self):
if getattr(self, 'context', {}).get('debug', False):
self.title = f"{self.app.title} - {self.__class__.__name__}"
if self.__showDescriptor: if self.__showDescriptor:
self.query_one("#showlabel", Static).update(f"{self.__showDescriptor.getId()} - {self.__showDescriptor.getName()} ({self.__showDescriptor.getYear()})") self.query_one("#showlabel", Static).update(f"{self.__showDescriptor.getId()} - {self.__showDescriptor.getName()} ({self.__showDescriptor.getYear()})")
if not self.__pattern is None: if not self.__pattern is None:

View File

@@ -175,7 +175,7 @@ class PatternDetailsScreen(Screen):
row = (td.getIndex(), row = (td.getIndex(),
t(trackType.label()), t(trackType.label()),
typeCounter[trackType], typeCounter[trackType],
td.getCodec().label(), td.getFormatDescriptor().label(),
t(audioLayout.label()) if trackType == TrackType.AUDIO t(audioLayout.label()) if trackType == TrackType.AUDIO
and audioLayout != AudioLayout.LAYOUT_UNDEFINED else ' ', and audioLayout != AudioLayout.LAYOUT_UNDEFINED else ' ',
trackLanguage.label() if trackLanguage != IsoLanguage.UNDEFINED else ' ', trackLanguage.label() if trackLanguage != IsoLanguage.UNDEFINED else ' ',
@@ -326,6 +326,9 @@ class PatternDetailsScreen(Screen):
def on_mount(self): 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: if not self.__showDescriptor is None:
self.query_one("#showlabel", Static).update(f"{self.__showDescriptor.getId()} - {self.__showDescriptor.getName()} ({self.__showDescriptor.getYear()})") self.query_one("#showlabel", Static).update(f"{self.__showDescriptor.getId()} - {self.__showDescriptor.getName()} ({self.__showDescriptor.getYear()})")

View File

@@ -20,5 +20,12 @@ class SettingsScreen(Screen):
yield build_screen_log_pane() yield build_screen_log_pane()
yield Footer() 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): def action_back(self):
go_back_or_exit(self) go_back_or_exit(self)

View File

@@ -67,6 +67,9 @@ class ShiftedSeasonDeleteScreen(Screen):
def on_mount(self): 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) shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId)
ownerLabel = ( ownerLabel = (

View File

@@ -109,6 +109,9 @@ class ShiftedSeasonDetailsScreen(Screen):
def on_mount(self): 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: if self.__shiftedSeasonId is not None:
shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId) shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId)

View File

@@ -109,5 +109,12 @@ class ShowDeleteScreen(Screen):
if event.button.id == "cancel_button": if event.button.id == "cancel_button":
self.app.pop_screen() 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): def action_back(self):
go_back_or_exit(self) go_back_or_exit(self)

View File

@@ -175,6 +175,9 @@ class ShowDetailsScreen(Screen):
def on_mount(self): 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: if self.__showDescriptor is not None:
showId = int(self.__showDescriptor.getId()) showId = int(self.__showDescriptor.getId())

View File

@@ -244,6 +244,10 @@ class ShowsScreen(Screen):
def on_mount(self) -> None: 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(): for show in self.__sc.getAllShows():
self._add_show_row(show.getDescriptor(self.context)) self._add_show_row(show.getDescriptor(self.context))

View File

@@ -64,6 +64,9 @@ class TagDeleteScreen(Screen):
def on_mount(self): 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("#keylabel", Static).update(str(self.__key))
self.query_one("#valuelabel", Static).update(str(self.__value)) self.query_one("#valuelabel", Static).update(str(self.__value))

View File

@@ -87,6 +87,9 @@ class TagDetailsScreen(Screen):
def on_mount(self): 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: if self.__key is not None:
self.query_one("#key_input", Input).value = str(self.__key) self.query_one("#key_input", Input).value = str(self.__key)

View File

@@ -4,7 +4,7 @@ from enum import Enum
class TrackCodec(Enum): class TrackCodec(Enum):
VP9 = {'identifier': 'vp9', 'format': 'ivf', 'extension': 'ivf' , 'label': 'VP9'} 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'} H264 = {'identifier': 'h264', 'format': 'h264', 'extension': 'h264' ,'label': 'H.264'}
MPEG4 = {'identifier': 'mpeg4', 'format': 'm4v', 'extension': 'm4v' ,'label': 'MPEG-4'} MPEG4 = {'identifier': 'mpeg4', 'format': 'm4v', 'extension': 'm4v' ,'label': 'MPEG-4'}
MPEG2 = {'identifier': 'mpeg2video', 'format': 'mpeg2video', 'extension': 'mpg' ,'label': 'MPEG-2'} 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'} WEBVTT = {'identifier': 'webvtt', 'format': 'webvtt', 'extension': 'vtt' , 'label': 'WebVTT'}
SRT = {'identifier': 'subrip', 'format': 'srt', 'extension': 'srt' , 'label': 'SRT'} SRT = {'identifier': 'subrip', 'format': 'srt', 'extension': 'srt' , 'label': 'SRT'}
ASS = {'identifier': 'ass', 'format': 'ass', 'extension': 'ass' , 'label': 'ASS'} 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'} PGS = {'identifier': 'hdmv_pgs_subtitle', 'format': 'sup', 'extension': 'sup' , 'label': 'PGS'}
VOBSUB = {'identifier': 'dvd_subtitle', 'format': None, 'extension': 'mkv' , 'label': 'VobSub'} VOBSUB = {'identifier': 'dvd_subtitle', 'format': None, 'extension': 'mkv' , 'label': 'VobSub'}

View File

@@ -43,7 +43,7 @@ class TrackController():
s = self.Session() s = self.Session()
track = Track(pattern_id = patId, track = Track(pattern_id = patId,
track_type = int(trackDescriptor.getType().index()), track_type = int(trackDescriptor.getType().index()),
codec_name = str(trackDescriptor.getCodec().identifier()), codec_name = str(trackDescriptor.getFormatDescriptor().identifier()),
index = int(trackDescriptor.getIndex()), index = int(trackDescriptor.getIndex()),
source_index = int(trackDescriptor.getSourceIndex()), source_index = int(trackDescriptor.getSourceIndex()),
disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet())), disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet())),
@@ -82,7 +82,7 @@ class TrackController():
track.index = int(trackDescriptor.getIndex()) track.index = int(trackDescriptor.getIndex())
track.track_type = int(trackDescriptor.getType().index()) 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.audio_layout = int(trackDescriptor.getAudioLayout().index())
track.disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet())) track.disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet()))

View File

@@ -67,6 +67,9 @@ class TrackDeleteScreen(Screen):
def on_mount(self): 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("#subindexlabel", Static).update(str(self.__trackDescriptor.getSubIndex()))
self.query_one("#patternlabel", Static).update(str(self.__trackDescriptor.getPatternId())) self.query_one("#patternlabel", Static).update(str(self.__trackDescriptor.getPatternId()))
self.query_one("#languagelabel", Static).update(str(self.__trackDescriptor.getLanguage().label())) self.query_one("#languagelabel", Static).update(str(self.__trackDescriptor.getLanguage().label()))

View File

@@ -1,5 +1,6 @@
from typing import Self from typing import Self
from .attachment_format import AttachmentFormat
from .iso_language import IsoLanguage from .iso_language import IsoLanguage
from .track_type import TrackType from .track_type import TrackType
from .audio_layout import AudioLayout from .audio_layout import AudioLayout
@@ -26,6 +27,7 @@ class TrackDescriptor:
TRACK_TYPE_KEY = "track_type" TRACK_TYPE_KEY = "track_type"
CODEC_KEY = "codec_name" CODEC_KEY = "codec_name"
ATTACHMENT_FORMAT_KEY = "attachment_format"
AUDIO_LAYOUT_KEY = "audio_layout" AUDIO_LAYOUT_KEY = "audio_layout"
FFPROBE_INDEX_KEY = "index" FFPROBE_INDEX_KEY = "index"
@@ -110,15 +112,6 @@ class TrackDescriptor:
else: else:
self.__trackType = TrackType.UNKNOWN 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 TrackDescriptor.TAGS_KEY in kwargs.keys():
if type(kwargs[TrackDescriptor.TAGS_KEY]) is not dict: if type(kwargs[TrackDescriptor.TAGS_KEY]) is not dict:
raise TypeError( raise TypeError(
@@ -151,6 +144,34 @@ class TrackDescriptor:
else: else:
self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED 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 @classmethod
def fromFfprobe(cls, streamObj, subIndex: int = -1): def fromFfprobe(cls, streamObj, subIndex: int = -1):
"""Processes ffprobe stream data as array with elements according to the following example """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.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] = ( kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = (
{ {
@@ -277,6 +303,14 @@ class TrackDescriptor:
def getCodec(self) -> TrackCodec: def getCodec(self) -> TrackCodec:
return 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): def getLanguage(self):
if "language" in self.__trackTags.keys(): if "language" in self.__trackTags.keys():
return IsoLanguage.findThreeLetter(self.__trackTags["language"]) return IsoLanguage.findThreeLetter(self.__trackTags["language"])
@@ -353,12 +387,16 @@ class TrackDescriptor:
TrackDescriptor.SOURCE_INDEX_KEY: int(self.__sourceIndex), TrackDescriptor.SOURCE_INDEX_KEY: int(self.__sourceIndex),
TrackDescriptor.SUB_INDEX_KEY: int(self.__subIndex), TrackDescriptor.SUB_INDEX_KEY: int(self.__subIndex),
TrackDescriptor.TRACK_TYPE_KEY: self.__trackType, TrackDescriptor.TRACK_TYPE_KEY: self.__trackType,
TrackDescriptor.CODEC_KEY: self.__trackCodec,
TrackDescriptor.TAGS_KEY: dict(self.__trackTags), TrackDescriptor.TAGS_KEY: dict(self.__trackTags),
TrackDescriptor.DISPOSITION_SET_KEY: set(self.__dispositionSet), TrackDescriptor.DISPOSITION_SET_KEY: set(self.__dispositionSet),
TrackDescriptor.AUDIO_LAYOUT_KEY: self.__audioLayout, 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: if context is not None:
kwargs[TrackDescriptor.CONTEXT_KEY] = context kwargs[TrackDescriptor.CONTEXT_KEY] = context
elif self.__context: elif self.__context:

View File

@@ -5,6 +5,7 @@ from textual.widgets import Header, Footer, Static, Button, SelectionList, Selec
from textual.containers import Grid from textual.containers import Grid
from textual.widgets._data_table import CellDoesNotExist from textual.widgets._data_table import CellDoesNotExist
from .attachment_format import AttachmentFormat
from .audio_layout import AudioLayout from .audio_layout import AudioLayout
from .iso_language import IsoLanguage from .iso_language import IsoLanguage
from .tag_delete_screen import TagDeleteScreen from .tag_delete_screen import TagDeleteScreen
@@ -141,6 +142,7 @@ class TrackDetailsScreen(Screen):
if self.__isNew: if self.__isNew:
self.__trackType = trackType self.__trackType = trackType
self.__trackCodec = TrackCodec.UNKNOWN self.__trackCodec = TrackCodec.UNKNOWN
self.__attachmentFormat = AttachmentFormat.UNKNOWN
self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED
self.__index = index self.__index = index
self.__subIndex = subIndex self.__subIndex = subIndex
@@ -150,6 +152,7 @@ class TrackDetailsScreen(Screen):
else: else:
self.__trackType = trackDescriptor.getType() self.__trackType = trackDescriptor.getType()
self.__trackCodec = trackDescriptor.getCodec() self.__trackCodec = trackDescriptor.getCodec()
self.__attachmentFormat = trackDescriptor.getAttachmentFormat()
self.__audioLayout = trackDescriptor.getAudioLayout() self.__audioLayout = trackDescriptor.getAudioLayout()
self.__index = trackDescriptor.getIndex() self.__index = trackDescriptor.getIndex()
self.__subIndex = trackDescriptor.getSubIndex() self.__subIndex = trackDescriptor.getSubIndex()
@@ -236,6 +239,9 @@ class TrackDetailsScreen(Screen):
def on_mount(self): 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( self.query_one("#index_label", Static).update(
str(self.__index) if self.__index is not None else "-" str(self.__index) if self.__index is not None else "-"
) )
@@ -430,6 +436,9 @@ class TrackDetailsScreen(Screen):
if not isinstance(selectedTrackType, TrackType): if not isinstance(selectedTrackType, TrackType):
selectedTrackType = TrackType.UNKNOWN selectedTrackType = TrackType.UNKNOWN
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = selectedTrackType kwargs[TrackDescriptor.TRACK_TYPE_KEY] = selectedTrackType
if selectedTrackType == TrackType.ATTACHMENT:
kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = self.__attachmentFormat
else:
kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec
if selectedTrackType == TrackType.AUDIO: if selectedTrackType == TrackType.AUDIO:

View File

@@ -7,6 +7,7 @@ import os
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import sys import sys
from functools import lru_cache
from typing import Mapping from typing import Mapping
@@ -95,8 +96,69 @@ def write_vtt(path: Path, lines: tuple[str, ...]) -> Path:
return 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 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_video = any(track.track_type == TrackType.VIDEO for track in tracks)
has_audio = any(track.track_type == TrackType.AUDIO 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 += map_tokens
command += metadata_tokens command += metadata_tokens
command += disposition_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 += [ command += [
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-crf",
"35",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"48k",
"-c:s",
"webvtt",
"-t", "-t",
str(duration_seconds), str(duration_seconds),
"-shortest", "-shortest",

View File

@@ -18,7 +18,7 @@ from ffx.track_type import TrackType # noqa: E402
class UnmuxSequenceTests(unittest.TestCase): 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( track_descriptor = TrackDescriptor(
index=0, index=0,
sub_index=0, sub_index=0,
@@ -46,8 +46,6 @@ class UnmuxSequenceTests(unittest.TestCase):
"copy", "copy",
"-bsf:v", "-bsf:v",
"hevc_mp4toannexb", "hevc_mp4toannexb",
"-f",
"h265",
"episode_0_eng.h265", "episode_0_eng.h265",
], ],
sequence, sequence,

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import click
from pathlib import Path from pathlib import Path
import sys import sys
import unittest import unittest
@@ -33,6 +34,9 @@ class StaticConfig:
class FfxControllerTests(unittest.TestCase): class FfxControllerTests(unittest.TestCase):
def tearDown(self):
FfxController.isFfmpegEncoderAvailable.cache_clear()
def make_context(self, video_encoder: VideoEncoder) -> dict: def make_context(self, video_encoder: VideoEncoder) -> dict:
return { return {
"logger": get_ffx_logger(), "logger": get_ffx_logger(),
@@ -318,6 +322,61 @@ class FfxControllerTests(unittest.TestCase):
self.assertNotIn("libopus", commands[0]) self.assertNotIn("libopus", commands[0])
self.assertFalse(any(token.startswith("-b:a") for token in 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])) 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__": if __name__ == "__main__":

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import sys import sys
import tempfile
import unittest 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.logging_utils import get_ffx_logger # noqa: E402
from ffx.track_codec import TrackCodec # noqa: E402 from ffx.track_codec import TrackCodec # noqa: E402
from ffx.track_type import TrackType # noqa: E402 from ffx.track_type import TrackType # noqa: E402
from tests.support.ffx_bundle import SourceTrackSpec, create_source_fixture # noqa: E402
class StaticConfig: class StaticConfig:
@@ -39,10 +41,26 @@ class FilePropertiesAssetProbeTests(unittest.TestCase):
} }
set_current_language("de") set_current_language("de")
media_path = ( with tempfile.TemporaryDirectory() as tmpdir:
Path(__file__).resolve().parents[1] media_path = create_source_fixture(
/ "assets" Path(tmpdir),
/ "Boruto; Naruto Next Generations (2017) - 0069 Super-Chochos Liebestaumel - S01E0069.webm" "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)) file_properties = FileProperties(context, str(media_path))

View File

@@ -15,6 +15,7 @@ if str(SRC_ROOT) not in sys.path:
from ffx.logging_utils import get_ffx_logger # noqa: E402 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.media_descriptor import MediaDescriptor # noqa: E402
from ffx.metadata_editor import ( # noqa: E402 from ffx.metadata_editor import ( # noqa: E402
apply_metadata_edits, apply_metadata_edits,
@@ -33,6 +34,16 @@ class StaticConfig:
return {} 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: def make_context(*, dry_run: bool = False) -> dict:
return { return {
"logger": get_ffx_logger(), "logger": get_ffx_logger(),
@@ -151,7 +162,7 @@ class MetadataEditorTests(unittest.TestCase):
context = make_context(dry_run=True) context = make_context(dry_run=True)
baseline_descriptor = make_descriptor() baseline_descriptor = make_descriptor()
draft_descriptor = baseline_descriptor.clone(context=context) draft_descriptor = baseline_descriptor.clone(context=context)
notifications = [] notifications = NotificationCollector()
expected_command = build_metadata_edit_command( expected_command = build_metadata_edit_command(
build_metadata_edit_context(context), build_metadata_edit_context(context),
"/tmp/example.mkv", "/tmp/example.mkv",
@@ -170,12 +181,13 @@ class MetadataEditorTests(unittest.TestCase):
"/tmp/example.mkv", "/tmp/example.mkv",
baseline_descriptor, baseline_descriptor,
draft_descriptor, draft_descriptor,
loggingHandler = notifications.append, loggingHandler = notifications,
) )
mocked_execute.assert_not_called() mocked_execute.assert_not_called()
mocked_replace.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( self.assertEqual(
{ {
"applied": False, "applied": False,
@@ -204,7 +216,7 @@ class MetadataEditorTests(unittest.TestCase):
context["verbosity"] = 1 context["verbosity"] = 1
baseline_descriptor = make_descriptor() baseline_descriptor = make_descriptor()
draft_descriptor = baseline_descriptor.clone(context=context) draft_descriptor = baseline_descriptor.clone(context=context)
notifications = [] notifications = NotificationCollector()
with ( with (
patch("ffx.metadata_editor.create_temporary_output_path", return_value="/tmp/.edit.mkv"), patch("ffx.metadata_editor.create_temporary_output_path", return_value="/tmp/.edit.mkv"),
@@ -216,11 +228,12 @@ class MetadataEditorTests(unittest.TestCase):
"/tmp/example.mkv", "/tmp/example.mkv",
baseline_descriptor, baseline_descriptor,
draft_descriptor, draft_descriptor,
loggingHandler = notifications.append, loggingHandler = notifications,
) )
self.assertEqual(1, len(notifications)) self.assertEqual(1, len(notifications.messages))
self.assertTrue(notifications[0].startswith("ffmpeg: ffmpeg ")) self.assertTrue(notifications.messages[0].startswith("ffmpeg: ffmpeg "))
self.assertEqual([LogLevel.DEBUG], notifications.levels)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -14,6 +14,7 @@ if str(SRC_ROOT) not in sys.path:
from ffx.audio_layout import AudioLayout # noqa: E402 from ffx.audio_layout import AudioLayout # noqa: E402
from ffx.attachment_format import AttachmentFormat # noqa: E402
from ffx.helper import DIFF_ADDED_KEY # noqa: E402 from ffx.helper import DIFF_ADDED_KEY # noqa: E402
from ffx.iso_language import IsoLanguage # noqa: E402 from ffx.iso_language import IsoLanguage # noqa: E402
from ffx.logging_utils import get_ffx_logger # 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("German Audio", descriptor.getTitle())
self.assertEqual("value", descriptor.getTags()["KEEP"]) 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): def test_track_details_screen_auto_sets_localized_title_from_selected_language(self):
set_current_language("de") set_current_language("de")
screen = object.__new__(TrackDetailsScreen) screen = object.__new__(TrackDetailsScreen)
@@ -695,6 +722,33 @@ class TagTableScreenStateTests(unittest.TestCase):
self.assertIn("English Full", screen.tracksTable.rows["row-0"]) self.assertIn("English Full", screen.tracksTable.rows["row-0"])
self.assertIs(target_track, screen.getSelectedTrackDescriptor()) 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): def test_inspect_details_screen_maps_target_selection_back_to_source_track(self):
source_track = TrackDescriptor( source_track = TrackDescriptor(
index=3, index=3,

View File

@@ -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()

View File

@@ -172,20 +172,84 @@ fetch_remote_state() {
git fetch "${ORIGIN_REMOTE}" "${DEV_BRANCH}" "${MAIN_BRANCH}" --tags >/dev/null git fetch "${ORIGIN_REMOTE}" "${DEV_BRANCH}" "${MAIN_BRANCH}" --tags >/dev/null
} }
require_branch_matches_remote() { branch_divergence_counts() {
local branch="$1" local branch="$1"
local local_sha="" local remote_only=""
local remote_sha="" local local_only=""
if ! git show-ref --verify --quiet "refs/remotes/${ORIGIN_REMOTE}/${branch}"; then if ! git show-ref --verify --quiet "refs/remotes/${ORIGIN_REMOTE}/${branch}"; then
fail "Remote branch '${ORIGIN_REMOTE}/${branch}' does not exist." fail "Remote branch '${ORIGIN_REMOTE}/${branch}' does not exist."
fi fi
local_sha="$(git rev-parse "refs/heads/${branch}")" read -r remote_only local_only < <(
remote_sha="$(git rev-parse "refs/remotes/${ORIGIN_REMOTE}/${branch}")" git rev-list --left-right --count \
"refs/remotes/${ORIGIN_REMOTE}/${branch}...refs/heads/${branch}"
)
if [ "${local_sha}" != "${remote_sha}" ]; then printf '%s %s\n' "${remote_only}" "${local_only}"
fail "Local branch '${branch}' is not up to date with '${ORIGIN_REMOTE}/${branch}'. Pull, rebase, or push first." }
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 fi
} }
@@ -249,13 +313,11 @@ print_release_plan() {
printf 'Dry run only. Planned steps:\n' printf 'Dry run only. Planned steps:\n'
printf '1. Ensure current branch is %s and the worktree is clean.\n' "${DEV_BRANCH}" 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}" \ "${ORIGIN_REMOTE}" \
"${DEV_BRANCH}" \ "${DEV_BRANCH}" \
"${MAIN_BRANCH}" \ "${MAIN_BRANCH}" \
"${ORIGIN_REMOTE}" \ "${ORIGIN_REMOTE}" \
"${DEV_BRANCH}" \
"${ORIGIN_REMOTE}" \
"${MAIN_BRANCH}" "${MAIN_BRANCH}"
if [ "${SKIP_TESTS}" -eq 1 ]; then if [ "${SKIP_TESTS}" -eq 1 ]; then
printf '3. Skip the pre-release test gate.\n' printf '3. Skip the pre-release test gate.\n'
@@ -304,8 +366,8 @@ require_repo_state
require_dev_checkout require_dev_checkout
require_clean_worktree require_clean_worktree
fetch_remote_state fetch_remote_state
require_branch_matches_remote "${DEV_BRANCH}" sync_release_source_branch "${DEV_BRANCH}"
require_branch_matches_remote "${MAIN_BRANCH}" sync_release_target_branch "${MAIN_BRANCH}"
RELEASE_VERSION="$(resolve_release_version)" RELEASE_VERSION="$(resolve_release_version)"
RELEASE_TAG="v${RELEASE_VERSION}" RELEASE_TAG="v${RELEASE_VERSION}"
@@ -341,8 +403,8 @@ fi
run_pre_release_tests run_pre_release_tests
require_clean_worktree require_clean_worktree
fetch_remote_state fetch_remote_state
require_branch_matches_remote "${DEV_BRANCH}" sync_release_source_branch "${DEV_BRANCH}"
require_branch_matches_remote "${MAIN_BRANCH}" sync_release_target_branch "${MAIN_BRANCH}"
require_release_tag_available "${RELEASE_VERSION}" require_release_tag_available "${RELEASE_VERSION}"
git switch "${MAIN_BRANCH}" >/dev/null git switch "${MAIN_BRANCH}" >/dev/null