summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSchark <jordan@schark.online>2026-03-26 00:02:21 -0400
committerSchark <jordan@schark.online>2026-03-26 00:02:21 -0400
commit7e98ec883241a40ba79b14e6a571cd949c1cd8e7 (patch)
tree9b8274043bd05d5cddd626e4128e5996e0f881f4
parent028b6a80e473252bfce2d6aaf664f5af8949b760 (diff)
downloadcs2pov-7e98ec883241a40ba79b14e6a571cd949c1cd8e7.tar.gz
cs2pov-7e98ec883241a40ba79b14e6a571cd949c1cd8e7.zip
Update UX for batching jobs
Diffstat (limited to '')
-rw-r--r--cs2pov/gui/app.py130
-rw-r--r--cs2pov/gui/job_queue.py200
-rw-r--r--cs2pov/gui/pages/__init__.py7
-rw-r--r--cs2pov/gui/pages/info_page.py77
-rw-r--r--cs2pov/gui/pages/pov_page.py243
-rw-r--r--cs2pov/gui/pages/trim_page.py126
-rw-r--r--cs2pov/gui/widgets/__init__.py7
-rw-r--r--cs2pov/gui/widgets/demo_cache.py62
-rw-r--r--cs2pov/gui/widgets/file_picker.py3
-rw-r--r--cs2pov/gui/widgets/job_card.py432
-rw-r--r--cs2pov/gui/workers.py175
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."""