From 849d03d054cb77a7395daf0fef8258c464814512 Mon Sep 17 00:00:00 2001 From: Javanaut Date: Thu, 16 Apr 2026 19:26:17 +0200 Subject: [PATCH] v0.3.1 --- README.md | 7 ++++ pyproject.toml | 2 +- requirements/project.md | 2 +- src/ffx/constants.py | 2 +- src/ffx/ffx_controller.py | 61 +++++++++++++++++++++++++++++-- tests/support/ffx_bundle.py | 44 ++++++++++++++++++++++ tests/unit/test_ffx_controller.py | 60 ++++++++++++++++++++++++++++++ 7 files changed, 171 insertions(+), 7 deletions(-) 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/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/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 9ec9600..a9bbe1e 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/tests/support/ffx_bundle.py b/tests/support/ffx_bundle.py index 543c5ef..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,6 +96,45 @@ def write_vtt(path: Path, lines: tuple[str, ...]) -> Path: return 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, @@ -115,6 +155,10 @@ def create_source_fixture( 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) diff --git a/tests/unit/test_ffx_controller.py b/tests/unit/test_ffx_controller.py index 0102113..bfe4a73 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 @@ -32,6 +33,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(), @@ -192,6 +196,62 @@ class FfxControllerTests(unittest.TestCase): self.assertIn("ENCODING_QUALITY=19", commands[0]) mocked_info.assert_any_call("Setting quality 19 from pattern") + 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__": unittest.main()