From 6d70842338017ae53ff5a66eed4616fbe53c4bc1 Mon Sep 17 00:00:00 2001 From: MIA Dev Date: Fri, 3 Apr 2026 20:42:37 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=D0=94=D0=B0=D1=82=D1=8C=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8E=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BC=D0=B5=D0=BD=D0=B8=D1=82=D1=8C=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BB=D0=B3=D1=83=D1=8E=20=D0=BE=D1=81=D1=82=D0=B0=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D1=83=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Финализация записи с большим файлом могла зависать на этапе FFmpeg merge, а кнопка остановки в GUI до конца блокировала пользователя. Перевожу stop операцию в фоновый поток в MainWindow, добавляю возможность запросить отмену долгой финализации и делаю Encoding pipeline отменяемым через cancellable FFmpeg long process. Constraint: Нужно сохранить текущий sync stop API для внутренних вызовов, но сделать GUI-путь отзывчивым и отменяемым Rejected: Полноценный async operation API/WebSocket протокол в первом проходе | слишком широкий scope для точечного UX-фикса Rejected: Просто уменьшить таймауты stop/finalize | не решает отсутствие пользовательской отмены Confidence: high Scope-risk: moderate Reversibility: clean Directive: Если будете расширять stop cancellation дальше, держите cancel semantics на уровне encoder/controller, а GUI используйте только как orchestration слой для фоновой операции Tested: python -m pytest tests/unit/test_encoder.py tests/unit/test_gui_controllers.py tests/unit/test_main_window.py -q Tested: python -m ruff check recorder/encoder.py gui/controllers/recording_controller.py gui/main_window.py tests/unit/test_encoder.py tests/unit/test_gui_controllers.py tests/unit/test_main_window.py Tested: python -m mypy recorder/encoder.py gui/controllers/recording_controller.py gui/main_window.py --ignore-missing-imports --follow-imports=skip Not-tested: Ручная проверка force-cancel финализации на реальном большом файле и живом FFmpeg процессе --- gui/controllers/recording_controller.py | 17 +++- gui/main_window.py | 89 +++++++++++++++++-- recorder/encoder.py | 112 ++++++++++++++++++++---- tests/unit/test_encoder.py | 19 +++- tests/unit/test_gui_controllers.py | 24 +++++ tests/unit/test_main_window.py | 82 ++++++++++++++++- 6 files changed, 312 insertions(+), 31 deletions(-) diff --git a/gui/controllers/recording_controller.py b/gui/controllers/recording_controller.py index e0e3712..4450eb7 100644 --- a/gui/controllers/recording_controller.py +++ b/gui/controllers/recording_controller.py @@ -65,7 +65,7 @@ def state(self) -> RecordingState: def elapsed_time(self) -> float: """Получить время записи.""" if self._video_recorder: - return self._video_recorder.elapsed_time + return float(self._video_recorder.elapsed_time) return 0.0 def build_capture_area(self, capture: CaptureSettings) -> CaptureArea: @@ -366,6 +366,19 @@ def stop_recording(self) -> Path | None: logger.info(f"Запись остановлена: {output_path}") return output_path + def request_stop_cancellation(self) -> bool: + """ + Запросить отмену долгой остановки/финализации записи. + + Returns: + `True`, если запрос отмены отправлен. + """ + if self._encoder and self._encoder.is_finalizing: + self._encoder.cancel() + logger.info("Запрошена отмена финализации записи") + return True + return False + def cancel_recording(self) -> None: """Отмена записи без сохранения.""" self._cleanup() @@ -410,5 +423,5 @@ def _on_audio_chunks_dropped(self, count: int) -> None: def dropped_audio_chunks(self) -> int: """Количество потерянных аудио-чанков.""" if self._audio_recorder: - return self._audio_recorder.dropped_chunks + return int(self._audio_recorder.dropped_chunks) return 0 diff --git a/gui/main_window.py b/gui/main_window.py index 10144c0..fa79b63 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -9,6 +9,7 @@ import os import platform import subprocess +import threading from collections.abc import Callable from datetime import datetime from pathlib import Path @@ -69,6 +70,7 @@ class MainWindow(QMainWindow): recording_resumed = pyqtSignal() error_occurred = pyqtSignal(str) close_requested = pyqtSignal(object) + stop_operation_finished = pyqtSignal(object, object) def __init__(self, headless: bool = False): """ @@ -82,6 +84,8 @@ def __init__(self, headless: bool = False): self._headless = headless self._api_controls: dict[str, Callable[..., Any]] = {} self._api_server: Any | None = None + self._stop_operation_thread: threading.Thread | None = None + self._stop_operation_in_progress = False # Инициализация модели и контроллеров self._state = RecordingState() @@ -330,6 +334,7 @@ def _connect_signals(self) -> None: self._output_view.output_path_changed.connect( self._on_output_path_changed ) + self.stop_operation_finished.connect(self._on_stop_operation_finished) # Сигналы ApiSettingsView self._api_settings_view.apply_requested.connect( @@ -491,18 +496,18 @@ def _start_recording(self) -> None: def _stop_recording(self) -> None: """Остановка записи.""" - if not self._state.is_recording() and not self._state.is_paused(): + if self._stop_operation_in_progress: + self._cancel_stop_operation() return - output_path = self._recording_controller.stop_recording() - - if output_path: - self._on_recording_stopped(output_path) - else: - self._show_error("Не удалось сохранить запись") + if not self._state.is_recording() and not self._state.is_paused(): + return + self._begin_stop_operation() def _toggle_pause(self) -> None: """Переключение состояния паузы.""" + if self._stop_operation_in_progress: + return if self._state.is_paused(): self._recording_controller.resume_recording() self._on_recording_resumed() @@ -510,6 +515,76 @@ def _toggle_pause(self) -> None: self._recording_controller.pause_recording() self._on_recording_paused() + def _begin_stop_operation(self) -> None: + """Запустить остановку записи в фоне.""" + self._stop_operation_in_progress = True + self.start_btn.setEnabled(False) + self.pause_btn.setEnabled(False) + self.stop_btn.setEnabled(True) + self.stop_btn.setText("Отменить остановку") + self.status_label.setText("Остановка...") + self.status_label.setStyleSheet("color: orange; font-weight: bold;") + self.status_bar.showMessage("Финализация записи...", 0) + + self._stop_operation_thread = threading.Thread( + target=self._stop_recording_worker, + daemon=True, + ) + self._stop_operation_thread.start() + + def _stop_recording_worker(self) -> None: + """Фоновый worker остановки записи.""" + output_path = self._recording_controller.stop_recording() + error_message = ( + None if output_path is not None else "Не удалось сохранить запись" + ) + self.stop_operation_finished.emit(output_path, error_message) + + def _cancel_stop_operation(self) -> None: + """Запросить отмену долгой остановки записи.""" + if not self._stop_operation_in_progress: + return + + if self._recording_controller.request_stop_cancellation(): + self.status_label.setText("Отмена остановки...") + self.status_bar.showMessage( + "Запрошена отмена остановки записи", + 5000, + ) + self.stop_btn.setEnabled(False) + return + + self.status_bar.showMessage( + "Остановка ещё не дошла до стадии, которую можно отменить", + 5000, + ) + + def _on_stop_operation_finished( + self, + output_path: Path | None, + error_message: str | None, + ) -> None: + """Завершить UI-часть операции остановки записи.""" + self._stop_operation_in_progress = False + self._stop_operation_thread = None + self.stop_btn.setText("Стоп") + + if output_path is not None: + self._on_recording_stopped(output_path) + return + + self.start_btn.setEnabled(True) + self.stop_btn.setEnabled(False) + self.pause_btn.setEnabled(False) + self.status_label.setText("Готов") + self.status_label.setStyleSheet("") + self.time_label.setText("00:00") + self._recording_indicator.hide_indicator() + self.status_bar.showMessage( + error_message or "Остановка записи не завершена", + 5000, + ) + # === Обработчики событий записи === def _on_recording_started( diff --git a/recorder/encoder.py b/recorder/encoder.py index 10f721c..d39718d 100644 --- a/recorder/encoder.py +++ b/recorder/encoder.py @@ -10,6 +10,8 @@ import shutil import subprocess import tempfile +import threading +import time from collections.abc import Callable from dataclasses import dataclass from pathlib import Path @@ -92,6 +94,7 @@ def merge_video_audio( output_path: Path, keep_originals: bool = True, progress_callback: Callable[[float], None] | None = None, + cancel_event: threading.Event | None = None, ) -> tuple[bool, str | None]: """ Объединение видеофайла и аудиофайла в один выходной файл. @@ -154,7 +157,14 @@ def merge_video_audio( ] logger.info(f"Запуск FFmpeg: {' '.join(cmd)}") - result = self._run_ffmpeg_long_process(cmd, timeout=3600) + result = self._run_ffmpeg_long_process( + cmd, + timeout=3600, + cancel_event=cancel_event, + ) + + if result.cancelled: + return False, "Операция кодирования отменена пользователем" if result.returncode != 0: error_msg = result.stderr_tail or "Неизвестная ошибка FFmpeg" @@ -191,6 +201,7 @@ def encode_video( output_path: Path, settings: EncodingSettings | None = None, progress_callback: Callable[[float], None] | None = None, + cancel_event: threading.Event | None = None, ) -> tuple[bool, str | None]: """ Перекодирование видео с указанными настройками. @@ -240,7 +251,14 @@ def encode_video( ] logger.info(f"Кодирование видео: {' '.join(cmd)}") - result = self._run_ffmpeg_long_process(cmd, timeout=3600) + result = self._run_ffmpeg_long_process( + cmd, + timeout=3600, + cancel_event=cancel_event, + ) + + if result.cancelled: + return False, "Операция кодирования отменена пользователем" if result.returncode != 0: return ( @@ -257,6 +275,7 @@ def encode_video( class _FFmpegProcessResult: returncode: int stderr_tail: str | None + cancelled: bool = False def _read_file_tail(self, path: Path, max_bytes: int) -> str: """Читает хвост текстового файла безопасно по размеру.""" @@ -271,7 +290,10 @@ def _read_file_tail(self, path: Path, max_bytes: int) -> str: return data.decode("utf-8", errors="replace").strip() def _run_ffmpeg_long_process( - self, cmd: list[str], timeout: int + self, + cmd: list[str], + timeout: int, + cancel_event: threading.Event | None = None, ) -> _FFmpegProcessResult: """ Выполняет долгий FFmpeg-процесс без накопления stderr в памяти. @@ -288,23 +310,60 @@ def _run_ffmpeg_long_process( delete=False, ) as stderr_file: stderr_temp_path = Path(stderr_file.name) - if creationflags: - process = subprocess.run( - cmd, - stdout=subprocess.DEVNULL, - stderr=stderr_file, - timeout=timeout, - creationflags=creationflags, - ) + cancelled = False + process: Any + if cancel_event is None: + if creationflags: + process = subprocess.run( + cmd, + stdout=subprocess.DEVNULL, + stderr=stderr_file, + timeout=timeout, + creationflags=creationflags, + ) + else: + process = subprocess.run( + cmd, + stdout=subprocess.DEVNULL, + stderr=stderr_file, + timeout=timeout, + ) else: - process = subprocess.run( - cmd, - stdout=subprocess.DEVNULL, - stderr=stderr_file, - timeout=timeout, - ) + popen_kwargs: dict[str, Any] = { + "stdout": subprocess.DEVNULL, + "stderr": stderr_file, + } + if creationflags: + popen_kwargs["creationflags"] = creationflags + + process = subprocess.Popen(cmd, **popen_kwargs) + deadline = time.monotonic() + timeout + + while True: + if cancel_event.is_set(): + cancelled = True + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + break + + if process.poll() is not None: + break + + if time.monotonic() >= deadline: + process.kill() + raise subprocess.TimeoutExpired( + cmd=cmd, timeout=timeout + ) + + time.sleep(0.1) stderr_tail = None - if process.returncode != 0 and stderr_temp_path is not None: + if ( + process.returncode != 0 or cancelled + ) and stderr_temp_path is not None: stderr_tail = self._read_file_tail( stderr_temp_path, max_bytes=_FFMPEG_ERROR_TAIL_BYTES, @@ -316,6 +375,7 @@ def _run_ffmpeg_long_process( return self._FFmpegProcessResult( returncode=process.returncode, stderr_tail=stderr_tail, + cancelled=cancelled, ) finally: if stderr_temp_path is not None: @@ -537,6 +597,13 @@ def __init__( self._temp_dir: Path | None = None self._temp_video: Path | None = None self._temp_audio: Path | None = None + self._cancel_requested = threading.Event() + self._is_finalizing = False + + @property + def is_finalizing(self) -> bool: + """Показывает, идёт ли финализация записи.""" + return self._is_finalizing def setup(self) -> tuple[Path, Path]: """ @@ -575,6 +642,8 @@ def finalize( if self._temp_dir is None: return False, "Нет временной директории для обработки" + self._cancel_requested.clear() + self._is_finalizing = True try: temp_output_path = self._temp_dir / ( f"final_temp{self.output_path.suffix}" @@ -587,6 +656,7 @@ def finalize( temp_output_path, keep_originals=False, progress_callback=progress_callback, + cancel_event=self._cancel_requested, ) else: # Просто копирование видео в вывод @@ -594,6 +664,7 @@ def finalize( self._temp_video, temp_output_path, progress_callback=progress_callback, + cancel_event=self._cancel_requested, ) if success: @@ -605,6 +676,7 @@ def finalize( return success, error finally: + self._is_finalizing = False # Очистка временных файлов self._cleanup() @@ -666,5 +738,9 @@ def _move_final_output( def cancel(self) -> None: """Отмена записи и очистка.""" + self._cancel_requested.set() + if self._is_finalizing: + logger.info("Запрошена отмена текущей финализации записи") + return self._cleanup() logger.info("Запись отменена, временные файлы очищены") diff --git a/tests/unit/test_encoder.py b/tests/unit/test_encoder.py index c975bf3..c8a499e 100644 --- a/tests/unit/test_encoder.py +++ b/tests/unit/test_encoder.py @@ -7,7 +7,7 @@ import tempfile from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch import pytest @@ -740,6 +740,7 @@ def test_finalize_merges_video_and_audio_and_cleans_up( temp_dir / "final_temp.mp4", keep_originals=False, progress_callback=None, + cancel_event=ANY, ) mock_move.assert_called_once_with(temp_dir / "final_temp.mp4") assert not temp_dir.exists() @@ -780,6 +781,7 @@ def test_finalize_uses_encode_video_without_audio( temp_video, temp_dir / "final_temp.mp4", progress_callback=None, + cancel_event=ANY, ) mock_move.assert_called_once_with(temp_dir / "final_temp.mp4") assert not temp_dir.exists() @@ -813,3 +815,18 @@ def test_cancel_calls_cleanup(self, tmp_path: Path) -> None: recording_encoder.cancel() mock_cleanup.assert_called_once() + + def test_cancel_during_finalization_only_sets_cancel_flag( + self, tmp_path: Path + ) -> None: + """Во время финализации cancel не должен преждевременно чистить файлы.""" + with patch("recorder.encoder.Encoder", return_value=MagicMock()): + recording_encoder = RecordingEncoder(tmp_path / "output.mp4") + + recording_encoder._is_finalizing = True + + with patch.object(recording_encoder, "_cleanup") as mock_cleanup: + recording_encoder.cancel() + + assert recording_encoder._cancel_requested.is_set() is True + mock_cleanup.assert_not_called() diff --git a/tests/unit/test_gui_controllers.py b/tests/unit/test_gui_controllers.py index f995ff2..608b3f6 100644 --- a/tests/unit/test_gui_controllers.py +++ b/tests/unit/test_gui_controllers.py @@ -388,6 +388,30 @@ def test_cancel_recording(self, controller: RecordingController) -> None: assert controller.state.status == RecordingStatus.IDLE + def test_request_stop_cancellation_during_finalization( + self, controller: RecordingController + ) -> None: + """Во время финализации должен отправляться запрос отмены.""" + controller._encoder = MagicMock() + controller._encoder.is_finalizing = True + + result = controller.request_stop_cancellation() + + assert result is True + controller._encoder.cancel.assert_called_once() + + def test_request_stop_cancellation_without_finalization( + self, controller: RecordingController + ) -> None: + """Без активной финализации отменять нечего.""" + controller._encoder = MagicMock() + controller._encoder.is_finalizing = False + + result = controller.request_stop_cancellation() + + assert result is False + controller._encoder.cancel.assert_not_called() + class TestSettingsController: """Тесты контроллера настроек.""" diff --git a/tests/unit/test_main_window.py b/tests/unit/test_main_window.py index 282f7c7..205f7f2 100644 --- a/tests/unit/test_main_window.py +++ b/tests/unit/test_main_window.py @@ -53,6 +53,9 @@ def _build_window(): window._recordings_filter_input = MagicMock() window._diagnostics_view = MagicMock() window._recording_indicator = MagicMock() + window._stop_operation_in_progress = False + window._stop_operation_thread = None + window.stop_operation_finished = MagicMock() return window @@ -759,12 +762,23 @@ def test_stop_recording_reports_missing_output(self) -> None: """Если backend не вернул путь, показывается ошибка.""" window = _build_window() window._state.start_recording(Path("D:/capture.mp4")) - window._recording_controller.stop_recording.return_value = None - window._show_error = MagicMock() + window._begin_stop_operation = MagicMock() window._stop_recording() - window._show_error.assert_called_once() + window._begin_stop_operation.assert_called_once() + + def test_stop_recording_requests_cancel_when_operation_in_progress( + self, + ) -> None: + """Повторное нажатие стопа должно просить отмену остановки.""" + window = _build_window() + window._stop_operation_in_progress = True + window._cancel_stop_operation = MagicMock() + + window._stop_recording() + + window._cancel_stop_operation.assert_called_once() def test_toggle_pause_calls_expected_controller_branch(self) -> None: """Переключение паузы вызывает pause или resume ветку.""" @@ -829,6 +843,68 @@ def test_on_recording_stopped_adds_recent_recording_for_existing_file( window.recording_stopped.emit.assert_called_once_with(str(output)) window._recording_indicator.hide_indicator.assert_called_once() + def test_begin_stop_operation_updates_ui_and_spawns_thread(self) -> None: + """Начало stop operation переводит UI в stopping-состояние.""" + window = _build_window() + + class FakeThread: + def __init__(self, target, daemon): + self.target = target + self.daemon = daemon + self.started = False + + def start(self): + self.started = True + + with patch("gui.main_window.threading.Thread", FakeThread): + window._begin_stop_operation() + + assert window._stop_operation_in_progress is True + window.pause_btn.setEnabled.assert_called_with(False) + window.stop_btn.setText.assert_called_with("Отменить остановку") + window.status_label.setText.assert_called_with("Остановка...") + + def test_cancel_stop_operation_requests_controller_cancellation( + self, + ) -> None: + """Отмена долгой остановки делегируется контроллеру.""" + window = _build_window() + window._stop_operation_in_progress = True + window._recording_controller.request_stop_cancellation.return_value = ( + True + ) + + window._cancel_stop_operation() + + window._recording_controller.request_stop_cancellation.assert_called_once() + window.stop_btn.setEnabled.assert_called_with(False) + + def test_on_stop_operation_finished_success_calls_recording_stopped( + self, + ) -> None: + """Успешное завершение stop operation должно завершать lifecycle.""" + window = _build_window() + window._stop_operation_in_progress = True + window._on_recording_stopped = MagicMock() + output = Path("D:/capture.mp4") + + window._on_stop_operation_finished(output, None) + + assert window._stop_operation_in_progress is False + window._on_recording_stopped.assert_called_once_with(output) + + def test_on_stop_operation_finished_error_restores_idle_ui(self) -> None: + """Ошибка stop operation должна вернуть UI в idle-состояние.""" + window = _build_window() + window._stop_operation_in_progress = True + + window._on_stop_operation_finished(None, "Не удалось сохранить запись") + + assert window._stop_operation_in_progress is False + window.start_btn.setEnabled.assert_called_with(True) + window.stop_btn.setEnabled.assert_called_with(False) + window._recording_indicator.hide_indicator.assert_called_once() + def test_update_status_formats_elapsed_time(self) -> None: """Таймер статуса форматирует elapsed time во время записи.""" window = _build_window() From a95257f7b3495cd9f4e14630a168e1fcaa2875bc Mon Sep 17 00:00:00 2001 From: MIA Dev Date: Fri, 3 Apr 2026 21:46:33 +0300 Subject: [PATCH 2/2] =?UTF-8?q?=D0=9E=D1=82=D1=80=D0=B0=D0=B7=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D1=82=D0=BC=D0=B5=D0=BD=D1=83=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BB=D0=B3=D0=BE=D0=B9=20=D0=BE=D1=81=D1=82=D0=B0=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=D0=B2=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавляю в актуальную Unreleased секцию запись про возможность отмены длительной остановки записи и про покрывающие её unit-тесты. Основа для записи взята из свежего origin/main, чтобы не создавать конфликтов при merge PR #37. Constraint: Нужно сохранить уже накопленные записи Unreleased из main и добавить новый UX/lifecycle фикс без конфликта changelog Rejected: Оставить changelog на потом после merge | повышает риск следующего merge-конфликта и теряет контекст задачи Confidence: high Scope-risk: narrow Reversibility: clean Directive: Для user-visible lifecycle fixes записи всегда дополняйте Unreleased на свежей базе main до merge PR Tested: Визуальная проверка итоговой секции CHANGELOG.md после обновления Not-tested: Отдельный markdown lint changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ee523e..66ebded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] ### Fixed +- Добавлена возможность отмены длительной остановки записи: + финализация записи теперь запускается в фоне, пользователь может + запросить отмену затянувшейся операции, а отменяемый FFmpeg pipeline + корректно прерывает долгий merge/encode без зависания GUI. - Добавлена визуальная индикация активной области записи: во время записи поверх выбранной области (`full`, `window`, `rect`) показывается тонкая рамка с мягкой пульсацией, а при остановке записи @@ -25,6 +29,8 @@ path без лишних повторных вызовов `ffmpeg -version`. ### Tests +- Добавлены unit-тесты на stop/cancel flow долгой остановки записи в + encoder, RecordingController и MainWindow. - Добавлены unit-тесты на расчёт и lifecycle визуального индикатора активной области записи. - Добавлены unit-тесты на отказ старта без FFmpeg и на поведение