diff options
| author | Schark <jordan@schark.online> | 2026-03-26 00:02:21 -0400 |
|---|---|---|
| committer | Schark <jordan@schark.online> | 2026-03-26 00:02:21 -0400 |
| commit | 7e98ec883241a40ba79b14e6a571cd949c1cd8e7 (patch) | |
| tree | 9b8274043bd05d5cddd626e4128e5996e0f881f4 | |
| parent | 028b6a80e473252bfce2d6aaf664f5af8949b760 (diff) | |
| download | cs2pov-7e98ec883241a40ba79b14e6a571cd949c1cd8e7.tar.gz cs2pov-7e98ec883241a40ba79b14e6a571cd949c1cd8e7.zip | |
Update UX for batching jobs
Diffstat (limited to '')
| -rw-r--r-- | cs2pov/gui/app.py | 130 | ||||
| -rw-r--r-- | cs2pov/gui/job_queue.py | 200 | ||||
| -rw-r--r-- | cs2pov/gui/pages/__init__.py | 7 | ||||
| -rw-r--r-- | cs2pov/gui/pages/info_page.py | 77 | ||||
| -rw-r--r-- | cs2pov/gui/pages/pov_page.py | 243 | ||||
| -rw-r--r-- | cs2pov/gui/pages/trim_page.py | 126 | ||||
| -rw-r--r-- | cs2pov/gui/widgets/__init__.py | 7 | ||||
| -rw-r--r-- | cs2pov/gui/widgets/demo_cache.py | 62 | ||||
| -rw-r--r-- | cs2pov/gui/widgets/file_picker.py | 3 | ||||
| -rw-r--r-- | cs2pov/gui/widgets/job_card.py | 432 | ||||
| -rw-r--r-- | cs2pov/gui/workers.py | 175 |
11 files changed, 991 insertions, 471 deletions
diff --git a/cs2pov/gui/app.py b/cs2pov/gui/app.py index 1da91c4..2ae2e2f 100644 --- a/cs2pov/gui/app.py +++ b/cs2pov/gui/app.py @@ -2,10 +2,15 @@ import sys +from PySide6.QtCore import Qt from PySide6.QtGui import QColor, QPalette -from PySide6.QtWidgets import QMainWindow, QTabWidget, QStatusBar +from PySide6.QtWidgets import QMainWindow, QSplitter, QStatusBar, QApplication -from .pages import POVPage, InfoPage, TrimPage +from .job_queue import JobQueuePage +from .widgets.log_console import LogConsole +from .widgets.demo_cache import DemoCache +from .widgets.job_card import JobStatus +from .workers import BatchJobWorker def _apply_dark_theme(app): @@ -44,7 +49,7 @@ def _apply_dark_theme(app): class MainWindow(QMainWindow): - """Main application window with tabbed interface.""" + """Main application window with job queue and log console.""" def __init__(self): super().__init__() @@ -52,26 +57,119 @@ class MainWindow(QMainWindow): self.setMinimumSize(800, 700) self.resize(900, 750) - from PySide6.QtWidgets import QApplication _apply_dark_theme(QApplication.instance()) - tabs = QTabWidget() - self._pov_page = POVPage() - self._info_page = InfoPage() - self._trim_page = TrimPage() + self._worker = None - tabs.addTab(self._pov_page, "POV Recording") - tabs.addTab(self._info_page, "Demo Info") - tabs.addTab(self._trim_page, "Trim") + # Shared demo cache + self._demo_cache = DemoCache(self) - self.setCentralWidget(tabs) + # Splitter: job queue (top) + log console (bottom) + splitter = QSplitter(Qt.Orientation.Vertical) + + self._job_queue = JobQueuePage(demo_cache=self._demo_cache) + splitter.addWidget(self._job_queue) + + self._log = LogConsole() + splitter.addWidget(self._log) + + # ~70/30 ratio + splitter.setSizes([500, 200]) + splitter.setHandleWidth(4) + splitter.setStyleSheet( + "QSplitter::handle { background: #3a3a44; }" + "QSplitter::handle:hover { background: #4e9aff; }" + ) + + self.setCentralWidget(splitter) self._status_bar = QStatusBar() self.setStatusBar(self._status_bar) self._status_bar.showMessage("Ready") - # Platform check: disable recording on non-Linux - if sys.platform != "linux": - self._pov_page.set_recording_enabled(False) - self._status_bar.showMessage("Recording is only available on Linux") + # Wire up signals + self._demo_cache.parse_complete.connect(self._on_parse_complete) + self._demo_cache.parse_error.connect(self._on_parse_error) + self._demo_cache.parse_started.connect( + lambda p: self._log.append_line(f"Parsing: {p.split('/')[-1]}...") + ) + + self._job_queue.start_requested.connect(self._on_start_jobs) + # Platform check + if sys.platform != "linux": + self._status_bar.showMessage("Recording is only available on Linux (trim/comms still work)") + + def _on_parse_complete(self, path: str, demo_info): + self._log.append_line( + f"Parsed: {demo_info.map_name}, {len(demo_info.players)} players" + ) + self._job_queue.propagate_players(path, demo_info) + + def _on_parse_error(self, path: str, msg: str): + self._log.append_line(f"Parse error ({path.split('/')[-1]}): {msg}") + + def _on_start_jobs(self, jobs: list): + if not jobs or self._worker is not None: + return + + # Validate jobs + for i, job in enumerate(jobs): + if not job.get("demo_path"): + self._log.append_line(f"Job {i + 1}: No demo file selected.") + return + if not job.get("player"): + self._log.append_line(f"Job {i + 1}: No player selected.") + return + if job["type"] in ("pov", "record") and not job.get("output_path"): + self._log.append_line(f"Job {i + 1}: No output path set.") + return + if job["type"] == "trim" and not job.get("video_path"): + self._log.append_line(f"Job {i + 1}: No video file set.") + return + if job["type"] == "comms" and (not job.get("video_path") or not job.get("comms_audio_path")): + self._log.append_line(f"Job {i + 1}: Video and audio files required for comms.") + return + + self._log.clear() + self._log.append_line(f"Starting {len(jobs)} job(s)...") + self._status_bar.showMessage("Running jobs...") + + # Set all cards to queued + cards = self._job_queue.cards() + self._job_queue.set_all_locked(True) + for card in cards: + card.set_status(JobStatus.QUEUED, "Queued") + + self._worker = BatchJobWorker(jobs) + self._worker.message.connect(self._log.append_line) + self._worker.job_started.connect(self._on_job_started) + self._worker.job_finished.connect(self._on_job_finished) + self._worker.job_error.connect(self._on_job_error) + self._worker.all_finished.connect(self._on_all_finished) + self._worker.start() + + def _on_job_started(self, index: int): + cards = self._job_queue.cards() + if 0 <= index < len(cards): + cards[index].set_status(JobStatus.RUNNING, "Running...") + self._status_bar.showMessage(f"Running job {index + 1}...") + + def _on_job_finished(self, index: int, success: bool): + cards = self._job_queue.cards() + if 0 <= index < len(cards): + if success: + cards[index].set_status(JobStatus.SUCCESS, "Completed") + else: + cards[index].set_status(JobStatus.FAILED, "Failed") + + def _on_job_error(self, index: int, msg: str): + cards = self._job_queue.cards() + if 0 <= index < len(cards): + cards[index].set_status(JobStatus.FAILED, f"Failed: {msg}") + + def _on_all_finished(self, succeeded: int, total: int): + self._worker = None + self._job_queue.set_all_locked(False) + self._log.append_line(f"\nAll done: {succeeded}/{total} jobs succeeded.") + self._status_bar.showMessage(f"Done: {succeeded}/{total} succeeded") diff --git a/cs2pov/gui/job_queue.py b/cs2pov/gui/job_queue.py new file mode 100644 index 0000000..0579ec6 --- /dev/null +++ b/cs2pov/gui/job_queue.py @@ -0,0 +1,200 @@ +"""Job queue page - scrollable list of job cards with action bar.""" + +from PySide6.QtCore import Signal, Qt +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QScrollArea, QFrame, +) + +from .widgets.job_card import JobCard, JobStatus + + +class JobQueuePage(QWidget): + """Main page: scrollable job card list with add/clear/start controls.""" + + start_requested = Signal(list) # emits list of job dicts + + def __init__(self, demo_cache=None, parent=None): + super().__init__(parent) + self._demo_cache = demo_cache + self._cards: list[JobCard] = [] + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # --- Scroll area for cards --- + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setFrameShape(QFrame.Shape.NoFrame) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._scroll.setStyleSheet( + "QScrollArea { background: transparent; border: none; }" + "QScrollBar:vertical { background: #1e1e24; width: 8px; border: none; }" + "QScrollBar::handle:vertical { background: #3a3a44; border-radius: 4px; min-height: 30px; }" + "QScrollBar::handle:vertical:hover { background: #555560; }" + "QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }" + ) + + self._scroll_content = QWidget() + self._scroll_layout = QVBoxLayout(self._scroll_content) + self._scroll_layout.setContentsMargins(12, 12, 12, 12) + self._scroll_layout.setSpacing(10) + + # "+" add button at the bottom of the scroll area + self._add_btn = QPushButton("+ Add Job") + self._add_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._add_btn.setMinimumHeight(52) + self._add_btn.setStyleSheet( + "QPushButton {" + " background: transparent;" + " border: 2px dashed #555560;" + " border-radius: 8px;" + " color: #8c8c96;" + " font-size: 14px;" + " font-weight: bold;" + "}" + "QPushButton:hover {" + " border-color: #4e9aff;" + " color: #4e9aff;" + "}" + ) + self._add_btn.clicked.connect(lambda: self.add_job()) + self._scroll_layout.addWidget(self._add_btn) + + self._scroll_layout.addStretch() + self._scroll.setWidget(self._scroll_content) + layout.addWidget(self._scroll, 1) + + # --- Bottom action bar --- + action_bar = QWidget() + action_bar.setStyleSheet("background: #1e1e24;") + action_layout = QHBoxLayout(action_bar) + action_layout.setContentsMargins(12, 8, 12, 8) + + self._clear_btn = QPushButton("Clear Jobs") + self._clear_btn.setFixedHeight(36) + self._clear_btn.setStyleSheet( + "QPushButton {" + " background: #2a2a32;" + " color: #dcdcdc;" + " border: 1px solid #3a3a44;" + " border-radius: 6px;" + " padding: 0 20px;" + " font-size: 13px;" + "}" + "QPushButton:hover { border-color: #555560; background: #333340; }" + "QPushButton:disabled { color: #555560; border-color: #2a2a32; }" + ) + self._clear_btn.clicked.connect(self.clear_jobs) + action_layout.addWidget(self._clear_btn) + + action_layout.addStretch() + + self._start_btn = QPushButton("Start Jobs") + self._start_btn.setFixedHeight(36) + self._start_btn.setStyleSheet( + "QPushButton {" + " background: qlineargradient(x1:0, y1:0, x2:1, y2:0, " + " stop:0 #5aa8ff, stop:1 #3d7dd9);" + " color: white;" + " font-weight: bold;" + " font-size: 14px;" + " border: none;" + " border-radius: 6px;" + " padding: 0 28px;" + "}" + "QPushButton:hover {" + " background: qlineargradient(x1:0, y1:0, x2:1, y2:0, " + " stop:0 #6db5ff, stop:1 #4e8ee6);" + "}" + "QPushButton:disabled {" + " background: #3a3a44;" + " color: #555560;" + "}" + ) + self._start_btn.clicked.connect(self._on_start) + action_layout.addWidget(self._start_btn) + + layout.addWidget(action_bar) + + def add_job(self, config: dict = None) -> JobCard: + """Create a new job card and insert it above the '+' button.""" + card = JobCard(job_number=len(self._cards) + 1) + + # Connect card signals + card.remove_requested.connect(self._on_remove) + card.duplicate_requested.connect(self._on_duplicate) + + if self._demo_cache: + card.demo_changed.connect(self._demo_cache.request_parse) + + self._cards.append(card) + + # Insert before the "+" button (which is at index count-2 before stretch) + insert_idx = self._scroll_layout.count() - 2 # before add_btn and stretch + self._scroll_layout.insertWidget(insert_idx, card) + + if config: + card.load_from_dict(config) + + return card + + def _on_remove(self, card: JobCard): + if card in self._cards: + self._cards.remove(card) + self._scroll_layout.removeWidget(card) + card.deleteLater() + self._renumber() + + def _on_duplicate(self, card: JobCard): + config = card.to_job_dict() + new_card = self.add_job(config) + # If demo was already parsed, propagate players + if self._demo_cache and config.get("demo_path"): + demo_info = self._demo_cache.get_cached(config["demo_path"]) + if demo_info: + new_card.set_players(demo_info.players) + # Try to restore player selection by matching text + player_text = config.get("player_text", "") + if player_text: + idx = new_card._player_combo.findText(player_text) + if idx >= 0: + new_card._player_combo.setCurrentIndex(idx) + + def _renumber(self): + for i, card in enumerate(self._cards): + card.set_job_number(i + 1) + + def clear_jobs(self): + for card in self._cards[:]: + self._scroll_layout.removeWidget(card) + card.deleteLater() + self._cards.clear() + + def cards(self) -> list[JobCard]: + return list(self._cards) + + def set_all_locked(self, locked: bool): + """Lock/unlock all cards and action buttons during execution.""" + for card in self._cards: + card.set_locked(locked) + self._add_btn.setEnabled(not locked) + self._clear_btn.setEnabled(not locked) + self._start_btn.setEnabled(not locked) + self._start_btn.setText("Running..." if locked else "Start Jobs") + + def _on_start(self): + if not self._cards: + return + jobs = [card.to_job_dict() for card in self._cards] + self.start_requested.emit(jobs) + + def propagate_players(self, demo_path: str, demo_info): + """Send player list to all cards that have this demo path.""" + from pathlib import Path + resolved = str(Path(demo_path).resolve()) + for card in self._cards: + card_path = card._demo_picker.path().strip() + if card_path and str(Path(card_path).resolve()) == resolved: + card.set_players(demo_info.players) diff --git a/cs2pov/gui/pages/__init__.py b/cs2pov/gui/pages/__init__.py deleted file mode 100644 index a9f0c9a..0000000 --- a/cs2pov/gui/pages/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""GUI pages (tabs).""" - -from .pov_page import POVPage -from .info_page import InfoPage -from .trim_page import TrimPage - -__all__ = ["POVPage", "InfoPage", "TrimPage"] diff --git a/cs2pov/gui/pages/info_page.py b/cs2pov/gui/pages/info_page.py deleted file mode 100644 index 6b4ed17..0000000 --- a/cs2pov/gui/pages/info_page.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Demo Info page - parse and display demo metadata.""" - -from pathlib import Path - -from PySide6.QtWidgets import QWidget, QVBoxLayout, QPushButton - -from ..widgets import FilePicker, PlayerTable, DemoHeader, LogConsole -from ..workers import ParseWorker - - -class InfoPage(QWidget): - """Tab for viewing demo information.""" - - def __init__(self, parent=None): - super().__init__(parent) - self._worker = None - - layout = QVBoxLayout(self) - - # File picker - self._demo_picker = FilePicker("Demo File", "Demo Files (*.dem)") - layout.addWidget(self._demo_picker) - - # Parse button - self._parse_btn = QPushButton("Parse Demo") - self._parse_btn.clicked.connect(self._on_parse) - layout.addWidget(self._parse_btn) - - # Demo header - self._demo_header = DemoHeader() - layout.addWidget(self._demo_header) - - # Player table (non-selectable, shows all players) - self._player_table = PlayerTable(selectable=False) - layout.addWidget(self._player_table, 1) - - # Log console - self._log = LogConsole() - layout.addWidget(self._log) - - def _on_parse(self): - path = self._demo_picker.path() - if not path: - self._log.append_line("No demo file selected.") - return - - demo_path = Path(path).resolve() - if not demo_path.exists(): - self._log.append_line(f"File not found: {demo_path}") - return - - self._set_busy(True) - self._log.clear() - self._demo_header.clear() - self._player_table.clear() - - self._worker = ParseWorker(demo_path) - self._worker.message.connect(self._log.append_line) - self._worker.finished.connect(self._on_parse_done) - self._worker.error.connect(self._on_error) - self._worker.start() - - def _on_parse_done(self, result): - demo_info, _ = result - self._demo_header.set_info(demo_info) - self._player_table.set_players(demo_info.players) - self._log.append_line(f"Found {len(demo_info.players)} players.") - self._set_busy(False) - - def _on_error(self, msg): - self._log.append_line(f"Error: {msg}") - self._set_busy(False) - - def _set_busy(self, busy): - self._parse_btn.setEnabled(not busy) - self._parse_btn.setText("Parsing..." if busy else "Parse Demo") - self._demo_picker.set_enabled(not busy) diff --git a/cs2pov/gui/pages/pov_page.py b/cs2pov/gui/pages/pov_page.py deleted file mode 100644 index fee2fdb..0000000 --- a/cs2pov/gui/pages/pov_page.py +++ /dev/null @@ -1,243 +0,0 @@ -"""POV Recording page - full recording workflow.""" - -import sys -from pathlib import Path - -from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QPushButton, - QComboBox, QCheckBox, QLabel, QGroupBox, QSpinBox, -) - -from ..widgets import FilePicker, PlayerTable, DemoHeader, LogConsole -from ..workers import ParseWorker, RecordWorker - - -RESOLUTIONS = [ - ("1920x1080", (1920, 1080)), - ("2560x1440", (2560, 1440)), - ("3840x2160", (3840, 2160)), - ("1280x720", (1280, 720)), -] - - -class POVPage(QWidget): - """Tab for the full POV recording workflow.""" - - def __init__(self, parent=None): - super().__init__(parent) - self._worker = None - self._demo_info = None - self._recording_disabled = False - - layout = QVBoxLayout(self) - - # --- File pickers --- - self._demo_picker = FilePicker("Demo File", "Demo Files (*.dem)") - layout.addWidget(self._demo_picker) - - self._output_picker = FilePicker("Output", "Video Files (*.mp4)", save_mode=True) - self._output_picker.path_changed.connect(lambda _: self._update_record_btn()) - layout.addWidget(self._output_picker) - - # --- Parse button --- - self._parse_btn = QPushButton("Parse Demo") - self._parse_btn.clicked.connect(self._on_parse) - layout.addWidget(self._parse_btn) - - # --- Demo header --- - self._demo_header = DemoHeader() - layout.addWidget(self._demo_header) - - # --- Player table --- - self._player_table = PlayerTable(selectable=True) - self._player_table.player_selected.connect(self._on_player_selected) - layout.addWidget(self._player_table, 1) - - # --- Settings --- - settings_box = QGroupBox("Recording Settings") - settings_layout = QVBoxLayout(settings_box) - - # Row 1: resolution, framerate, display - row1 = QHBoxLayout() - row1.addWidget(QLabel("Resolution:")) - self._res_combo = QComboBox() - for label, _ in RESOLUTIONS: - self._res_combo.addItem(label) - row1.addWidget(self._res_combo) - - row1.addWidget(QLabel("Framerate:")) - self._fps_spin = QSpinBox() - self._fps_spin.setRange(24, 240) - self._fps_spin.setValue(60) - row1.addWidget(self._fps_spin) - - row1.addWidget(QLabel("Display:")) - self._display_spin = QSpinBox() - self._display_spin.setRange(0, 9) - self._display_spin.setValue(0) - row1.addWidget(self._display_spin) - - row1.addStretch() - settings_layout.addLayout(row1) - - # Row 2: checkboxes - row2 = QHBoxLayout() - self._hud_cb = QCheckBox("Hide HUD") - self._hud_cb.setChecked(True) - row2.addWidget(self._hud_cb) - - self._tick_nav_cb = QCheckBox("Tick Navigation") - self._tick_nav_cb.setToolTip("Skip death periods during recording (real-time)") - row2.addWidget(self._tick_nav_cb) - - self._trim_cb = QCheckBox("Trim Deaths") - self._trim_cb.setChecked(True) - self._trim_cb.setToolTip("Post-process to remove death periods from video") - row2.addWidget(self._trim_cb) - - self._audio_cb = QCheckBox("Record Audio") - self._audio_cb.setChecked(True) - row2.addWidget(self._audio_cb) - - row2.addStretch() - settings_layout.addLayout(row2) - - layout.addWidget(settings_box) - - # --- Record button --- - self._record_btn = QPushButton("Record POV") - self._record_btn.setEnabled(False) - self._record_btn.setMinimumHeight(40) - self._record_btn.setStyleSheet( - "QPushButton:enabled { background-color: #4e9aff; color: white; font-weight: bold; font-size: 14px; }" - ) - self._record_btn.clicked.connect(self._on_record) - layout.addWidget(self._record_btn) - - # --- Log console --- - self._log = LogConsole() - layout.addWidget(self._log) - - self._selected_player_row = -1 - - def set_recording_enabled(self, enabled: bool): - """Enable/disable recording capability (platform guard).""" - self._recording_disabled = not enabled - if not enabled: - self._record_btn.setEnabled(False) - self._record_btn.setToolTip("Recording is only available on Linux") - - def _on_parse(self): - path = self._demo_picker.path() - if not path: - self._log.append_line("No demo file selected.") - return - - demo_path = Path(path).resolve() - if not demo_path.exists(): - self._log.append_line(f"File not found: {demo_path}") - return - - self._set_busy(True, "parse") - self._log.clear() - self._demo_header.clear() - self._player_table.clear() - self._demo_info = None - self._selected_player_row = -1 - self._update_record_btn() - - self._worker = ParseWorker(demo_path) - self._worker.message.connect(self._log.append_line) - self._worker.finished.connect(self._on_parse_done) - self._worker.error.connect(self._on_error) - self._worker.start() - - def _on_parse_done(self, result): - demo_info, _ = result - self._demo_info = demo_info - self._demo_header.set_info(demo_info) - self._player_table.set_players(demo_info.players) - self._log.append_line(f"Select a player to record ({len(demo_info.players)} found).") - self._set_busy(False, "parse") - - def _on_player_selected(self, row): - self._selected_player_row = row - if self._demo_info and 0 <= row < len(self._demo_info.players): - p = self._demo_info.players[row] - self._log.append_line(f"Selected: {p.name} ({p.steamid})") - self._update_record_btn() - - def _update_record_btn(self): - if self._recording_disabled: - return - can_record = ( - self._demo_info is not None - and self._selected_player_row >= 0 - and bool(self._output_picker.path()) - and self._worker is None - ) - self._record_btn.setEnabled(can_record) - - def _on_record(self): - if not self._demo_info or self._selected_player_row < 0: - return - - player = self._demo_info.players[self._selected_player_row] - demo_path = Path(self._demo_picker.path()).resolve() - output_path = Path(self._output_picker.path()).resolve() - - res_idx = self._res_combo.currentIndex() - resolution = RESOLUTIONS[res_idx][1] - - self._set_busy(True, "record") - self._log.clear() - self._log.append_line(f"Starting recording: {player.name} on {self._demo_info.map_name}") - - self._worker = RecordWorker( - demo_path=demo_path, - player_identifier=str(player.steamid), - output_path=output_path, - resolution=resolution, - framerate=self._fps_spin.value(), - hide_hud=self._hud_cb.isChecked(), - display_num=self._display_spin.value(), - enable_audio=self._audio_cb.isChecked(), - tick_nav=self._tick_nav_cb.isChecked(), - do_trim=self._trim_cb.isChecked(), - ) - self._worker.message.connect(self._log.append_line) - self._worker.finished.connect(self._on_record_done) - self._worker.error.connect(self._on_error) - self._worker.start() - - def _on_record_done(self, result): - if result and result.success: - self._log.append_line(f"\nRecording complete: {result.video_path}") - size_mb = result.video_path.stat().st_size / (1024 * 1024) - self._log.append_line(f"Size: {size_mb:.1f} MB") - elif result: - self._log.append_line(f"\nRecording ended: {result.exit_reason}") - self._set_busy(False, "record") - - def _on_error(self, msg): - self._log.append_line(f"Error: {msg}") - self._set_busy(False, "parse") - - def _set_busy(self, busy, mode="parse"): - if busy: - self._parse_btn.setEnabled(False) - self._record_btn.setEnabled(False) - self._demo_picker.set_enabled(False) - self._output_picker.set_enabled(False) - if mode == "parse": - self._parse_btn.setText("Parsing...") - else: - self._record_btn.setText("Recording...") - else: - self._worker = None - self._parse_btn.setEnabled(True) - self._parse_btn.setText("Parse Demo") - self._record_btn.setText("Record POV") - self._demo_picker.set_enabled(True) - self._output_picker.set_enabled(True) - self._update_record_btn() diff --git a/cs2pov/gui/pages/trim_page.py b/cs2pov/gui/pages/trim_page.py deleted file mode 100644 index 853ce68..0000000 --- a/cs2pov/gui/pages/trim_page.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Trim page - post-process existing recordings.""" - -from pathlib import Path - -from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QPushButton, - QLabel, QLineEdit, QCheckBox, QDoubleSpinBox, -) - -from ..widgets import FilePicker, LogConsole -from ..workers import TrimWorker - - -class TrimPage(QWidget): - """Tab for trimming death periods from existing recordings.""" - - def __init__(self, parent=None): - super().__init__(parent) - self._worker = None - - layout = QVBoxLayout(self) - - # File pickers - self._video_picker = FilePicker("Video File", "Video Files (*.mp4 *.mkv)") - layout.addWidget(self._video_picker) - - self._demo_picker = FilePicker("Demo File", "Demo Files (*.dem)") - layout.addWidget(self._demo_picker) - - # Player input - player_row = QHBoxLayout() - lbl = QLabel("Player") - lbl.setFixedWidth(100) - player_row.addWidget(lbl) - self._player_edit = QLineEdit() - self._player_edit.setPlaceholderText("Player name or SteamID") - player_row.addWidget(self._player_edit) - layout.addLayout(player_row) - - # Output path - self._output_picker = FilePicker("Output", "Video Files (*.mp4)", save_mode=True) - layout.addWidget(self._output_picker) - - # Options row - opts = QHBoxLayout() - self._tick_nav_cb = QCheckBox("Tick-nav mode") - self._tick_nav_cb.setToolTip("Video was recorded with --tick-nav (changes trim strategy)") - opts.addWidget(self._tick_nav_cb) - - opts.addWidget(QLabel("Startup time override:")) - self._startup_spin = QDoubleSpinBox() - self._startup_spin.setRange(0, 9999) - self._startup_spin.setDecimals(1) - self._startup_spin.setSpecialValueText("auto") - self._startup_spin.setValue(0) - self._startup_spin.setSuffix(" s") - opts.addWidget(self._startup_spin) - opts.addStretch() - layout.addLayout(opts) - - # Trim button - self._trim_btn = QPushButton("Trim Video") - self._trim_btn.clicked.connect(self._on_trim) - layout.addWidget(self._trim_btn) - - # Log console - self._log = LogConsole() - layout.addWidget(self._log, 1) - - def _on_trim(self): - video = self._video_picker.path() - demo = self._demo_picker.path() - player = self._player_edit.text().strip() - - if not video or not demo or not player: - self._log.append_line("Please fill in video file, demo file, and player.") - return - - video_path = Path(video).resolve() - demo_path = Path(demo).resolve() - - if not video_path.exists(): - self._log.append_line(f"Video not found: {video_path}") - return - if not demo_path.exists(): - self._log.append_line(f"Demo not found: {demo_path}") - return - - output = self._output_picker.path() - output_path = Path(output).resolve() if output else None - - startup_time = self._startup_spin.value() if self._startup_spin.value() > 0 else None - - self._set_busy(True) - self._log.clear() - - self._worker = TrimWorker( - video_path=video_path, - demo_path=demo_path, - player_identifier=player, - output_path=output_path, - tick_nav=self._tick_nav_cb.isChecked(), - startup_time=startup_time, - ) - self._worker.message.connect(self._log.append_line) - self._worker.finished.connect(self._on_done) - self._worker.error.connect(self._on_error) - self._worker.start() - - def _on_done(self, result): - if result: - path, segments = result - self._log.append_line(f"Trimmed video saved: {path}") - self._set_busy(False) - - def _on_error(self, msg): - self._log.append_line(f"Error: {msg}") - self._set_busy(False) - - def _set_busy(self, busy): - self._trim_btn.setEnabled(not busy) - self._trim_btn.setText("Trimming..." if busy else "Trim Video") - self._video_picker.set_enabled(not busy) - self._demo_picker.set_enabled(not busy) - self._output_picker.set_enabled(not busy) - self._player_edit.setEnabled(not busy) diff --git a/cs2pov/gui/widgets/__init__.py b/cs2pov/gui/widgets/__init__.py index 3cb0237..cb89d0f 100644 --- a/cs2pov/gui/widgets/__init__.py +++ b/cs2pov/gui/widgets/__init__.py @@ -4,5 +4,10 @@ from .file_picker import FilePicker from .player_table import PlayerTable from .log_console import LogConsole from .demo_header import DemoHeader +from .job_card import JobCard, JobStatus +from .demo_cache import DemoCache -__all__ = ["FilePicker", "PlayerTable", "LogConsole", "DemoHeader"] +__all__ = [ + "FilePicker", "PlayerTable", "LogConsole", "DemoHeader", + "JobCard", "JobStatus", "DemoCache", +] diff --git a/cs2pov/gui/widgets/demo_cache.py b/cs2pov/gui/widgets/demo_cache.py new file mode 100644 index 0000000..c99a19d --- /dev/null +++ b/cs2pov/gui/widgets/demo_cache.py @@ -0,0 +1,62 @@ +"""Shared demo parse cache - parses each demo file once and shares results.""" + +from pathlib import Path + +from PySide6.QtCore import QObject, Signal + +from ..workers import ParseWorker + + +class DemoCache(QObject): + """Cache parsed demo results so the same file isn't parsed twice. + + All cache mutations happen on the main thread via signal handlers, + so no explicit locking is needed. + """ + + parse_complete = Signal(str, object) # (abs_path, DemoInfo) + parse_error = Signal(str, str) # (abs_path, error_message) + parse_started = Signal(str) # (abs_path,) + + def __init__(self, parent=None): + super().__init__(parent) + self._cache: dict[str, object] = {} # abs_path -> DemoInfo + self._in_flight: dict[str, ParseWorker] = {} # abs_path -> active worker + + def request_parse(self, path: str): + """Request a demo parse. Emits parse_complete when done. + + If already cached, emits immediately. If already in-flight, + the caller will get the signal when the existing parse finishes. + """ + resolved = str(Path(path).resolve()) + + if resolved in self._cache: + self.parse_complete.emit(resolved, self._cache[resolved]) + return + + if resolved in self._in_flight: + return # Already parsing, signal will fire when done + + self.parse_started.emit(resolved) + + worker = ParseWorker(Path(resolved)) + worker.finished.connect(lambda result, p=resolved: self._on_finished(p, result)) + worker.error.connect(lambda msg, p=resolved: self._on_error(p, msg)) + self._in_flight[resolved] = worker + worker.start() + + def _on_finished(self, path: str, result): + demo_info, _ = result # We only cache DemoInfo, not timeline + self._cache[path] = demo_info + self._in_flight.pop(path, None) + self.parse_complete.emit(path, demo_info) + + def _on_error(self, path: str, msg: str): + self._in_flight.pop(path, None) + self.parse_error.emit(path, msg) + + def get_cached(self, path: str): + """Return cached DemoInfo or None.""" + resolved = str(Path(path).resolve()) + return self._cache.get(resolved) diff --git a/cs2pov/gui/widgets/file_picker.py b/cs2pov/gui/widgets/file_picker.py index 49a5856..abef7b1 100644 --- a/cs2pov/gui/widgets/file_picker.py +++ b/cs2pov/gui/widgets/file_picker.py @@ -17,6 +17,7 @@ class FilePicker(QWidget): filter_str: str = "", save_mode: bool = False, directory_mode: bool = False, + label_width: int = 100, parent=None, ): super().__init__(parent) @@ -28,7 +29,7 @@ class FilePicker(QWidget): layout.setContentsMargins(0, 0, 0, 0) self._label = QLabel(label) - self._label.setFixedWidth(100) + self._label.setFixedWidth(label_width) layout.addWidget(self._label) self._line_edit = QLineEdit() diff --git a/cs2pov/gui/widgets/job_card.py b/cs2pov/gui/widgets/job_card.py new file mode 100644 index 0000000..14b7138 --- /dev/null +++ b/cs2pov/gui/widgets/job_card.py @@ -0,0 +1,432 @@ +"""Job card widget - a single job in the queue.""" + +from enum import Enum + +from PySide6.QtCore import Signal, Qt +from PySide6.QtWidgets import ( + QFrame, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, + QPushButton, QCheckBox, QSpinBox, QDoubleSpinBox, QWidget, +) + +from .file_picker import FilePicker + + +class JobStatus(Enum): + IDLE = "idle" + QUEUED = "queued" + RUNNING = "running" + SUCCESS = "success" + FAILED = "failed" + + +STATUS_COLORS = { + JobStatus.IDLE: "#3a3a44", + JobStatus.QUEUED: "#555560", + JobStatus.RUNNING: "#4e9aff", + JobStatus.SUCCESS: "#4CAF50", + JobStatus.FAILED: "#f44336", +} + +RESOLUTIONS = [ + ("1920x1080", (1920, 1080)), + ("2560x1440", (2560, 1440)), + ("3840x2160", (3840, 2160)), + ("1280x720", (1280, 720)), +] + +JOB_TYPES = ["pov", "record", "trim", "comms"] + + +class JobCard(QFrame): + """A single job card in the queue.""" + + remove_requested = Signal(object) # emits self + duplicate_requested = Signal(object) # emits self + demo_changed = Signal(str) # emits demo path for cache lookup + + def __init__(self, job_number: int = 1, parent=None): + super().__init__(parent) + self._job_number = job_number + self._status = JobStatus.IDLE + self._status_message = "Ready" + self._locked = False + + self.setFrameShape(QFrame.Shape.NoFrame) + self._build_ui() + self._on_type_changed() + self._update_style() + + def _build_ui(self): + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(12, 10, 12, 10) + main_layout.setSpacing(8) + + # --- Header row: remove btn, job label, spacer, type dropdown --- + header = QHBoxLayout() + header.setSpacing(8) + + self._remove_btn = QPushButton("\u00d7") + self._remove_btn.setFixedSize(24, 24) + self._remove_btn.setStyleSheet( + "QPushButton { background: transparent; color: #8c8c96; font-size: 16px; " + "font-weight: bold; border: none; border-radius: 12px; }" + "QPushButton:hover { color: #f44336; background: rgba(244,67,54,0.15); }" + ) + self._remove_btn.clicked.connect(lambda: self.remove_requested.emit(self)) + header.addWidget(self._remove_btn) + + self._job_label = QLabel(f"Job #{self._job_number}") + self._job_label.setStyleSheet("font-weight: bold; color: #8c8c96; font-size: 13px;") + header.addWidget(self._job_label) + + header.addStretch() + + self._dup_btn = QPushButton("Duplicate") + self._dup_btn.setFixedHeight(24) + self._dup_btn.setStyleSheet( + "QPushButton { background: transparent; color: #8c8c96; font-size: 11px; " + "border: 1px solid #3a3a44; border-radius: 4px; padding: 2px 8px; }" + "QPushButton:hover { color: #dcdcdc; border-color: #555560; }" + ) + self._dup_btn.clicked.connect(lambda: self.duplicate_requested.emit(self)) + header.addWidget(self._dup_btn) + + type_label = QLabel("Type:") + type_label.setStyleSheet("color: #8c8c96;") + header.addWidget(type_label) + + self._type_combo = QComboBox() + self._type_combo.addItems(JOB_TYPES) + self._type_combo.setFixedWidth(100) + self._type_combo.currentTextChanged.connect(self._on_type_changed) + header.addWidget(self._type_combo) + + main_layout.addLayout(header) + + # --- File pickers --- + self._demo_picker = FilePicker("Demo", "Demo Files (*.dem)", label_width=70) + self._demo_picker.path_changed.connect(self._on_demo_changed) + main_layout.addWidget(self._demo_picker) + + self._output_picker = FilePicker("Output", "Video Files (*.mp4)", save_mode=True, label_width=70) + main_layout.addWidget(self._output_picker) + + self._video_picker = FilePicker("Video", "Video Files (*.mp4 *.mkv)", label_width=70) + main_layout.addWidget(self._video_picker) + + self._audio_picker = FilePicker("Audio", "Audio Files (*.mp3 *.wav *.ogg *.m4a)", label_width=70) + main_layout.addWidget(self._audio_picker) + + # --- Player dropdown --- + player_row = QHBoxLayout() + player_row.setContentsMargins(0, 0, 0, 0) + plbl = QLabel("Player") + plbl.setFixedWidth(70) + player_row.addWidget(plbl) + self._player_combo = QComboBox() + self._player_combo.setPlaceholderText("Parse a demo first...") + player_row.addWidget(self._player_combo, 1) + self._player_container = QWidget() + self._player_container.setLayout(player_row) + main_layout.addWidget(self._player_container) + + # --- Recording options (checkboxes) --- + self._rec_checks_container = QWidget() + checks_layout = QHBoxLayout(self._rec_checks_container) + checks_layout.setContentsMargins(0, 0, 0, 0) + checks_layout.setSpacing(16) + + self._hud_cb = QCheckBox("Hide HUD") + self._hud_cb.setChecked(True) + checks_layout.addWidget(self._hud_cb) + + self._tick_nav_cb = QCheckBox("Tick Nav") + self._tick_nav_cb.setToolTip("Skip death periods during recording") + checks_layout.addWidget(self._tick_nav_cb) + + self._trim_cb = QCheckBox("Trim") + self._trim_cb.setChecked(True) + self._trim_cb.setToolTip("Post-process to remove death periods") + checks_layout.addWidget(self._trim_cb) + + self._audio_cb = QCheckBox("Audio") + self._audio_cb.setChecked(True) + checks_layout.addWidget(self._audio_cb) + + checks_layout.addStretch() + main_layout.addWidget(self._rec_checks_container) + + # --- Recording settings (resolution, fps, display) --- + self._rec_settings_container = QWidget() + settings_layout = QHBoxLayout(self._rec_settings_container) + settings_layout.setContentsMargins(0, 0, 0, 0) + settings_layout.setSpacing(8) + + settings_layout.addWidget(QLabel("Resolution")) + self._res_combo = QComboBox() + for label, _ in RESOLUTIONS: + self._res_combo.addItem(label) + self._res_combo.setFixedWidth(110) + settings_layout.addWidget(self._res_combo) + + settings_layout.addWidget(QLabel("FPS")) + self._fps_spin = QSpinBox() + self._fps_spin.setRange(24, 240) + self._fps_spin.setValue(60) + self._fps_spin.setFixedWidth(60) + settings_layout.addWidget(self._fps_spin) + + settings_layout.addWidget(QLabel("Display")) + self._display_spin = QSpinBox() + self._display_spin.setRange(0, 9) + self._display_spin.setValue(0) + self._display_spin.setFixedWidth(50) + settings_layout.addWidget(self._display_spin) + + settings_layout.addStretch() + main_layout.addWidget(self._rec_settings_container) + + # --- Trim-specific: startup time --- + self._startup_container = QWidget() + startup_layout = QHBoxLayout(self._startup_container) + startup_layout.setContentsMargins(0, 0, 0, 0) + startup_layout.addWidget(QLabel("Startup time:")) + self._startup_spin = QDoubleSpinBox() + self._startup_spin.setRange(0, 9999) + self._startup_spin.setDecimals(1) + self._startup_spin.setSpecialValueText("auto") + self._startup_spin.setValue(0) + self._startup_spin.setSuffix(" s") + startup_layout.addWidget(self._startup_spin) + + # Trim-specific tick-nav checkbox (separate from recording tick-nav) + self._trim_tick_nav_cb = QCheckBox("Tick-nav mode") + self._trim_tick_nav_cb.setToolTip("Video was recorded with --tick-nav") + startup_layout.addWidget(self._trim_tick_nav_cb) + + startup_layout.addStretch() + main_layout.addWidget(self._startup_container) + + # --- Comms-specific: sync time, volumes --- + self._comms_container = QWidget() + comms_layout = QHBoxLayout(self._comms_container) + comms_layout.setContentsMargins(0, 0, 0, 0) + comms_layout.setSpacing(8) + + comms_layout.addWidget(QLabel("R1 sync time:")) + self._sync_spin = QDoubleSpinBox() + self._sync_spin.setRange(0, 9999) + self._sync_spin.setDecimals(1) + self._sync_spin.setSuffix(" s") + self._sync_spin.setFixedWidth(80) + comms_layout.addWidget(self._sync_spin) + + comms_layout.addWidget(QLabel("Game vol:")) + self._game_vol_spin = QDoubleSpinBox() + self._game_vol_spin.setRange(0, 5.0) + self._game_vol_spin.setDecimals(2) + self._game_vol_spin.setValue(1.0) + self._game_vol_spin.setSingleStep(0.1) + self._game_vol_spin.setFixedWidth(70) + comms_layout.addWidget(self._game_vol_spin) + + comms_layout.addWidget(QLabel("Comms vol:")) + self._comms_vol_spin = QDoubleSpinBox() + self._comms_vol_spin.setRange(0, 5.0) + self._comms_vol_spin.setDecimals(2) + self._comms_vol_spin.setValue(1.0) + self._comms_vol_spin.setSingleStep(0.1) + self._comms_vol_spin.setFixedWidth(70) + comms_layout.addWidget(self._comms_vol_spin) + + comms_layout.addStretch() + main_layout.addWidget(self._comms_container) + + # --- Status label --- + self._status_label = QLabel("Ready") + self._status_label.setStyleSheet("color: #8c8c96; font-size: 11px;") + main_layout.addWidget(self._status_label) + + def _on_type_changed(self): + """Show/hide fields based on current job type.""" + t = self._type_combo.currentText() + + # Demo picker: all types + self._demo_picker.setVisible(True) + + # Output picker: all types (optional for trim/comms) + self._output_picker.setVisible(True) + + # Video picker: trim and comms only + self._video_picker.setVisible(t in ("trim", "comms")) + + # Audio picker: comms only + self._audio_picker.setVisible(t == "comms") + + # Player dropdown: all types + self._player_container.setVisible(True) + + # Recording checkboxes: pov and record + self._rec_checks_container.setVisible(t in ("pov", "record")) + + # Trim checkbox visibility within rec checks + self._trim_cb.setVisible(t == "pov") + + # Recording settings: pov and record + self._rec_settings_container.setVisible(t in ("pov", "record")) + + # Startup time: trim only + self._startup_container.setVisible(t == "trim") + + # Comms settings: comms only + self._comms_container.setVisible(t == "comms") + + def _on_demo_changed(self, path: str): + if path.strip(): + self.demo_changed.emit(path.strip()) + + def set_players(self, players): + """Populate player dropdown from list of PlayerInfo objects.""" + current = self._player_combo.currentText() + self._player_combo.clear() + for p in players: + self._player_combo.addItem(f"{p.name} ({p.steamid})", userData=p) + # Restore selection if same player exists + if current: + idx = self._player_combo.findText(current) + if idx >= 0: + self._player_combo.setCurrentIndex(idx) + + def set_job_number(self, num: int): + self._job_number = num + self._job_label.setText(f"Job #{num}") + + def set_status(self, status: JobStatus, message: str = ""): + self._status = status + self._status_message = message or status.value.capitalize() + self._status_label.setText(self._status_message) + self._update_style() + + def set_locked(self, locked: bool): + """Disable all inputs during execution.""" + self._locked = locked + self._type_combo.setEnabled(not locked) + self._demo_picker.set_enabled(not locked) + self._output_picker.set_enabled(not locked) + self._video_picker.set_enabled(not locked) + self._audio_picker.set_enabled(not locked) + self._player_combo.setEnabled(not locked) + self._hud_cb.setEnabled(not locked) + self._tick_nav_cb.setEnabled(not locked) + self._trim_cb.setEnabled(not locked) + self._audio_cb.setEnabled(not locked) + self._res_combo.setEnabled(not locked) + self._fps_spin.setEnabled(not locked) + self._display_spin.setEnabled(not locked) + self._startup_spin.setEnabled(not locked) + self._trim_tick_nav_cb.setEnabled(not locked) + self._sync_spin.setEnabled(not locked) + self._game_vol_spin.setEnabled(not locked) + self._comms_vol_spin.setEnabled(not locked) + self._remove_btn.setEnabled(not locked) + self._dup_btn.setEnabled(not locked) + + def job_type(self) -> str: + return self._type_combo.currentText() + + def to_job_dict(self) -> dict: + """Extract configuration dict for batch execution.""" + t = self._type_combo.currentText() + player_data = self._player_combo.currentData() + + d = { + "type": t, + "demo_path": self._demo_picker.path(), + "output_path": self._output_picker.path(), + "player": player_data, + "player_text": self._player_combo.currentText(), + } + + if t in ("pov", "record"): + res_idx = self._res_combo.currentIndex() + d["resolution"] = RESOLUTIONS[res_idx][1] + d["framerate"] = self._fps_spin.value() + d["display_num"] = self._display_spin.value() + d["hide_hud"] = self._hud_cb.isChecked() + d["enable_audio"] = self._audio_cb.isChecked() + d["tick_nav"] = self._tick_nav_cb.isChecked() + if t == "pov": + d["do_trim"] = self._trim_cb.isChecked() + else: + d["do_trim"] = False + + if t == "trim": + d["video_path"] = self._video_picker.path() + d["tick_nav"] = self._trim_tick_nav_cb.isChecked() + startup = self._startup_spin.value() + d["startup_time"] = startup if startup > 0 else None + + if t == "comms": + d["video_path"] = self._video_picker.path() + d["comms_audio_path"] = self._audio_picker.path() + d["r1_sync_time"] = self._sync_spin.value() + d["game_volume"] = self._game_vol_spin.value() + d["comms_volume"] = self._comms_vol_spin.value() + + return d + + def load_from_dict(self, d: dict): + """Populate card fields from a job dict (for duplication).""" + idx = self._type_combo.findText(d.get("type", "pov")) + if idx >= 0: + self._type_combo.setCurrentIndex(idx) + + if d.get("demo_path"): + self._demo_picker.set_path(d["demo_path"]) + if d.get("output_path"): + self._output_picker.set_path(d["output_path"]) + if d.get("video_path"): + self._video_picker.set_path(d["video_path"]) + if d.get("comms_audio_path"): + self._audio_picker.set_path(d["comms_audio_path"]) + + if d.get("resolution"): + for i, (_, res) in enumerate(RESOLUTIONS): + if res == d["resolution"]: + self._res_combo.setCurrentIndex(i) + break + if d.get("framerate"): + self._fps_spin.setValue(d["framerate"]) + if d.get("display_num") is not None: + self._display_spin.setValue(d["display_num"]) + if "hide_hud" in d: + self._hud_cb.setChecked(d["hide_hud"]) + if "enable_audio" in d: + self._audio_cb.setChecked(d["enable_audio"]) + if "tick_nav" in d: + self._tick_nav_cb.setChecked(d["tick_nav"]) + self._trim_tick_nav_cb.setChecked(d["tick_nav"]) + if "do_trim" in d: + self._trim_cb.setChecked(d["do_trim"]) + if d.get("startup_time") is not None: + self._startup_spin.setValue(d["startup_time"]) + if "r1_sync_time" in d: + self._sync_spin.setValue(d["r1_sync_time"]) + if "game_volume" in d: + self._game_vol_spin.setValue(d["game_volume"]) + if "comms_volume" in d: + self._comms_vol_spin.setValue(d["comms_volume"]) + + def _update_style(self): + color = STATUS_COLORS.get(self._status, "#3a3a44") + self.setStyleSheet( + f"JobCard {{" + f" background: #2a2a32;" + f" border-radius: 8px;" + f" border-left: 3px solid {color};" + f"}}" + f"JobCard:hover {{" + f" border-left: 3px solid {color};" + f" background: #2e2e38;" + f"}}" + ) diff --git a/cs2pov/gui/workers.py b/cs2pov/gui/workers.py index 854fad3..c4998fd 100644 --- a/cs2pov/gui/workers.py +++ b/cs2pov/gui/workers.py @@ -2,6 +2,7 @@ import builtins import sys +import time import traceback from contextlib import contextmanager from pathlib import Path @@ -154,6 +155,180 @@ class RecordWorker(QThread): _set_gui_mode(False) +class BatchJobWorker(QThread): + """Run a list of jobs sequentially.""" + + message = Signal(str) + job_started = Signal(int) # job index (0-based) + job_finished = Signal(int, bool) # index, success + job_error = Signal(int, str) # index, error_message + all_finished = Signal(int, int) # succeeded_count, total_count + + def __init__(self, jobs: list, parent=None): + super().__init__(parent) + self.jobs = jobs + + def run(self): + succeeded = 0 + last_was_recording = False + + for i, job in enumerate(self.jobs): + try: + from ..loading import _set_gui_mode + _set_gui_mode(True) + + job_type = job["type"] + prefix = f"[Job {i + 1}]" + + # Sleep between recording jobs to let CS2 fully exit + if last_was_recording and job_type in ("pov", "record"): + self.message.emit(f"{prefix} Waiting 10s for CS2 to exit...") + time.sleep(10) + + # Platform guard for recording jobs + if job_type in ("pov", "record") and sys.platform != "linux": + self.job_error.emit(i, "Recording only available on Linux") + last_was_recording = False + continue + + self.job_started.emit(i) + self.message.emit(f"{prefix} Starting {job_type} job...") + + with capture_prints(lambda msg, p=prefix: self.message.emit(f"{p} {msg}")): + if job_type in ("pov", "record"): + self._run_recording(job, prefix) + last_was_recording = True + elif job_type == "trim": + self._run_trim(job, prefix) + last_was_recording = False + elif job_type == "comms": + self._run_comms(job, prefix) + last_was_recording = False + else: + raise ValueError(f"Unknown job type: {job_type}") + + succeeded += 1 + self.job_finished.emit(i, True) + self.message.emit(f"{prefix} Completed successfully.") + + except Exception as e: + tb = traceback.format_exc() + self.job_error.emit(i, f"{type(e).__name__}: {e}") + self.message.emit(f"[Job {i + 1}] FAILED: {e}\n{tb}") + self.job_finished.emit(i, False) + last_was_recording = job.get("type") in ("pov", "record") + finally: + from ..loading import _set_gui_mode + _set_gui_mode(False) + + self.all_finished.emit(succeeded, len(self.jobs)) + + def _run_recording(self, job: dict, prefix: str): + from ..cli import record_demo, postprocess_video + + player = job.get("player") + if player is None: + raise ValueError("No player selected") + + player_id = str(player.steamid) + demo_path = Path(job["demo_path"]).resolve() + output_path = Path(job["output_path"]).resolve() + + result = record_demo( + demo_path=demo_path, + player_identifier=player_id, + output_path=output_path, + resolution=job.get("resolution", (1920, 1080)), + framerate=job.get("framerate", 60), + hide_hud=job.get("hide_hud", True), + display_num=job.get("display_num", 0), + enable_audio=job.get("enable_audio", True), + tick_nav=job.get("tick_nav", False), + ) + + if result.success and job.get("do_trim", False): + self.message.emit(f"{prefix} Post-processing: trimming...") + postprocess_video( + video_path=result.video_path, + console_log_path=result.console_log_path, + timeline=result.timeline, + transitions=result.transitions, + ) + + if not result.success: + raise RuntimeError(f"Recording failed: {result.exit_reason}") + + def _run_trim(self, job: dict, prefix: str): + from ..parser import parse_demo, find_player + from ..preprocessor import preprocess_demo + from ..cli import postprocess_video + from ..trim import load_transitions + + demo_path = Path(job["demo_path"]).resolve() + video_path = Path(job["video_path"]).resolve() + + player = job.get("player") + if player is None: + raise ValueError("No player selected") + + demo_info = parse_demo(demo_path) + player_info = find_player(demo_info, str(player.steamid)) + timeline = preprocess_demo(demo_path, player_info.steamid, player_info.name) + + transitions = None + if job.get("tick_nav"): + transitions = load_transitions(video_path) + + output_path = job.get("output_path") + target = video_path + if output_path: + import shutil + target = Path(output_path).resolve() + shutil.copy2(video_path, target) + + console_log_path = video_path.parent / f"console_{demo_path.stem}.log" + + postprocess_video( + video_path=target, + console_log_path=console_log_path, + timeline=timeline, + startup_time_override=job.get("startup_time"), + transitions=transitions, + ) + + def _run_comms(self, job: dict, prefix: str): + from ..parser import parse_demo, find_player + from ..preprocessor import preprocess_demo + from ..comms import apply_comms_overlay + + demo_path = Path(job["demo_path"]).resolve() + video_path = Path(job["video_path"]).resolve() + comms_audio_path = Path(job["comms_audio_path"]).resolve() + + player = job.get("player") + if player is None: + raise ValueError("No player selected") + + demo_info = parse_demo(demo_path) + player_info = find_player(demo_info, str(player.steamid)) + timeline = preprocess_demo(demo_path, player_info.steamid, player_info.name) + + output_path = job.get("output_path") + if not output_path: + output_path = str(video_path.parent / f"{video_path.stem}_comms{video_path.suffix}") + output_path = Path(output_path).resolve() + + apply_comms_overlay( + video_path=video_path, + comms_audio_path=comms_audio_path, + output_path=output_path, + timeline=timeline, + r1_sync_time=job.get("r1_sync_time", 0.0), + game_volume=job.get("game_volume", 1.0), + comms_volume=job.get("comms_volume", 1.0), + ) + + class TrimWorker(QThread): """Run post-processing trim in the background.""" |
