From 14e6ce84585bb866ab73ab94932a254c28d407da Mon Sep 17 00:00:00 2001 From: Javanaut Date: Tue, 14 Apr 2026 10:04:39 +0200 Subject: [PATCH 01/11] Fix logging --- src/ffx/cli.py | 40 +++-- src/ffx/ffx_app.py | 13 +- src/ffx/helper.py | 13 +- src/ffx/logging_utils.py | 29 ++++ src/ffx/media_edit_screen.py | 44 +++--- src/ffx/metadata_editor.py | 22 +-- src/ffx/screen_support.py | 217 +++++++++++++++++++++++++--- tests/unit/test_cli_lazy_imports.py | 34 +++++ tests/unit/test_logging.py | 29 ++++ tests/unit/test_metadata_editor.py | 4 +- tests/unit/test_screen_support.py | 87 +++++++++++ 11 files changed, 465 insertions(+), 67 deletions(-) diff --git a/src/ffx/cli.py b/src/ffx/cli.py index d827108..bd8f8f7 100755 --- a/src/ffx/cli.py +++ b/src/ffx/cli.py @@ -252,9 +252,15 @@ def buildRenameTargetFilename( @click.pass_context @click.option('--language', 'app_language', type=str, default='', help='Set application language') @click.option('--database-file', type=str, default='', help='Path to database file') +@click.option( + '--debug', + is_flag=True, + default=False, + help='Enable debug-only TUI diagnostics such as the log pane', +) @click.option('-v', '--verbose', type=int, default=0, help='Set verbosity of output') @click.option("--dry-run", is_flag=True, default=False) -def ffx(ctx, app_language, database_file, verbose, dry_run): +def ffx(ctx, app_language, database_file, debug, verbose, dry_run): """FFX""" ctx.obj = {} @@ -274,6 +280,7 @@ def ffx(ctx, app_language, database_file, verbose, dry_run): ) set_current_language(resolvedLanguage) ctx.obj['language'] = resolvedLanguage + ctx.obj['debug'] = bool(debug) if ctx.invoked_subcommand in LIGHTWEIGHT_COMMANDS: ctx.obj['dry_run'] = dry_run @@ -287,6 +294,7 @@ def ffx(ctx, app_language, database_file, verbose, dry_run): ctx.obj['dry_run'] = dry_run ctx.obj['verbosity'] = verbose + ctx.obj['debug'] = bool(debug) ctx.obj['language'] = resolve_application_language( cli_language=app_language, config_language=ctx.obj['config'].getLanguage(), @@ -391,6 +399,20 @@ def runScriptWrapper(ctx, scriptPath, missingDescription, commandArgs): ctx.exit(completed.returncode) +def runTuiApp(ctx) -> None: + from ffx.ffx_app import FfxApp + from ffx.logging_utils import set_ffx_console_logging_enabled + + logger = ctx.obj.get('logger') + set_ffx_console_logging_enabled(logger, enabled=False) + + try: + app = FfxApp(ctx.obj) + app.run() + finally: + set_ffx_console_logging_enabled(logger, enabled=True) + + @ffx.command(name='setup') @click.pass_context @click.option('--check', is_flag=True, default=False, help='Only verify bundle-setup readiness') @@ -527,14 +549,11 @@ def inspect(ctx, shift, filenames): if len(filenames) != 1: raise click.ClickException("Inspect without --shift requires exactly one filename.") - from ffx.ffx_app import FfxApp - ctx.obj['command'] = 'inspect' ctx.obj['arguments'] = {} ctx.obj['arguments']['filename'] = filenames[0] - app = FfxApp(ctx.obj) - app.run() + runTuiApp(ctx) @ffx.command() @@ -544,8 +563,6 @@ def edit(ctx, filename): if not os.path.isfile(filename): raise click.ClickException(f"File not found: {filename}") - from ffx.ffx_app import FfxApp - ctx.obj['command'] = 'edit' ctx.obj['arguments'] = {'filename': filename} ctx.obj['use_pattern'] = False @@ -554,8 +571,7 @@ def edit(ctx, filename): ctx.obj['apply_metadata_normalization'] = True ctx.obj['resource_limits'] = ctx.obj.get('resource_limits', {}) - app = FfxApp(ctx.obj) - app.run() + runTuiApp(ctx) @ffx.command() @@ -837,12 +853,8 @@ def cropdetect(ctx, @click.pass_context def shows(ctx): - from ffx.ffx_app import FfxApp - ctx.obj['command'] = 'shows' - - app = FfxApp(ctx.obj) - app.run() + runTuiApp(ctx) def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor): diff --git a/src/ffx/ffx_app.py b/src/ffx/ffx_app.py index 8796395..58d0bf8 100644 --- a/src/ffx/ffx_app.py +++ b/src/ffx/ffx_app.py @@ -4,7 +4,7 @@ from .i18n import set_current_language, t from .shows_screen import ShowsScreen from .inspect_details_screen import InspectDetailsScreen from .media_edit_screen import MediaEditScreen -from .screen_support import toggle_screen_log_pane +from .screen_support import configure_screen_log_handler, set_screen_log_pane_enabled class FfxApp(App): @@ -14,7 +14,6 @@ class FfxApp(App): BINDINGS = [ ("q", "quit()", t("Quit")), ("h", "switch_mode('help')", t("Help")), - ("l", "toggle_log_pane", t("Log")), ] @@ -24,6 +23,13 @@ class FfxApp(App): # Data 'input' variable self.context = context set_current_language(self.context.get("language")) + debug_mode = bool(self.context.get("debug", False)) + set_screen_log_pane_enabled(debug_mode) + configure_screen_log_handler( + self.context.get("logger"), + self, + enabled=debug_mode, + ) def on_mount(self) -> None: @@ -43,6 +49,3 @@ class FfxApp(App): def getContext(self): """Data 'output' method""" return self.context - - def action_toggle_log_pane(self) -> None: - toggle_screen_log_pane(self.screen) diff --git a/src/ffx/helper.py b/src/ffx/helper.py index 00f1d45..9fb528a 100644 --- a/src/ffx/helper.py +++ b/src/ffx/helper.py @@ -6,12 +6,23 @@ from .configuration_controller import ConfigurationController from .logging_utils import get_ffx_logger from .show_descriptor import ShowDescriptor +from enum import Enum + class EmptyStringUndefined(Undefined): def __str__(self): return '' +class LogLevel(Enum): + + DEBUG = 'debug' + INFO = 'info' + WARNING = 'warning' + ERROR = 'error' + CRITICAL = 'critical' + + DIFF_ADDED_KEY = 'added' DIFF_REMOVED_KEY = 'removed' DIFF_CHANGED_KEY = 'changed' @@ -119,7 +130,7 @@ def setDiff(a : set, b : set) -> set: def permutateList(inputList: list, permutation: list): # 0,1,2: ABC - # 0,2,1: ACB + # 0,2,1: ACBffmpeg: # 1,2,0: BCA pass diff --git a/src/ffx/logging_utils.py b/src/ffx/logging_utils.py index 1e27601..d426191 100644 --- a/src/ffx/logging_utils.py +++ b/src/ffx/logging_utils.py @@ -5,6 +5,7 @@ import os FFX_LOGGER_NAME = "FFX" CONSOLE_HANDLER_NAME = "ffx-console" FILE_HANDLER_NAME = "ffx-file" +MUTED_CONSOLE_LEVEL = logging.CRITICAL + 1 def get_ffx_logger(name: str = FFX_LOGGER_NAME) -> logging.Logger: @@ -66,3 +67,31 @@ def configure_ffx_logger( ) return logger + + +def set_ffx_console_logging_enabled( + logger: logging.Logger | None, + *, + enabled: bool, +): + if logger is None: + return None + + console_handler = next( + (handler for handler in logger.handlers if handler.get_name() == CONSOLE_HANDLER_NAME), + None, + ) + if console_handler is None: + return None + + if enabled: + saved_level = getattr(console_handler, "_ffx_saved_level", None) + if saved_level is not None: + console_handler.setLevel(saved_level) + delattr(console_handler, "_ffx_saved_level") + return console_handler + + if not hasattr(console_handler, "_ffx_saved_level"): + console_handler._ffx_saved_level = console_handler.level + console_handler.setLevel(MUTED_CONSOLE_LEVEL) + return console_handler diff --git a/src/ffx/media_edit_screen.py b/src/ffx/media_edit_screen.py index 8c8692d..5e27b3d 100644 --- a/src/ffx/media_edit_screen.py +++ b/src/ffx/media_edit_screen.py @@ -12,11 +12,13 @@ from ffx.track_descriptor import TrackDescriptor from .i18n import t from .confirm_screen import ConfirmScreen from .media_workflow_screen_base import MediaWorkflowScreenBase -from .screen_support import build_screen_log_pane, localized_column_width, write_screen_log +from .screen_support import build_screen_log_pane, localized_column_width from .tag_delete_screen import TagDeleteScreen from .tag_details_screen import TagDetailsScreen from .track_details_screen import TrackDetailsScreen +from .helper import LogLevel + class MediaEditScreen(MediaWorkflowScreenBase): @@ -207,9 +209,24 @@ class MediaEditScreen(MediaWorkflowScreenBase): if self._messageText: self.notify(self._messageText) - def _notify_from_worker(self, message: str) -> None: - self.app.call_from_thread(write_screen_log, self, str(message)) - self.app.call_from_thread(self.notify, str(message)) + + def workerLoggingHandler(self, + message: str, + level: LogLevel = LogLevel.INFO) -> None: + + if level == LogLevel.DEBUG: + self.context["logger"].debug(str(message)) + elif level == LogLevel.INFO: + self.context["logger"].info(str(message)) + elif level == LogLevel.WARNING: + self.context["logger"].warning(str(message)) + elif level == LogLevel.ERROR: + self.context["logger"].error(str(message)) + elif level == LogLevel.CRITICAL: + self.context["logger"].critical(str(message)) + else: + raise Exception(f"Undefined Logging Level (msg={message})") + def _report_apply_timings(self, applyResult: dict, reloadSeconds: float = 0.0) -> None: timings = dict(applyResult.get("timings", {})) @@ -226,10 +243,6 @@ class MediaEditScreen(MediaWorkflowScreenBase): + f"total={totalSeconds:.2f}s" ) self.context["logger"].info(timingSummary) - write_screen_log(self, timingSummary) - - if int(self.context.get("verbosity", 0) or 0) > 0: - self.notify(timingSummary) def updateToggleButtons(self): self._set_toggle_button_state( @@ -402,9 +415,8 @@ class MediaEditScreen(MediaWorkflowScreenBase): self.setMessage(t("Apply already running.")) return - write_screen_log( - self, - t("Starting metadata apply for {filename}.", filename=self._mediaFilename), + self.context["logger"].info( + t("Starting metadata apply for {filename}.", filename=self._mediaFilename) ) self._applyChangesWorker = self.run_apply_changes_worker() @@ -420,7 +432,7 @@ class MediaEditScreen(MediaWorkflowScreenBase): self._mediaFilename, self._baselineMediaDescriptor, self._sourceMediaDescriptor, - notify=self._notify_from_worker, + loggingHandler = self.workerLoggingHandler, ) def on_worker_state_changed(self, event: Worker.StateChanged) -> None: @@ -435,7 +447,6 @@ class MediaEditScreen(MediaWorkflowScreenBase): self._mediaFilename, exc_info=(type(error), error, error.__traceback__), ) - write_screen_log(self, t("Apply failed: {error}", error=error)) self.setMessage(t("Apply failed: {error}", error=error)) self._applyChangesWorker = None return @@ -447,8 +458,7 @@ class MediaEditScreen(MediaWorkflowScreenBase): if applyResult.get("dry_run", False): self._report_apply_timings(applyResult, reloadSeconds=0.0) - write_screen_log( - self, + self.context["logger"].info( t( "Dry-run prepared temporary output {target_path}.", target_path=applyResult["target_path"], @@ -464,12 +474,12 @@ class MediaEditScreen(MediaWorkflowScreenBase): return reloadStart = monotonic() - write_screen_log(self, t("Reloading file after metadata write.")) + self.context["logger"].info(t("Reloading file after metadata write.")) self.reloadProperties(reset_draft=True) self.refreshAfterDraftChange() reloadSeconds = monotonic() - reloadStart self._report_apply_timings(applyResult, reloadSeconds=reloadSeconds) - write_screen_log(self, t("Changes applied and file reloaded.")) + self.context["logger"].info(t("Changes applied and file reloaded.")) self.setMessage(t("Changes applied and file reloaded.")) self._applyChangesWorker = None diff --git a/src/ffx/metadata_editor.py b/src/ffx/metadata_editor.py index 7ae5534..b6e63dd 100644 --- a/src/ffx/metadata_editor.py +++ b/src/ffx/metadata_editor.py @@ -16,6 +16,8 @@ from .media_descriptor_change_set import MediaDescriptorChangeSet from .process import executeProcess, formatCommandSequence from .video_encoder import VideoEncoder +from .helper import LogLevel + def create_temporary_output_path(source_path: str) -> str: sourceDirectory = os.path.dirname(os.path.abspath(source_path)) or "." @@ -75,22 +77,22 @@ def notify_ffmpeg_invocation( context: dict, command_sequence: list[str], *, - notify=None, + loggingHandler = None, dry_run: bool = False, ) -> None: - notify_callback = notify or context.get("notify_callback") - if not callable(notify_callback): + loggingCallback = loggingHandler or context.get("logging_handler") + if not callable(loggingCallback): return verbosity = int(context.get("verbosity", 0) or 0) if verbosity > 0: if dry_run: - notify_callback(f"ffmpeg dry-run: {formatCommandSequence(command_sequence)}") + loggingCallback(f"ffmpeg dry-run: {formatCommandSequence(command_sequence)}", level = LogLevel.DEBUG) else: - notify_callback(f"ffmpeg: {formatCommandSequence(command_sequence)}") + loggingCallback(f"ffmpeg: {formatCommandSequence(command_sequence)}", level = LogLevel.DEBUG) return - notify_callback("ffmpeg dry-run prepared.") if dry_run else notify_callback( + loggingCallback("ffmpeg dry-run prepared.") if dry_run else loggingCallback( "ffmpeg metadata write started." ) @@ -101,7 +103,7 @@ def apply_metadata_edits( baseline_descriptor: MediaDescriptor, draft_descriptor: MediaDescriptor, *, - notify=None, + loggingHandler = None, ) -> dict[str, object]: temporaryOutputPath = create_temporary_output_path(source_path) @@ -126,7 +128,7 @@ def apply_metadata_edits( notify_ffmpeg_invocation( editContext, commandSequence, - notify=notify, + loggingHandler = loggingHandler, dry_run=True, ) @@ -142,7 +144,9 @@ def apply_metadata_edits( }, } - notify_ffmpeg_invocation(editContext, commandSequence, notify=notify) + notify_ffmpeg_invocation(editContext, + commandSequence, + loggingHandler = loggingHandler) ffmpegStart = monotonic() _out, err, rc = executeProcess(commandSequence, context=editContext) diff --git a/src/ffx/screen_support.py b/src/ffx/screen_support.py index a60e497..03cbcc9 100644 --- a/src/ffx/screen_support.py +++ b/src/ffx/screen_support.py @@ -1,12 +1,15 @@ from __future__ import annotations +import logging +import weakref from collections.abc import Mapping from dataclasses import dataclass from rich.cells import cell_len from rich.measure import measure_renderables from rich.text import Text -from textual.widgets import Collapsible, RichLog +from textual import events +from textual.widgets import Collapsible, RichLog, Static from .helper import formatRichColor from .i18n import t @@ -20,6 +23,152 @@ from .track_controller import TrackController SCREEN_LOG_PANE_ID = "screen_log_pane" SCREEN_LOG_VIEW_ID = "screen_log_view" +SCREEN_LOG_RESIZE_HANDLE_ID = "screen_log_resize_handle" +SCREEN_LOG_HANDLER_NAME = "ffx-screen-log" +SCREEN_LOG_DEFAULT_HEIGHT = 8 +SCREEN_LOG_MIN_HEIGHT = 4 +SCREEN_LOG_COMPONENT_WIDTH = 16 +SCREEN_LOG_LEVEL_WIDTH = 8 + +_SCREEN_LOG_PANE_ENABLED = False + + +class ScreenLogHandler(logging.Handler): + """Mirror logger output into the active screen log pane when available.""" + + def __init__(self, app) -> None: + super().__init__(level=logging.DEBUG) + self.set_name(SCREEN_LOG_HANDLER_NAME) + self.set_app(app) + + def set_app(self, app) -> None: + self._app_ref = weakref.ref(app) if app is not None else lambda: None + + def emit(self, record: logging.LogRecord) -> None: + app = self._app_ref() + if app is None: + return + + try: + message = str(self.format(record)).strip() + except Exception: + self.handleError(record) + return + + if not message: + return + + try: + app.call_from_thread(write_screen_log, app.screen, message) + except RuntimeError: + write_screen_log(app.screen, message) + except Exception: + self.handleError(record) + + +class ScreenLogResizeHandle(Static): + DEFAULT_CSS = """ + ScreenLogResizeHandle { + width: 100%; + height: 1; + content-align: center middle; + color: $text-muted; + background: $panel-lighten-1; + } + + ScreenLogResizeHandle:hover { + color: $text; + background: $panel-lighten-2; + } + """ + + def __init__(self) -> None: + super().__init__(" drag to resize ", id=SCREEN_LOG_RESIZE_HANDLE_ID) + self._drag_active = False + self._drag_origin_screen_y = 0 + self._drag_origin_height = SCREEN_LOG_DEFAULT_HEIGHT + + def _get_log_pane(self): + return self.parent.parent if self.parent is not None else None + + def on_mouse_down(self, event: events.MouseDown) -> None: + if event.button != 1: + return + + log_pane = self._get_log_pane() + if log_pane is None: + return + + self._drag_active = True + self._drag_origin_screen_y = event.screen_y + self._drag_origin_height = log_pane.get_log_height() + self.capture_mouse() + event.stop() + + def on_mouse_move(self, event: events.MouseMove) -> None: + if not self._drag_active: + return + + log_pane = self._get_log_pane() + if log_pane is None: + return + + next_height = self._drag_origin_height + ( + self._drag_origin_screen_y - event.screen_y + ) + log_pane.set_log_height(next_height) + event.stop() + + def on_mouse_up(self, event: events.MouseUp) -> None: + if not self._drag_active: + return + + self._drag_active = False + self.release_mouse() + event.stop() + + +class ResizableScreenLogPane(Collapsible): + def __init__(self) -> None: + self._log_view = RichLog( + id=SCREEN_LOG_VIEW_ID, + wrap=True, + markup=False, + highlight=False, + auto_scroll=True, + ) + self._log_height = SCREEN_LOG_DEFAULT_HEIGHT + self._apply_log_height() + + super().__init__( + ScreenLogResizeHandle(), + self._log_view, + title=t("Log"), + collapsed=True, + id=SCREEN_LOG_PANE_ID, + ) + self.styles.width = "100%" + + def _apply_log_height(self) -> None: + self._log_view.styles.height = self._log_height + self._log_view.styles.width = "100%" + + def get_log_height(self) -> int: + return int(self._log_height) + + def set_log_height(self, height: int) -> None: + next_height = max(SCREEN_LOG_MIN_HEIGHT, int(height)) + + try: + available_height = int(self.app.size.height) - 8 + except Exception: + available_height = next_height + + if available_height > 0: + next_height = min(next_height, available_height) + + self._log_height = next_height + self._apply_log_height() @dataclass(frozen=True) @@ -52,6 +201,48 @@ def build_screen_bootstrap(context: dict) -> ScreenBootstrap: ) +def set_screen_log_pane_enabled(enabled: bool) -> None: + global _SCREEN_LOG_PANE_ENABLED + _SCREEN_LOG_PANE_ENABLED = bool(enabled) + + +def is_screen_log_pane_enabled() -> bool: + return bool(_SCREEN_LOG_PANE_ENABLED) + + +def configure_screen_log_handler(logger, app, *, enabled: bool): + if logger is None: + return None + + screen_log_handler = next( + (handler for handler in logger.handlers if handler.get_name() == SCREEN_LOG_HANDLER_NAME), + None, + ) + + if not enabled: + if screen_log_handler is not None: + logger.removeHandler(screen_log_handler) + screen_log_handler.close() + return None + + if screen_log_handler is None: + screen_log_handler = ScreenLogHandler(app) + logger.addHandler(screen_log_handler) + elif isinstance(screen_log_handler, ScreenLogHandler): + screen_log_handler.set_app(app) + + screen_log_handler.setLevel(logging.DEBUG) + screen_log_handler.setFormatter( + logging.Formatter( + f"%(name)-{SCREEN_LOG_COMPONENT_WIDTH}s " + + f"%(levelname)-{SCREEN_LOG_LEVEL_WIDTH}s " + + "%(asctime)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + return screen_log_handler + + def build_screen_controllers( context: dict, *, @@ -149,27 +340,15 @@ def update_table_column_label(table, column_key, label) -> None: table.refresh() -def build_screen_log_pane() -> Collapsible: +def build_screen_log_pane() -> ResizableScreenLogPane | Static: """Create a shared collapsible log pane for screen-local diagnostics.""" - logView = RichLog( - id=SCREEN_LOG_VIEW_ID, - wrap=True, - markup=False, - highlight=False, - auto_scroll=True, - ) - logView.styles.height = 8 - logView.styles.width = "100%" + if not is_screen_log_pane_enabled(): + hidden = Static("", id=f"{SCREEN_LOG_PANE_ID}_disabled") + hidden.display = False + return hidden - logPane = Collapsible( - logView, - title=t("Log"), - collapsed=True, - id=SCREEN_LOG_PANE_ID, - ) - logPane.styles.width = "100%" - return logPane + return ResizableScreenLogPane() def toggle_screen_log_pane(screen) -> bool: diff --git a/tests/unit/test_cli_lazy_imports.py b/tests/unit/test_cli_lazy_imports.py index b6bfddf..d3cb4f4 100644 --- a/tests/unit/test_cli_lazy_imports.py +++ b/tests/unit/test_cli_lazy_imports.py @@ -168,6 +168,40 @@ class CliLazyImportTests(unittest.TestCase): result["modules"], ) + def test_root_debug_flag_parses_without_loading_runtime_modules(self): + result = self.run_python( + textwrap.dedent( + f""" + import json + import sys + + sys.path.insert(0, {str(SRC_ROOT)!r}) + + import ffx.cli + + context = ffx.cli.ffx.make_context( + "ffx", + ["--debug", "help"], + resilient_parsing=True, + ) + + print(json.dumps({{ + "debug": context.params["debug"], + "modules": {{ + module_name: module_name in sys.modules + for module_name in {HEAVY_MODULES!r} + }}, + }})) + """ + ) + ) + + self.assertTrue(result["debug"]) + self.assertTrue( + all(not is_loaded for is_loaded in result["modules"].values()), + result["modules"], + ) + def test_convert_cut_option_supports_flag_duration_and_start_duration_forms(self): result = self.run_python( textwrap.dedent( diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py index 0f44c7f..3d24d2a 100644 --- a/tests/unit/test_logging.py +++ b/tests/unit/test_logging.py @@ -16,8 +16,10 @@ if str(SRC_ROOT) not in sys.path: from ffx.logging_utils import ( # noqa: E402 CONSOLE_HANDLER_NAME, FILE_HANDLER_NAME, + MUTED_CONSOLE_LEVEL, configure_ffx_logger, get_ffx_logger, + set_ffx_console_logging_enabled, ) @@ -81,6 +83,33 @@ class LoggingUtilsTests(unittest.TestCase): self.cleanup_logger(logger_name) + def test_set_ffx_console_logging_enabled_mutes_and_restores_console_handler(self): + logger_name = "ffx-test-console-mute" + self.cleanup_logger(logger_name) + + with tempfile.TemporaryDirectory() as tempdir: + log_path = Path(tempdir) / "ffx.log" + + logger = configure_ffx_logger( + str(log_path), + logging.DEBUG, + logging.INFO, + name=logger_name, + ) + console_handler = next( + handler for handler in logger.handlers if handler.get_name() == CONSOLE_HANDLER_NAME + ) + + self.assertEqual(logging.INFO, console_handler.level) + + set_ffx_console_logging_enabled(logger, enabled=False) + self.assertEqual(MUTED_CONSOLE_LEVEL, console_handler.level) + + set_ffx_console_logging_enabled(logger, enabled=True) + self.assertEqual(logging.INFO, console_handler.level) + + self.cleanup_logger(logger_name) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_metadata_editor.py b/tests/unit/test_metadata_editor.py index bcbb0fa..7b65a1a 100644 --- a/tests/unit/test_metadata_editor.py +++ b/tests/unit/test_metadata_editor.py @@ -170,7 +170,7 @@ class MetadataEditorTests(unittest.TestCase): "/tmp/example.mkv", baseline_descriptor, draft_descriptor, - notify=notifications.append, + loggingHandler = notifications.append, ) mocked_execute.assert_not_called() @@ -216,7 +216,7 @@ class MetadataEditorTests(unittest.TestCase): "/tmp/example.mkv", baseline_descriptor, draft_descriptor, - notify=notifications.append, + loggingHandler = notifications.append, ) self.assertEqual(1, len(notifications)) diff --git a/tests/unit/test_screen_support.py b/tests/unit/test_screen_support.py index 16f7984..a70cc94 100644 --- a/tests/unit/test_screen_support.py +++ b/tests/unit/test_screen_support.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +import logging import sys import unittest from unittest.mock import patch @@ -57,9 +58,38 @@ class FakeScreen: self.app = FakeApp(screen_stack) +class FakeRichLog: + def __init__(self): + self.messages = [] + + def write(self, message): + self.messages.append(message) + + +class FakeScreenWithLog: + def __init__(self): + self.log_view = FakeRichLog() + + def query_one(self, selector, _widget_type=None): + if selector == f"#{screen_support.SCREEN_LOG_VIEW_ID}": + return self.log_view + raise LookupError(selector) + + +class FakeThreadedApp: + def __init__(self, screen): + self.screen = screen + self.calls = [] + + def call_from_thread(self, func, *args): + self.calls.append((func, args)) + return func(*args) + + class ScreenSupportTests(unittest.TestCase): def tearDown(self): set_current_language("de") + screen_support.set_screen_log_pane_enabled(False) def make_context(self): return { @@ -168,6 +198,63 @@ class ScreenSupportTests(unittest.TestCase): self.assertGreater(len(translated), 8) self.assertEqual(len(translated) + 2, screen_support.localized_column_width(translated, 8)) + def test_build_screen_log_pane_is_hidden_when_debug_mode_is_disabled(self): + screen_support.set_screen_log_pane_enabled(False) + + log_pane = screen_support.build_screen_log_pane() + + self.assertFalse(log_pane.display) + + def test_build_screen_log_pane_is_collapsed_when_debug_mode_is_enabled(self): + screen_support.set_screen_log_pane_enabled(True) + + log_pane = screen_support.build_screen_log_pane() + + self.assertIsInstance(log_pane, screen_support.ResizableScreenLogPane) + self.assertEqual(screen_support.SCREEN_LOG_PANE_ID, log_pane.id) + self.assertTrue(log_pane.collapsed) + + def test_resizable_screen_log_pane_clamps_height_to_minimum(self): + log_pane = screen_support.ResizableScreenLogPane() + + log_pane.set_log_height(1) + + self.assertEqual(screen_support.SCREEN_LOG_MIN_HEIGHT, log_pane.get_log_height()) + + def test_configure_screen_log_handler_routes_logger_messages_to_active_screen(self): + logger_name = "ffx-test-screen-log-handler" + logger = logging.getLogger(logger_name) + logger.setLevel(logging.DEBUG) + logger.propagate = False + + for handler in list(logger.handlers): + logger.removeHandler(handler) + handler.close() + + screen = FakeScreenWithLog() + app = FakeThreadedApp(screen) + + try: + handler = screen_support.configure_screen_log_handler( + logger, + app, + enabled=True, + ) + self.assertIsNotNone(handler) + + logger.info("hello pane") + + self.assertEqual(1, len(screen.log_view.messages)) + self.assertRegex( + screen.log_view.messages[0], + r"^ffx-test-screen-log-handler\s+INFO\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \| hello pane$", + ) + finally: + screen_support.configure_screen_log_handler(logger, app, enabled=False) + for handler in list(logger.handlers): + logger.removeHandler(handler) + handler.close() + if __name__ == "__main__": unittest.main() From 02e375fbf295cc9d60ecf5d68a993c749dccace9 Mon Sep 17 00:00:00 2001 From: Javanaut Date: Tue, 14 Apr 2026 19:08:29 +0200 Subject: [PATCH 02/11] nnn --- SCRATCHPAD.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/SCRATCHPAD.md b/SCRATCHPAD.md index 89eaddd..cbb714c 100644 --- a/SCRATCHPAD.md +++ b/SCRATCHPAD.md @@ -69,3 +69,9 @@ ## Delete When - Delete this scratchpad once the optimization backlog is either converted into issues/work items or distilled into durable project guidance. + + +## Missing Timestamps + +Detect ffmpeg warning "imestamps 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. + From 0728ece4b8e1c90420a0db4421dd49ff5a3ff8d1 Mon Sep 17 00:00:00 2001 From: Javanaut Date: Wed, 15 Apr 2026 00:03:17 +0200 Subject: [PATCH 03/11] Fix h265 subtrack unmux --- src/ffx/cli.py | 13 ++-- tests/unit/test_cli_unmux_sequence.py | 91 +++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_cli_unmux_sequence.py diff --git a/src/ffx/cli.py b/src/ffx/cli.py index bd8f8f7..9b380fc 100755 --- a/src/ffx/cli.py +++ b/src/ffx/cli.py @@ -631,21 +631,24 @@ def rename(ctx, paths, prefix, season, suffix, dry_run): def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix, targetDirectory = ''): + from ffx.track_codec import TrackCodec + from ffx.track_type import TrackType # executable and input file commandTokens = list(FFMPEG_COMMAND_TOKENS) + ['-i', sourcePath] trackType = trackDescriptor.getType() + trackCodec = trackDescriptor.getCodec() targetPathBase = os.path.join(targetDirectory, targetPrefix) if targetDirectory else targetPrefix # mapping - commandTokens += ['-map', - f"0:{trackType.indicator()}:{trackDescriptor.getSubIndex()}", - '-c', - 'copy'] + commandTokens += ['-map', f"0:{trackType.indicator()}:{trackDescriptor.getSubIndex()}"] - trackCodec = trackDescriptor.getCodec() + if trackType == TrackType.VIDEO and trackCodec == TrackCodec.H265: + commandTokens += ['-c:v', 'copy', '-bsf:v', 'hevc_mp4toannexb'] + else: + commandTokens += ['-c', 'copy'] # output format codecFormat = trackCodec.format() diff --git a/tests/unit/test_cli_unmux_sequence.py b/tests/unit/test_cli_unmux_sequence.py new file mode 100644 index 0000000..b858755 --- /dev/null +++ b/tests/unit/test_cli_unmux_sequence.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +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 import cli # 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 + + +class UnmuxSequenceTests(unittest.TestCase): + def test_h265_video_unmux_uses_annex_b_bitstream_filter(self): + track_descriptor = TrackDescriptor( + index=0, + sub_index=0, + track_type=TrackType.VIDEO, + codec_name=TrackCodec.H265, + tags={}, + disposition_set=set(), + ) + + sequence = cli.getUnmuxSequence( + track_descriptor, + "input.mp4", + "episode_0_eng", + ) + + self.assertEqual( + [ + "ffmpeg", + "-y", + "-i", + "input.mp4", + "-map", + "0:v:0", + "-c:v", + "copy", + "-bsf:v", + "hevc_mp4toannexb", + "-f", + "h265", + "episode_0_eng.h265", + ], + sequence, + ) + + def test_non_h265_unmux_keeps_generic_copy_behavior(self): + track_descriptor = TrackDescriptor( + index=1, + sub_index=0, + track_type=TrackType.SUBTITLE, + codec_name=TrackCodec.SRT, + tags={}, + disposition_set=set(), + ) + + sequence = cli.getUnmuxSequence( + track_descriptor, + "input.mkv", + "episode_1_eng", + ) + + self.assertEqual( + [ + "ffmpeg", + "-y", + "-i", + "input.mkv", + "-map", + "0:s:0", + "-c", + "copy", + "-f", + "srt", + "episode_1_eng.srt", + ], + sequence, + ) + + +if __name__ == "__main__": + unittest.main() From d3d2de8a0d2b37faf8d0413ce948cee50a8f2364 Mon Sep 17 00:00:00 2001 From: Javanaut Date: Wed, 15 Apr 2026 15:50:24 +0200 Subject: [PATCH 04/11] adds scratchpad points --- SCRATCHPAD.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/SCRATCHPAD.md b/SCRATCHPAD.md index cbb714c..eb9465a 100644 --- a/SCRATCHPAD.md +++ b/SCRATCHPAD.md @@ -73,5 +73,9 @@ ## Missing Timestamps -Detect ffmpeg warning "imestamps 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 + +-map 0:v -c:v copy -bsf:v hevc_mp4toannexb out.h265 From bc1e0889e7cb66f824da8826b79dbacf7447250f Mon Sep 17 00:00:00 2001 From: Javanaut Date: Thu, 16 Apr 2026 18:10:39 +0200 Subject: [PATCH 05/11] Fix inspect details screen --- src/ffx/confirm_screen.py | 7 +++++++ src/ffx/help_screen.py | 7 +++++++ src/ffx/inspect_details_screen.py | 24 +++++++++++++++--------- src/ffx/media_edit_screen.py | 4 ++++ src/ffx/pattern_delete_screen.py | 4 ++++ src/ffx/pattern_details_screen.py | 3 +++ src/ffx/settings_screen.py | 7 +++++++ src/ffx/shifted_season_delete_screen.py | 3 +++ src/ffx/shifted_season_details_screen.py | 3 +++ src/ffx/show_delete_screen.py | 7 +++++++ src/ffx/show_details_screen.py | 3 +++ src/ffx/shows_screen.py | 4 ++++ src/ffx/tag_delete_screen.py | 3 +++ src/ffx/tag_details_screen.py | 3 +++ src/ffx/track_delete_screen.py | 3 +++ src/ffx/track_details_screen.py | 3 +++ 16 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/ffx/confirm_screen.py b/src/ffx/confirm_screen.py index 94ae8c8..5379a91 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 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/help_screen.py b/src/ffx/help_screen.py index 5475b84..f83fea9 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 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..46b8045 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 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_edit_screen.py b/src/ffx/media_edit_screen.py index 5e27b3d..33619c6 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 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/pattern_delete_screen.py b/src/ffx/pattern_delete_screen.py index d786978..7ccad0d 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 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..0ed1e57 100644 --- a/src/ffx/pattern_details_screen.py +++ b/src/ffx/pattern_details_screen.py @@ -326,6 +326,9 @@ class PatternDetailsScreen(Screen): def on_mount(self): + if 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..394ef77 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 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..b927ee1 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 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..79d8b70 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 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..8138fa8 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 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..c0002f6 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 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..d5e97c7 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 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..f14e8d5 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 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..28cda27 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 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_delete_screen.py b/src/ffx/track_delete_screen.py index edd8dd2..9b7c4f6 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 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_details_screen.py b/src/ffx/track_details_screen.py index aca5071..b7169da 100644 --- a/src/ffx/track_details_screen.py +++ b/src/ffx/track_details_screen.py @@ -236,6 +236,9 @@ class TrackDetailsScreen(Screen): def on_mount(self): + if 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 "-" ) From 0ab24084442338fee2be1adce5cb6b1a9afb8c2c Mon Sep 17 00:00:00 2001 From: Javanaut Date: Thu, 16 Apr 2026 18:20:17 +0200 Subject: [PATCH 06/11] Fix h265 format --- src/ffx/track_codec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffx/track_codec.py b/src/ffx/track_codec.py index d6ec091..386f4a1 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'} From ab5e8e53e19ac8bf1987ebd2fd17061fe03fa1fa Mon Sep 17 00:00:00 2001 From: Javanaut Date: Thu, 16 Apr 2026 18:32:07 +0200 Subject: [PATCH 07/11] Fix debug title --- src/ffx/confirm_screen.py | 2 +- src/ffx/help_screen.py | 2 +- src/ffx/inspect_details_screen.py | 2 +- src/ffx/media_edit_screen.py | 2 +- src/ffx/pattern_delete_screen.py | 2 +- src/ffx/pattern_details_screen.py | 2 +- src/ffx/settings_screen.py | 2 +- src/ffx/shifted_season_delete_screen.py | 2 +- src/ffx/shifted_season_details_screen.py | 2 +- src/ffx/show_delete_screen.py | 2 +- src/ffx/show_details_screen.py | 2 +- src/ffx/shows_screen.py | 2 +- src/ffx/tag_delete_screen.py | 2 +- src/ffx/tag_details_screen.py | 2 +- src/ffx/track_delete_screen.py | 2 +- src/ffx/track_details_screen.py | 2 +- 16 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/ffx/confirm_screen.py b/src/ffx/confirm_screen.py index 5379a91..88517e6 100644 --- a/src/ffx/confirm_screen.py +++ b/src/ffx/confirm_screen.py @@ -65,7 +65,7 @@ class ConfirmScreen(Screen): def on_mount(self): - if self.context.get('debug', False): + if getattr(self, 'context', {}).get('debug', False): self.title = f"{self.app.title} - {self.__class__.__name__}" diff --git a/src/ffx/help_screen.py b/src/ffx/help_screen.py index f83fea9..1d78e1c 100644 --- a/src/ffx/help_screen.py +++ b/src/ffx/help_screen.py @@ -23,7 +23,7 @@ class HelpScreen(Screen): def on_mount(self): - if self.context.get('debug', False): + if getattr(self, 'context', {}).get('debug', False): self.title = f"{self.app.title} - {self.__class__.__name__}" diff --git a/src/ffx/inspect_details_screen.py b/src/ffx/inspect_details_screen.py index 46b8045..a4d41e6 100644 --- a/src/ffx/inspect_details_screen.py +++ b/src/ffx/inspect_details_screen.py @@ -317,7 +317,7 @@ class InspectDetailsScreen(MediaWorkflowScreenBase): def on_mount(self): - if self.context.get('debug', False): + if getattr(self, 'context', {}).get('debug', False): self.title = f"{self.app.title} - {self.__class__.__name__}" self._update_grid_layout() diff --git a/src/ffx/media_edit_screen.py b/src/ffx/media_edit_screen.py index 33619c6..3e89205 100644 --- a/src/ffx/media_edit_screen.py +++ b/src/ffx/media_edit_screen.py @@ -179,7 +179,7 @@ class MediaEditScreen(MediaWorkflowScreenBase): def on_mount(self): - if self.context.get('debug', False): + if getattr(self, 'context', {}).get('debug', False): self.title = f"{self.app.title} - {self.__class__.__name__}" self._update_grid_layout() diff --git a/src/ffx/pattern_delete_screen.py b/src/ffx/pattern_delete_screen.py index 7ccad0d..ab25dc5 100644 --- a/src/ffx/pattern_delete_screen.py +++ b/src/ffx/pattern_delete_screen.py @@ -69,7 +69,7 @@ class PatternDeleteScreen(Screen): def on_mount(self): - if self.context.get('debug', False): + if getattr(self, 'context', {}).get('debug', False): self.title = f"{self.app.title} - {self.__class__.__name__}" if self.__showDescriptor: diff --git a/src/ffx/pattern_details_screen.py b/src/ffx/pattern_details_screen.py index 0ed1e57..55955d9 100644 --- a/src/ffx/pattern_details_screen.py +++ b/src/ffx/pattern_details_screen.py @@ -326,7 +326,7 @@ class PatternDetailsScreen(Screen): def on_mount(self): - if self.context.get('debug', False): + if getattr(self, 'context', {}).get('debug', False): self.title = f"{self.app.title} - {self.__class__.__name__}" if not self.__showDescriptor is None: diff --git a/src/ffx/settings_screen.py b/src/ffx/settings_screen.py index 394ef77..b82f251 100644 --- a/src/ffx/settings_screen.py +++ b/src/ffx/settings_screen.py @@ -23,7 +23,7 @@ class SettingsScreen(Screen): def on_mount(self): - if self.context.get('debug', False): + if getattr(self, 'context', {}).get('debug', False): self.title = f"{self.app.title} - {self.__class__.__name__}" diff --git a/src/ffx/shifted_season_delete_screen.py b/src/ffx/shifted_season_delete_screen.py index b927ee1..4139684 100644 --- a/src/ffx/shifted_season_delete_screen.py +++ b/src/ffx/shifted_season_delete_screen.py @@ -67,7 +67,7 @@ class ShiftedSeasonDeleteScreen(Screen): def on_mount(self): - if self.context.get('debug', False): + if getattr(self, 'context', {}).get('debug', False): self.title = f"{self.app.title} - {self.__class__.__name__}" shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId) diff --git a/src/ffx/shifted_season_details_screen.py b/src/ffx/shifted_season_details_screen.py index 79d8b70..24ad0bd 100644 --- a/src/ffx/shifted_season_details_screen.py +++ b/src/ffx/shifted_season_details_screen.py @@ -109,7 +109,7 @@ class ShiftedSeasonDetailsScreen(Screen): def on_mount(self): - if self.context.get('debug', False): + if getattr(self, 'context', {}).get('debug', False): self.title = f"{self.app.title} - {self.__class__.__name__}" if self.__shiftedSeasonId is not None: diff --git a/src/ffx/show_delete_screen.py b/src/ffx/show_delete_screen.py index 8138fa8..8262491 100644 --- a/src/ffx/show_delete_screen.py +++ b/src/ffx/show_delete_screen.py @@ -112,7 +112,7 @@ class ShowDeleteScreen(Screen): def on_mount(self): - if self.context.get('debug', False): + if getattr(self, 'context', {}).get('debug', False): self.title = f"{self.app.title} - {self.__class__.__name__}" diff --git a/src/ffx/show_details_screen.py b/src/ffx/show_details_screen.py index c0002f6..b5e99a9 100644 --- a/src/ffx/show_details_screen.py +++ b/src/ffx/show_details_screen.py @@ -175,7 +175,7 @@ class ShowDetailsScreen(Screen): def on_mount(self): - if self.context.get('debug', False): + if getattr(self, 'context', {}).get('debug', False): self.title = f"{self.app.title} - {self.__class__.__name__}" if self.__showDescriptor is not None: diff --git a/src/ffx/shows_screen.py b/src/ffx/shows_screen.py index d5e97c7..76758ab 100644 --- a/src/ffx/shows_screen.py +++ b/src/ffx/shows_screen.py @@ -245,7 +245,7 @@ class ShowsScreen(Screen): def on_mount(self) -> None: - if self.context.get('debug', False): + if getattr(self, 'context', {}).get('debug', False): self.title = f"{self.app.title} - {self.__class__.__name__}" for show in self.__sc.getAllShows(): diff --git a/src/ffx/tag_delete_screen.py b/src/ffx/tag_delete_screen.py index f14e8d5..38aefab 100644 --- a/src/ffx/tag_delete_screen.py +++ b/src/ffx/tag_delete_screen.py @@ -64,7 +64,7 @@ class TagDeleteScreen(Screen): def on_mount(self): - if self.context.get('debug', False): + 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)) diff --git a/src/ffx/tag_details_screen.py b/src/ffx/tag_details_screen.py index 28cda27..7bc948b 100644 --- a/src/ffx/tag_details_screen.py +++ b/src/ffx/tag_details_screen.py @@ -87,7 +87,7 @@ class TagDetailsScreen(Screen): def on_mount(self): - if self.context.get('debug', False): + if getattr(self, 'context', {}).get('debug', False): self.title = f"{self.app.title} - {self.__class__.__name__}" if self.__key is not None: diff --git a/src/ffx/track_delete_screen.py b/src/ffx/track_delete_screen.py index 9b7c4f6..5937981 100644 --- a/src/ffx/track_delete_screen.py +++ b/src/ffx/track_delete_screen.py @@ -67,7 +67,7 @@ class TrackDeleteScreen(Screen): def on_mount(self): - if self.context.get('debug', False): + 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())) diff --git a/src/ffx/track_details_screen.py b/src/ffx/track_details_screen.py index b7169da..cce5d25 100644 --- a/src/ffx/track_details_screen.py +++ b/src/ffx/track_details_screen.py @@ -236,7 +236,7 @@ class TrackDetailsScreen(Screen): def on_mount(self): - if self.context.get('debug', False): + if getattr(self, 'context', {}).get('debug', False): self.title = f"{self.app.title} - {self.__class__.__name__}" self.query_one("#index_label", Static).update( From 3a87bbbba64a4c3764073d890d31bd356e2bba85 Mon Sep 17 00:00:00 2001 From: Javanaut Date: Thu, 16 Apr 2026 19:02:57 +0200 Subject: [PATCH 08/11] Anpassung --cut flag --- src/ffx/cli.py | 1 - tests/support/ffx_bundle.py | 43 ++++++++++------ tests/unit/test_cli_unmux_sequence.py | 4 +- .../unit/test_file_properties_asset_probe.py | 50 +++++++++++++------ tests/unit/test_metadata_editor.py | 27 +++++++--- 5 files changed, 83 insertions(+), 42 deletions(-) diff --git a/src/ffx/cli.py b/src/ffx/cli.py index 9b380fc..7f4d9ca 100755 --- a/src/ffx/cli.py +++ b/src/ffx/cli.py @@ -958,7 +958,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, ) diff --git a/tests/support/ffx_bundle.py b/tests/support/ffx_bundle.py index 1fa5942..543c5ef 100644 --- a/tests/support/ffx_bundle.py +++ b/tests/support/ffx_bundle.py @@ -95,7 +95,25 @@ 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: +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 has_video = any(track.track_type == TrackType.VIDEO for track in tracks) @@ -189,21 +207,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_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__": From 849d03d054cb77a7395daf0fef8258c464814512 Mon Sep 17 00:00:00 2001 From: Javanaut Date: Thu, 16 Apr 2026 19:26:17 +0200 Subject: [PATCH 09/11] 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() From 9fe2a842e9962a96aecb3218cd4a8c1ad70fd978 Mon Sep 17 00:00:00 2001 From: Javanaut Date: Thu, 16 Apr 2026 19:32:41 +0200 Subject: [PATCH 10/11] ff --- tools/merge_dev_into_main.sh | 74 +++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/tools/merge_dev_into_main.sh b/tools/merge_dev_into_main.sh index 0340a11..3cf4ff6 100755 --- a/tools/merge_dev_into_main.sh +++ b/tools/merge_dev_into_main.sh @@ -172,21 +172,67 @@ 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}" +} + +require_branch_contains_remote() { + 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 + fail "Local branch '${branch}' is behind '${ORIGIN_REMOTE}/${branch}' by ${remote_only} commit(s). Pull or rebase first." + 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 +} + +require_branch_matches_remote_exactly() { + local branch="$1" + local remote_only="" + local local_only="" + + read -r remote_only local_only < <(branch_divergence_counts "${branch}") + + if [ "${remote_only}" -eq 0 ] && [ "${local_only}" -eq 0 ]; then + return 0 + fi + + 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 + fail "Local branch '${branch}' is behind '${ORIGIN_REMOTE}/${branch}' by ${remote_only} commit(s). Pull or rebase first." + fi + + fail "Local branch '${branch}' is ahead of '${ORIGIN_REMOTE}/${branch}' by ${local_only} commit(s). Push first so the release starts from the published ${branch} tip." } resolve_release_version() { @@ -249,13 +295,13 @@ 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 and verify local %s contains %s/%s while local %s exactly matches %s/%s.\n' \ + "${ORIGIN_REMOTE}" \ + "${DEV_BRANCH}" \ "${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 +350,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}" +require_branch_contains_remote "${DEV_BRANCH}" +require_branch_matches_remote_exactly "${MAIN_BRANCH}" RELEASE_VERSION="$(resolve_release_version)" RELEASE_TAG="v${RELEASE_VERSION}" @@ -341,8 +387,8 @@ fi run_pre_release_tests require_clean_worktree fetch_remote_state -require_branch_matches_remote "${DEV_BRANCH}" -require_branch_matches_remote "${MAIN_BRANCH}" +require_branch_contains_remote "${DEV_BRANCH}" +require_branch_matches_remote_exactly "${MAIN_BRANCH}" require_release_tag_available "${RELEASE_VERSION}" git switch "${MAIN_BRANCH}" >/dev/null From 1bead05d19f0e475908240a80a6050d80105def6 Mon Sep 17 00:00:00 2001 From: Javanaut Date: Thu, 16 Apr 2026 19:36:40 +0200 Subject: [PATCH 11/11] ff --- tools/merge_dev_into_main.sh | 50 ++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/tools/merge_dev_into_main.sh b/tools/merge_dev_into_main.sh index 3cf4ff6..e540635 100755 --- a/tools/merge_dev_into_main.sh +++ b/tools/merge_dev_into_main.sh @@ -189,7 +189,27 @@ branch_divergence_counts() { printf '%s %s\n' "${remote_only}" "${local_only}" } -require_branch_contains_remote() { +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="" @@ -201,7 +221,7 @@ require_branch_contains_remote() { fi if [ "${remote_only}" -ne 0 ]; then - fail "Local branch '${branch}' is behind '${ORIGIN_REMOTE}/${branch}' by ${remote_only} commit(s). Pull or rebase first." + fast_forward_branch_to_remote "${branch}" fi if [ "${local_only}" -ne 0 ]; then @@ -213,26 +233,24 @@ require_branch_contains_remote() { fi } -require_branch_matches_remote_exactly() { +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}" -eq 0 ] && [ "${local_only}" -eq 0 ]; then - return 0 - fi - 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 - fail "Local branch '${branch}' is behind '${ORIGIN_REMOTE}/${branch}' by ${remote_only} commit(s). Pull or rebase first." + 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 - fail "Local branch '${branch}' is ahead of '${ORIGIN_REMOTE}/${branch}' by ${local_only} commit(s). Push first so the release starts from the published ${branch} tip." + if [ "${remote_only}" -ne 0 ]; then + fast_forward_branch_to_remote "${branch}" + fi } resolve_release_version() { @@ -295,9 +313,7 @@ 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 contains %s/%s while local %s exactly matches %s/%s.\n' \ - "${ORIGIN_REMOTE}" \ - "${DEV_BRANCH}" \ + 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}" \ @@ -350,8 +366,8 @@ require_repo_state require_dev_checkout require_clean_worktree fetch_remote_state -require_branch_contains_remote "${DEV_BRANCH}" -require_branch_matches_remote_exactly "${MAIN_BRANCH}" +sync_release_source_branch "${DEV_BRANCH}" +sync_release_target_branch "${MAIN_BRANCH}" RELEASE_VERSION="$(resolve_release_version)" RELEASE_TAG="v${RELEASE_VERSION}" @@ -387,8 +403,8 @@ fi run_pre_release_tests require_clean_worktree fetch_remote_state -require_branch_contains_remote "${DEV_BRANCH}" -require_branch_matches_remote_exactly "${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