Описание
Текущая реализация поддерживает захват только с одного монитора за раз. При использовании нескольких мониторов пользователям приходится выбирать один. Нужна возможность одновременной записи с нескольких источников.
Текущее поведение
Выбор: [Монитор 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)
Критерии приёмки
Файлы для изменения
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
Описание
Текущая реализация поддерживает захват только с одного монитора за раз. При использовании нескольких мониторов пользователям приходится выбирать один. Нужна возможность одновременной записи с нескольких источников.
Текущее поведение
Ожидаемое поведение
Технические требования
1. Модель мультимониторного захвата
2. Мультиплексор захвата
3. Мультиплексор FFmpeg
4. PIP (Picture-in-Picture) режим
5. API endpoints
6. UI компонент
Критерии приёмки
Файлы для изменения
recorder/multi_monitor.py— новый модульrecorder/video_recorder.py— интеграцияrecorder/pip_compositor.py— PIP композитингapi/routes_recording.py— endpointsgui/views/capture_view.py— UItests/unit/— тестыПриоритет: P2
Тэги:
enhancement,functionality