Skip to content

Добавить ограничение максимального размера файла записи #53

@chelslava

Description

@chelslava

Описание

При очень длительной записи файлы могут достигать огромных размеров (десятки гигабайт), что затрудняет:

  • Хранение и архивирование
  • Передачу файлов
  • Воспроизведение (медиаплееры могут не открывать)
  • Резервное копирование

Текущее поведение

Запись идёт → Файл растёт бесконечно → Размер 50+ GB
Result: Проблемы с хранением и обработкой

Ожидаемое поведение

Запись идёт → Размер близок к лимиту → Новый файл → Нумерация
Result: Файлы разбиты на управляемые части

Технические требования

1. Конфигурация лимитов

from dataclasses import dataclass
from enum import Enum

class SplitMode(Enum):
    BY_SIZE = "size"      # По размеру файла
    BY_TIME = "time"      # По времени записи

@dataclass
class FileSplitConfig:
    """Конфигурация разбиения файлов."""
    enabled: bool = False
    mode: SplitMode = SplitMode.BY_SIZE
    
    # Для BY_SIZE
    max_file_size_mb: float = 1024.0  # 1 GB по умолчанию
    
    # Для BY_TIME
    max_duration_minutes: int = 60  # 1 час по умолчанию
    
    # Нумерация
    padding_width: int = 3  # 001, 002, ...
    separator: str = "_"

2. Мониторинг размера файла

class FileSizeMonitor:
    """Мониторинг размера файла для авторазбиения."""
    
    def __init__(self, max_size_bytes: int):
        self._max_size = max_size_bytes
        self._current_size = 0
        self._needs_split = False
    
    def update(self, written_bytes: int) -> None:
        """Обновляет счётчик записанных байт."""
        self._current_size += written_bytes
        self._needs_split = self._current_size >= self._max_size
    
    @property
    def needs_split(self) -> bool:
        """Требуется ли разбиение."""
        return self._needs_split
    
    @property
    def usage_percent(self) -> float:
        """Процент использования лимита."""
        return self._current_size / self._max_size * 100
    
    def reset(self) -> None:
        """Сброс счётчика."""
        self._current_size = 0
        self._needs_split = False

3. Авторазбиение в FFmpegWriter

class SegmentedFFmpegWriter:
    """FFmpeg writer с автоматическим разбиением."""
    
    def __init__(
        self,
        base_path: Path,
        width: int,
        height: int,
        fps: int = 30,
        split_config: FileSplitConfig | None = None,
        **kwargs
    ):
        self._base_path = Path(base_path)
        self._split_config = split_config or FileSplitConfig()
        self._current_writer: FFmpegVideoWriter | None = None
        self._current_segment: int = 1
        self._size_monitor: FileSizeMonitor | None = None
        
        if self._split_config.enabled:
            self._size_monitor = FileSizeMonitor(
                max_size_bytes=self._split_config.max_file_size_mb * 1024 * 1024
            )
        
        self._start_new_segment()
    
    def _start_new_segment(self) -> None:
        """Начинает новый сегмент записи."""
        # Закрыть текущий
        if self._current_writer:
            self._current_writer.close()
        
        # Генерация имени файла
        filename = self._generate_segment_filename()
        segment_path = self._base_path.parent / filename
        
        # Создать новый writer
        self._current_writer = FFmpegVideoWriter(
            output_path=segment_path,
            width=self._width,
            height=self._height,
            fps=self._fps,
            **self._kwargs
        )
        self._current_writer.open()
        
        # Сбросить монитор
        if self._size_monitor:
            self._size_monitor.reset()
        
        # Уведомление о начале сегмента
        self._on_segment_started(segment_path, self._current_segment)
    
    def _generate_segment_filename(self) -> str:
        """Генерирует имя файла для сегмента."""
        stem = self._base_path.stem
        suffix = self._base_path.suffix
        parent = self._base_path.parent
        
        segment_num = str(self._current_segment).zfill(self._split_config.padding_width)
        filename = f"{stem}{self._split_config.separator}{segment_num}{suffix}"
        
        return filename
    
    def write(self, frame: np.ndarray) -> bool:
        """Запись кадра с проверкой лимита."""
        if not self._current_writer:
            return False
        
        # Проверка необходимости разбиения
        if self._size_monitor and self._size_monitor.needs_split:
            self._current_segment += 1
            self._start_new_segment()
            return self._current_writer.write(frame)
        
        result = self._current_writer.write(frame)
        
        # Обновить монитор
        if result and self._size_monitor:
            # Примерный размер кадра
            frame_size = frame.nbytes
            self._size_monitor.update(frame_size)
            
            if self._size_monitor.needs_split:
                self._current_segment += 1
                self._start_new_segment()
        
        return result
    
    def close(self) -> dict[str, bool]:
        """Закрытие всех сегментов."""
        results = {}
        if self._current_writer:
            results["segment_1"] = self._current_writer.close()
            self._current_writer = None
        return results
    
    def _on_segment_started(self, path: Path, segment: int) -> None:
        """Callback при начале нового сегмента."""
        if self._event_bus:
            self._event_bus.publish(RecordingEvent(
                event_type=RecordingEventType.SEGMENT_STARTED,
                payload={
                    "segment_number": segment,
                    "file_path": str(path),
                    "file_size_bytes": 0
                }
            ))

4. API endpoints

# Настройка разбиения
PUT /api/v1/config/video
Body: {
    "split_enabled": true,
    "split_mode": "size",  # or "time"
    "max_file_size_mb": 2048,
    # или
    "max_duration_minutes": 30
}
Response: {"success": true}

# Информация о сегментах
GET /api/v1/recordings/{recording_id}/segments
Response: {
    "segments": [
        {"index": 1, "file_path": "...", "size_bytes": 2147483648},
        {"index": 2, "file_path": "...", "size_bytes": 1073741824}
    ],
    "total_size_bytes": 3221225472
}

5. UI компонент

class FileSizeLimitWidget(QWidget):
    """Виджет настроек лимита размера файла."""
    
    def __init__(self, config: FileSplitConfig):
        super().__init__()
        self._config = config
        self._setup_ui()
    
    def _setup_ui(self) -> None:
        layout = QVBoxLayout()
        
        # Чекбокс включения
        self._enabled_checkbox = QCheckBox("Автоматически разбивать на части")
        layout.addWidget(self._enabled_checkbox)
        
        # Режим
        mode_group = QGroupBox("Режим разбиения")
        mode_layout = QVBoxLayout()
        
        self._size_mode = QRadioButton("По размеру файла")
        self._time_mode = QRadioButton("По времени записи")
        
        mode_layout.addWidget(self._size_mode)
        mode_layout.addWidget(self._time_mode)
        mode_group.setLayout(mode_layout)
        layout.addWidget(mode_group)
        
        # Параметры
        params_layout = QHBoxLayout()
        
        self._limit_input = QSpinBox()
        self._limit_input.setMinimum(1)
        self._limit_input.setMaximum(10000)
        self._limit_input.setSuffix(" MB")
        
        self._time_unit_label = QLabel("минут")
        self._time_unit_label.setVisible(False)
        
        params_layout.addWidget(self._limit_input)
        params_layout.addWidget(self._time_unit_label)
        layout.addLayout(params_layout)
        
        self.setLayout(layout)
        
        # Connections
        self._size_mode.toggled.connect(self._on_mode_changed)
        self._enabled_checkbox.toggled.connect(self._on_enabled_changed)
    
    def _on_mode_changed(self, checked: bool) -> None:
        """Обработчик смены режима."""
        if checked:
            self._limit_input.setSuffix(" MB")
            self._time_unit_label.setVisible(False)
        else:
            self._limit_input.setSuffix("")
            self._time_unit_label.setVisible(True)

Конфигурация

# config.py
class VideoSettingsSchema(BaseModel):
    # ... existing fields ...
    split_enabled: bool = Field(default=False)
    split_mode: Literal["size", "time"] = Field(default="size")
    max_file_size_mb: float = Field(default=1024.0, ge=100, le=100000)
    max_duration_minutes: int = Field(default=60, ge=1, le=1440)
    segment_padding_width: int = Field(default=3, ge=1, le=6)

Критерии приёмки

  • Настройка максимального размера файла
  • Настройка максимальной продолжительности
  • Автоматическое создание нового файла при достижении лимита
  • Корректная нумерация (001, 002, ...)
  • Информация о сегментах через API
  • UI для настройки параметров
  • Уведомление при создании нового сегмента
  • Unit-тесты для SegmentedFFmpegWriter

Файлы для изменения

  • recorder/segmented_writer.py — новый модуль
  • recorder/ffmpeg_writer.py — интеграция
  • api/routes_config.py — endpoints
  • gui/views/video_view.py — UI
  • config.py — настройки
  • tests/unit/ — тесты

Приоритет: P3

Тэги: enhancement, functionality

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestfunctionalityУлучшения функциональности

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions