Skip to content

Добавить возможность записи с нескольких мониторов одновременно #51

@chelslava

Description

@chelslava

Описание

Текущая реализация поддерживает захват только с одного монитора за раз. При использовании нескольких мониторов пользователям приходится выбирать один. Нужна возможность одновременной записи с нескольких источников.

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

Выбор: [Монитор 1] [Монитор 2] [Окно Notepad]
Result: Запись только с одного источника

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

Выбор: [✓ Монитор 1] [✓ Монитор 2] [Окно Notepad]
Result: Мультиплексная запись с двух источников

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

1. Модель мультимониторного захвата

from dataclasses import dataclass, field
from typing import Literal

@dataclass
class MultiMonitorConfig:
    """Конфигурация мультимониторной записи."""
    
    mode: Literal["single", "multi", "pip"] = "single"
    sources: list[CaptureSourceConfig] = field(default_factory=list)
    pip_position: str = "top-right"  # Для PIP режима
    pip_size: tuple[int, int] = (320, 180)

@dataclass
class CaptureSourceConfig:
    """Конфигурация источника захвата."""
    source_id: str  # "monitor-1", "window-Notepad"
    enabled: bool = True
    label: str = ""  # Метка для файла

2. Мультиплексор захвата

from dataclasses import dataclass
import numpy as np

@dataclass
class CapturedFrame:
    """Кадр с метаданными источника."""
    source_id: str
    timestamp: float
    frame: np.ndarray

class MultiMonitorCapture:
    """Захват с нескольких источников одновременно."""
    
    def __init__(self, sources: list[CaptureSourceConfig]):
        self._sources = sources
        self._capture_sessions: dict[str, _WindowsCaptureSession] = {}
        self._lock = threading.Lock()
        self._frame_callback: Callable | None = None
    
    def start(self) -> bool:
        """Запуск всех сессий захвата."""
        for source in self._sources:
            if not source.enabled:
                continue
            
            session = _WindowsCaptureSession(
                on_closed_callback=lambda s, msg: self._on_source_lost(s, msg)
            )
            
            area = self._create_capture_area(source)
            try:
                session.start(area)
                self._capture_sessions[source.source_id] = session
            except Exception as e:
                logger.error(f"Failed to start {source.source_id}: {e}")
                return False
        
        # Запустить поток синхронизации
        self._sync_thread = threading.Thread(target=self._sync_loop, daemon=True)
        self._sync_thread.start()
        return True
    
    def _sync_loop(self) -> None:
        """Цикл синхронизации кадров от разных источников."""
        while self._running:
            frames: list[CapturedFrame] = []
            
            for source_id, session in self._capture_sessions.items():
                frame = session.read_frame(timeout=0.05)
                if frame is not None:
                    frames.append(CapturedFrame(
                        source_id=source_id,
                        timestamp=time.time(),
                        frame=frame
                    ))
            
            if frames and self._frame_callback:
                self._frame_callback(frames)
            
            time.sleep(0.001)  # ~1ms cycle
    
    def _on_source_lost(self, source_id: str, message: str) -> None:
        """Обработчик потери источника."""
        logger.warning(f"Source {source_id} lost: {message}")
        # Попытка восстановления...

3. Мультиплексор FFmpeg

class MultiMonitorFFmpegWriter:
    """FFmpeg writer для нескольких источников."""
    
    def __init__(self, output_path: Path, sources: list[CaptureSourceConfig]):
        self._output_path = output_path
        self._sources = sources
        self._writers: dict[str, FFmpegVideoWriter] = {}
    
    def start(self) -> bool:
        """Запуск всех FFmpeg процессов."""
        for source in self._sources:
            writer = FFmpegVideoWriter(
                output_path=self._get_output_path(source),
                width=self._get_width(source),
                height=self._get_height(source),
                fps=30,
                codec="libx264"
            )
            if not writer.open():
                return False
            self._writers[source.source_id] = writer
        return True
    
    def write(self, frames: list[CapturedFrame]) -> None:
        """Запись кадров от разных источников."""
        for captured in frames:
            if captured.source_id in self._writers:
                self._writers[captured.source_id].write(captured.frame)
    
    def close(self) -> dict[str, bool]:
        """Закрытие всех процессов."""
        results = {}
        for source_id, writer in self._writers.items():
            results[source_id] = writer.close()
        return results

4. PIP (Picture-in-Picture) режим

def composite_pip(
    main_frame: np.ndarray,
    pip_frame: np.ndarray,
    position: str = "top-right",
    pip_size: tuple[int, int] = (320, 180)
) -> np.ndarray:
    """Композитинг PIP изображения."""
    h, w = main_frame.shape[:2]
    pip_w, pip_h = pip_size
    
    # Изменение размера PIP
    pip_resized = cv2.resize(pip_frame, (pip_w, pip_h))
    
    # Определение позиции
    if position == "top-right":
        x, y = w - pip_w - 10, 10
    elif position == "top-left":
        x, y = 10, 10
    elif position == "bottom-right":
        x, y = w - pip_w - 10, h - pip_h - 10
    else:  # bottom-left
        x, y = 10, h - pip_h - 10
    
    # Наложение
    result = main_frame.copy()
    result[y:y+pip_h, x:x+pip_w] = pip_resized
    
    # Рамка
    cv2.rectangle(result, (x, y), (x+pip_w, y+pip_h), (0, 255, 0), 2)
    
    return result

5. API endpoints

# Получить доступные источники для мультимониторинга
GET /api/v1/resources/multi-monitor/sources
Response: {
    "monitors": [
        {"id": "monitor-1", "name": "Primary", "width": 1920, "height": 1080},
        {"id": "monitor-2", "name": "Secondary", "width": 1920, "height": 1080}
    ],
    "windows": [...]
}

# Начать мультимониторную запись
POST /api/v1/recording/start-multi
Body: {
    "mode": "multi",
    "sources": [
        {"source_id": "monitor-1", "label": "main"},
        {"source_id": "monitor-2", "label": "secondary"}
    ],
    "fps": 30,
    "codec": "libx264"
}
Response: {
    "success": true,
    "session_id": "abc123",
    "outputs": {
        "main": "D:\\Videos\\recording_main.mp4",
        "secondary": "D:\\Videos\\recording_secondary.mp4"
    }
}

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

class MultiMonitorSettingsWidget(QWidget):
    """Виджет настроек мультимониторинга."""
    
    def __init__(self):
        super().__init__()
        self._setup_ui()
    
    def _setup_ui(self) -> None:
        layout = QVBoxLayout()
        
        # Режим
        mode_group = QGroupBox("Режим записи")
        mode_layout = QVBoxLayout()
        
        self._mode_single = QRadioButton("Один источник")
        self._mode_multi = QRadioButton("Несколько источников (отдельные файлы)")
        self._mode_pip = QRadioButton("Картинка-в-картинке (PIP)")
        
        mode_layout.addWidget(self._mode_single)
        mode_layout.addWidget(self._mode_multi)
        mode_layout.addWidget(self._mode_pip)
        mode_group.setLayout(mode_layout)
        
        # Источники
        sources_group = QGroupBox("Источники")
        sources_layout = QVBoxLayout()
        
        self._source_list = QListWidget()
        self._source_list.setSelectionMode(QListWidget.SelectionMode.MultiSelection)
        sources_layout.addWidget(self._source_list)
        
        sources_group.setLayout(sources_layout)
        
        layout.addWidget(mode_group)
        layout.addWidget(sources_group)
        self.setLayout(layout)
    
    def populate_sources(self, monitors: list, windows: list) -> None:
        """Заполняет список источников."""
        self._source_list.clear()
        
        for monitor in monitors:
            item = QListWidgetItem(
                f"[Монитор] {monitor['name']} ({monitor['width']}x{monitor['height']})"
            )
            item.setData(Qt.ItemDataRole.UserRole, monitor["id"])
            item.setCheckState(Qt.CheckState.Unchecked)
            self._source_list.addItem(item)
        
        for window in windows:
            item = QListWidgetItem(f"[Окно] {window['title']}")
            item.setData(Qt.ItemDataRole.UserRole, f"window-{window['id']}")
            item.setCheckState(Qt.CheckState.Unchecked)
            self._source_list.addItem(item)

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

  • Поддержка записи с 2+ источников одновременно
  • Три режима: single, multi, PIP
  • Отдельные файлы для каждого источника в multi режиме
  • PIP композит с настраиваемой позицией
  • API endpoints для всех операций
  • UI для выбора источников
  • Синхронизация по времени между источниками
  • Unit-тесты для MultiMonitorCapture

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

  • recorder/multi_monitor.py — новый модуль
  • recorder/video_recorder.py — интеграция
  • recorder/pip_compositor.py — PIP композитинг
  • api/routes_recording.py — endpoints
  • gui/views/capture_view.py — UI
  • tests/unit/ — тесты

Приоритет: P2

Тэги: 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