summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSchark <jordan@schark.online>2026-03-25 23:22:28 -0400
committerSchark <jordan@schark.online>2026-03-25 23:22:28 -0400
commit028b6a80e473252bfce2d6aaf664f5af8949b760 (patch)
tree968dd89e3617e969ff201d23dc506930f0885c06
parent4262bf19f9234351f7879401ae325a8e144a0d40 (diff)
downloadcs2pov-feat/gui.tar.gz
cs2pov-feat/gui.zip
First pass at GUIfeat/gui
Diffstat (limited to '')
-rw-r--r--cs2pov/cli.py11
-rw-r--r--cs2pov/gui/__init__.py24
-rw-r--r--cs2pov/gui/app.py77
-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__.py8
-rw-r--r--cs2pov/gui/widgets/demo_header.py44
-rw-r--r--cs2pov/gui/widgets/file_picker.py63
-rw-r--r--cs2pov/gui/widgets/log_console.py20
-rw-r--r--cs2pov/gui/widgets/player_table.py81
-rw-r--r--cs2pov/gui/workers.py236
-rw-r--r--cs2pov/loading.py15
-rw-r--r--setup.py3
15 files changed, 1034 insertions, 1 deletions
diff --git a/cs2pov/cli.py b/cs2pov/cli.py
index c3dbf38..d80b73a 100644
--- a/cs2pov/cli.py
+++ b/cs2pov/cli.py
@@ -878,6 +878,13 @@ Examples:
description="Generate a template cs2pov.json config with defaults and example jobs.",
)
+ # GUI command
+ subparsers.add_parser(
+ "gui",
+ help="Launch the graphical interface (requires PySide6)",
+ description="Open the cs2pov GUI. Install PySide6 with: pip install cs2pov[gui]",
+ )
+
return parser
@@ -1619,6 +1626,10 @@ def main() -> int:
return cmd_trim(args)
elif args.command == "comms":
return cmd_comms(args)
+ elif args.command == "gui":
+ from .gui import launch_gui
+ launch_gui()
+ return 0
else:
parser.print_help()
return 1
diff --git a/cs2pov/gui/__init__.py b/cs2pov/gui/__init__.py
new file mode 100644
index 0000000..0fdb358
--- /dev/null
+++ b/cs2pov/gui/__init__.py
@@ -0,0 +1,24 @@
+"""CS2 POV Recorder GUI - PySide6 frontend."""
+
+
+def launch_gui():
+ """Launch the GUI application.
+
+ Raises ImportError with install instructions if PySide6 is not available.
+ """
+ try:
+ from PySide6 import QtWidgets # noqa: F401
+ except ImportError:
+ raise SystemExit(
+ "PySide6 is required for the GUI.\n"
+ "Install it with: pip install cs2pov[gui]\n"
+ " or: pip install PySide6>=6.5"
+ )
+
+ from .app import MainWindow
+
+ app = QtWidgets.QApplication([])
+ app.setApplicationName("cs2pov")
+ window = MainWindow()
+ window.show()
+ raise SystemExit(app.exec())
diff --git a/cs2pov/gui/app.py b/cs2pov/gui/app.py
new file mode 100644
index 0000000..1da91c4
--- /dev/null
+++ b/cs2pov/gui/app.py
@@ -0,0 +1,77 @@
+"""Main application window."""
+
+import sys
+
+from PySide6.QtGui import QColor, QPalette
+from PySide6.QtWidgets import QMainWindow, QTabWidget, QStatusBar
+
+from .pages import POVPage, InfoPage, TrimPage
+
+
+def _apply_dark_theme(app):
+ """Apply a CS2-themed dark palette."""
+ palette = QPalette()
+
+ dark = QColor(30, 30, 36)
+ mid_dark = QColor(42, 42, 50)
+ mid = QColor(58, 58, 68)
+ text = QColor(220, 220, 220)
+ dim_text = QColor(140, 140, 150)
+ accent = QColor(78, 154, 255) # CS2 blue
+ highlight_text = QColor(255, 255, 255)
+
+ palette.setColor(QPalette.ColorRole.Window, dark)
+ palette.setColor(QPalette.ColorRole.WindowText, text)
+ palette.setColor(QPalette.ColorRole.Base, mid_dark)
+ palette.setColor(QPalette.ColorRole.AlternateBase, mid)
+ palette.setColor(QPalette.ColorRole.ToolTipBase, mid)
+ palette.setColor(QPalette.ColorRole.ToolTipText, text)
+ palette.setColor(QPalette.ColorRole.Text, text)
+ palette.setColor(QPalette.ColorRole.Button, mid_dark)
+ palette.setColor(QPalette.ColorRole.ButtonText, text)
+ palette.setColor(QPalette.ColorRole.BrightText, highlight_text)
+ palette.setColor(QPalette.ColorRole.Link, accent)
+ palette.setColor(QPalette.ColorRole.Highlight, accent)
+ palette.setColor(QPalette.ColorRole.HighlightedText, highlight_text)
+ palette.setColor(QPalette.ColorRole.PlaceholderText, dim_text)
+
+ # Disabled state
+ palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, dim_text)
+ palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, dim_text)
+ palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, dim_text)
+
+ app.setPalette(palette)
+
+
+class MainWindow(QMainWindow):
+ """Main application window with tabbed interface."""
+
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("cs2pov")
+ 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()
+
+ tabs.addTab(self._pov_page, "POV Recording")
+ tabs.addTab(self._info_page, "Demo Info")
+ tabs.addTab(self._trim_page, "Trim")
+
+ self.setCentralWidget(tabs)
+
+ 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")
+
diff --git a/cs2pov/gui/pages/__init__.py b/cs2pov/gui/pages/__init__.py
new file mode 100644
index 0000000..a9f0c9a
--- /dev/null
+++ b/cs2pov/gui/pages/__init__.py
@@ -0,0 +1,7 @@
+"""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
new file mode 100644
index 0000000..6b4ed17
--- /dev/null
+++ b/cs2pov/gui/pages/info_page.py
@@ -0,0 +1,77 @@
+"""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
new file mode 100644
index 0000000..fee2fdb
--- /dev/null
+++ b/cs2pov/gui/pages/pov_page.py
@@ -0,0 +1,243 @@
+"""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
new file mode 100644
index 0000000..853ce68
--- /dev/null
+++ b/cs2pov/gui/pages/trim_page.py
@@ -0,0 +1,126 @@
+"""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
new file mode 100644
index 0000000..3cb0237
--- /dev/null
+++ b/cs2pov/gui/widgets/__init__.py
@@ -0,0 +1,8 @@
+"""Reusable GUI widgets."""
+
+from .file_picker import FilePicker
+from .player_table import PlayerTable
+from .log_console import LogConsole
+from .demo_header import DemoHeader
+
+__all__ = ["FilePicker", "PlayerTable", "LogConsole", "DemoHeader"]
diff --git a/cs2pov/gui/widgets/demo_header.py b/cs2pov/gui/widgets/demo_header.py
new file mode 100644
index 0000000..7212755
--- /dev/null
+++ b/cs2pov/gui/widgets/demo_header.py
@@ -0,0 +1,44 @@
+"""Demo metadata display widget."""
+
+from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel
+
+
+class DemoHeader(QWidget):
+ """Displays demo metadata: map, tickrate, duration, ticks."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ self._map_label = QLabel()
+ self._tickrate_label = QLabel()
+ self._ticks_label = QLabel()
+ self._duration_label = QLabel()
+
+ for label in (self._map_label, self._tickrate_label, self._ticks_label, self._duration_label):
+ label.setStyleSheet("font-weight: bold; padding: 4px 8px;")
+ layout.addWidget(label)
+
+ layout.addStretch()
+ self.clear()
+
+ def set_info(self, demo_info):
+ """Update from a DemoInfo dataclass.
+
+ Args:
+ demo_info: parser.DemoInfo instance
+ """
+ self._map_label.setText(f"Map: {demo_info.map_name}")
+ self._tickrate_label.setText(f"Tickrate: {demo_info.tick_rate:.0f}")
+ self._ticks_label.setText(f"Ticks: {demo_info.total_ticks}")
+ if demo_info.total_ticks and demo_info.tick_rate:
+ duration = demo_info.total_ticks / demo_info.tick_rate
+ mins, secs = divmod(int(duration), 60)
+ self._duration_label.setText(f"Duration: {mins}:{secs:02d}")
+ else:
+ self._duration_label.setText("Duration: N/A")
+
+ def clear(self):
+ for label in (self._map_label, self._tickrate_label, self._ticks_label, self._duration_label):
+ label.setText("")
diff --git a/cs2pov/gui/widgets/file_picker.py b/cs2pov/gui/widgets/file_picker.py
new file mode 100644
index 0000000..49a5856
--- /dev/null
+++ b/cs2pov/gui/widgets/file_picker.py
@@ -0,0 +1,63 @@
+"""File picker widget - label + path display + Browse button."""
+
+from pathlib import Path
+
+from PySide6.QtCore import Signal
+from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog
+
+
+class FilePicker(QWidget):
+ """Row widget: label | path text field | Browse button."""
+
+ path_changed = Signal(str)
+
+ def __init__(
+ self,
+ label: str,
+ filter_str: str = "",
+ save_mode: bool = False,
+ directory_mode: bool = False,
+ parent=None,
+ ):
+ super().__init__(parent)
+ self._filter_str = filter_str
+ self._save_mode = save_mode
+ self._directory_mode = directory_mode
+
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ self._label = QLabel(label)
+ self._label.setFixedWidth(100)
+ layout.addWidget(self._label)
+
+ self._line_edit = QLineEdit()
+ self._line_edit.setPlaceholderText("No file selected")
+ self._line_edit.textChanged.connect(self.path_changed.emit)
+ layout.addWidget(self._line_edit, 1)
+
+ self._browse_btn = QPushButton("Browse")
+ self._browse_btn.setFixedWidth(80)
+ self._browse_btn.clicked.connect(self._browse)
+ layout.addWidget(self._browse_btn)
+
+ def _browse(self):
+ if self._directory_mode:
+ path = QFileDialog.getExistingDirectory(self, "Select Directory")
+ elif self._save_mode:
+ path, _ = QFileDialog.getSaveFileName(self, "Save File", "", self._filter_str)
+ else:
+ path, _ = QFileDialog.getOpenFileName(self, "Open File", "", self._filter_str)
+
+ if path:
+ self._line_edit.setText(path)
+
+ def path(self) -> str:
+ return self._line_edit.text().strip()
+
+ def set_path(self, path: str):
+ self._line_edit.setText(path)
+
+ def set_enabled(self, enabled: bool):
+ self._line_edit.setEnabled(enabled)
+ self._browse_btn.setEnabled(enabled)
diff --git a/cs2pov/gui/widgets/log_console.py b/cs2pov/gui/widgets/log_console.py
new file mode 100644
index 0000000..a158902
--- /dev/null
+++ b/cs2pov/gui/widgets/log_console.py
@@ -0,0 +1,20 @@
+"""Read-only log console widget."""
+
+from PySide6.QtWidgets import QTextEdit
+from PySide6.QtGui import QFont, QTextCursor
+
+
+class LogConsole(QTextEdit):
+ """Read-only text area that shows log output, auto-scrolling to bottom."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setReadOnly(True)
+ self.setFont(QFont("Monospace", 9))
+ self.setMinimumHeight(120)
+
+ def append_line(self, text: str):
+ """Append a line and scroll to bottom."""
+ self.moveCursor(QTextCursor.MoveOperation.End)
+ self.insertPlainText(text + "\n")
+ self.moveCursor(QTextCursor.MoveOperation.End)
diff --git a/cs2pov/gui/widgets/player_table.py b/cs2pov/gui/widgets/player_table.py
new file mode 100644
index 0000000..18786df
--- /dev/null
+++ b/cs2pov/gui/widgets/player_table.py
@@ -0,0 +1,81 @@
+"""Player table widget with radio button selection."""
+
+from PySide6.QtCore import Signal
+from PySide6.QtWidgets import (
+ QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem,
+ QRadioButton, QHeaderView, QButtonGroup,
+)
+
+
+class PlayerTable(QWidget):
+ """Table showing players from a demo with optional radio selection."""
+
+ player_selected = Signal(int) # Emits row index
+
+ def __init__(self, selectable: bool = True, parent=None):
+ super().__init__(parent)
+ self._selectable = selectable
+ self._button_group = QButtonGroup(self) if selectable else None
+
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ columns = ["Name", "SteamID", "Team", "Kills", "Assists"]
+ if selectable:
+ columns = [""] + columns
+
+ self._table = QTableWidget(0, len(columns))
+ self._table.setHorizontalHeaderLabels(columns)
+ self._table.verticalHeader().setVisible(False)
+ self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
+ self._table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
+
+ header = self._table.horizontalHeader()
+ if selectable:
+ header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
+ self._table.setColumnWidth(0, 30)
+ for i in range(1, len(columns)):
+ header.setSectionResizeMode(i, QHeaderView.ResizeMode.Stretch)
+ else:
+ for i in range(len(columns)):
+ header.setSectionResizeMode(i, QHeaderView.ResizeMode.Stretch)
+
+ layout.addWidget(self._table)
+
+ def set_players(self, players):
+ """Populate table from a list of PlayerInfo objects.
+
+ Args:
+ players: list of parser.PlayerInfo dataclass instances
+ """
+ self._table.setRowCount(0)
+ if self._button_group:
+ # Remove old buttons from group
+ for btn in self._button_group.buttons():
+ self._button_group.removeButton(btn)
+
+ for row, p in enumerate(players):
+ self._table.insertRow(row)
+ col_offset = 0
+
+ if self._selectable:
+ radio = QRadioButton()
+ radio.clicked.connect(lambda checked, r=row: self.player_selected.emit(r))
+ self._button_group.addButton(radio, row)
+ self._table.setCellWidget(row, 0, radio)
+ col_offset = 1
+
+ self._table.setItem(row, col_offset, QTableWidgetItem(p.name))
+ self._table.setItem(row, col_offset + 1, QTableWidgetItem(str(p.steamid)))
+ self._table.setItem(row, col_offset + 2, QTableWidgetItem(p.team or ""))
+ self._table.setItem(row, col_offset + 3, QTableWidgetItem(str(p.kills)))
+ self._table.setItem(row, col_offset + 4, QTableWidgetItem(str(p.assists)))
+
+ def selected_row(self) -> int:
+ """Return selected row index, or -1 if none selected."""
+ if not self._button_group:
+ return -1
+ return self._button_group.checkedId()
+
+ def clear(self):
+ self._table.setRowCount(0)
diff --git a/cs2pov/gui/workers.py b/cs2pov/gui/workers.py
new file mode 100644
index 0000000..854fad3
--- /dev/null
+++ b/cs2pov/gui/workers.py
@@ -0,0 +1,236 @@
+"""Background workers for long-running operations."""
+
+import builtins
+import sys
+import traceback
+from contextlib import contextmanager
+from pathlib import Path
+
+from PySide6.QtCore import QThread, Signal, QObject
+
+
+@contextmanager
+def capture_prints(callback):
+ """Temporarily redirect builtins.print to a callback.
+
+ Only redirects prints to stdout/None (default). Prints to stderr or
+ other file objects pass through to the original print.
+ """
+ original = builtins.print
+
+ def redirect(*args, **kwargs):
+ if kwargs.get("file") not in (None, sys.stdout):
+ original(*args, **kwargs)
+ return
+ callback(" ".join(str(a) for a in args))
+
+ builtins.print = redirect
+ try:
+ yield
+ finally:
+ builtins.print = original
+
+
+class ParseWorker(QThread):
+ """Parse a demo file in the background."""
+
+ message = Signal(str)
+ finished = Signal(object) # Emits (DemoInfo, DemoTimeline | None)
+ error = Signal(str)
+
+ def __init__(self, demo_path: Path, player_steamid: int = None, player_name: str = "", parent=None):
+ super().__init__(parent)
+ self.demo_path = demo_path
+ self.player_steamid = player_steamid
+ self.player_name = player_name
+
+ def run(self):
+ try:
+ from ..parser import parse_demo
+ from ..preprocessor import preprocess_demo
+ from ..loading import _set_gui_mode
+
+ _set_gui_mode(True)
+
+ with capture_prints(self.message.emit):
+ self.message.emit(f"Parsing demo: {self.demo_path.name}")
+ demo_info = parse_demo(self.demo_path)
+ self.message.emit(
+ f" Map: {demo_info.map_name}, {len(demo_info.players)} players, "
+ f"Ticks: {demo_info.total_ticks}, Rate: {demo_info.tick_rate}"
+ )
+
+ timeline = None
+ if self.player_steamid is not None:
+ self.message.emit(f"Preprocessing timeline for {self.player_name or self.player_steamid}...")
+ timeline = preprocess_demo(self.demo_path, self.player_steamid, self.player_name)
+ self.message.emit(f" {len(timeline.alive_segments)} alive segments, {len(timeline.deaths)} deaths")
+
+ self.finished.emit((demo_info, timeline))
+
+ except Exception as e:
+ self.error.emit(f"{type(e).__name__}: {e}")
+ finally:
+ from ..loading import _set_gui_mode
+ _set_gui_mode(False)
+
+
+class RecordWorker(QThread):
+ """Run the full recording pipeline in the background."""
+
+ message = Signal(str)
+ finished = Signal(object) # Emits RecordingResult or None
+ error = Signal(str)
+
+ def __init__(
+ self,
+ demo_path: Path,
+ player_identifier: str,
+ output_path: Path,
+ resolution: tuple = (1920, 1080),
+ framerate: int = 60,
+ hide_hud: bool = True,
+ display_num: int = 0,
+ enable_audio: bool = True,
+ tick_nav: bool = False,
+ do_trim: bool = True,
+ cs2_path_override: Path = None,
+ verbose: bool = False,
+ parent=None,
+ ):
+ super().__init__(parent)
+ self.demo_path = demo_path
+ self.player_identifier = player_identifier
+ self.output_path = output_path
+ self.resolution = resolution
+ self.framerate = framerate
+ self.hide_hud = hide_hud
+ self.display_num = display_num
+ self.enable_audio = enable_audio
+ self.tick_nav = tick_nav
+ self.do_trim = do_trim
+ self.cs2_path_override = cs2_path_override
+ self.verbose = verbose
+
+ def run(self):
+ try:
+ from ..cli import record_demo, postprocess_video
+ from ..loading import _set_gui_mode
+
+ _set_gui_mode(True)
+
+ with capture_prints(self.message.emit):
+ result = record_demo(
+ demo_path=self.demo_path,
+ player_identifier=self.player_identifier,
+ output_path=self.output_path,
+ resolution=self.resolution,
+ framerate=self.framerate,
+ hide_hud=self.hide_hud,
+ display_num=self.display_num,
+ verbose=self.verbose,
+ cs2_path_override=self.cs2_path_override,
+ enable_audio=self.enable_audio,
+ tick_nav=self.tick_nav,
+ )
+
+ if result.success and self.do_trim:
+ self.message.emit("\nPost-processing: trimming death periods...")
+ postprocess_video(
+ video_path=result.video_path,
+ console_log_path=result.console_log_path,
+ verbose=self.verbose,
+ timeline=result.timeline,
+ transitions=result.transitions,
+ )
+
+ self.finished.emit(result)
+
+ except Exception as e:
+ tb = traceback.format_exc()
+ self.error.emit(f"{type(e).__name__}: {e}\n{tb}")
+ finally:
+ from ..loading import _set_gui_mode
+ _set_gui_mode(False)
+
+
+class TrimWorker(QThread):
+ """Run post-processing trim in the background."""
+
+ message = Signal(str)
+ finished = Signal(object) # Emits (output_path, keep_segments) or None
+ error = Signal(str)
+
+ def __init__(
+ self,
+ video_path: Path,
+ demo_path: Path,
+ player_identifier: str,
+ output_path: Path = None,
+ tick_nav: bool = False,
+ startup_time: float = None,
+ verbose: bool = False,
+ parent=None,
+ ):
+ super().__init__(parent)
+ self.video_path = video_path
+ self.demo_path = demo_path
+ self.player_identifier = player_identifier
+ self.output_path = output_path
+ self.tick_nav = tick_nav
+ self.startup_time = startup_time
+ self.verbose = verbose
+
+ def run(self):
+ try:
+ from ..parser import parse_demo, find_player
+ from ..preprocessor import preprocess_demo
+ from ..cli import postprocess_video
+ from ..trim import load_transitions
+ from ..loading import _set_gui_mode
+
+ _set_gui_mode(True)
+
+ with capture_prints(self.message.emit):
+ self.message.emit(f"Parsing demo: {self.demo_path.name}")
+ demo_info = parse_demo(self.demo_path)
+ player = find_player(demo_info, self.player_identifier)
+ self.message.emit(f"Player: {player.name} ({player.steamid})")
+
+ self.message.emit("Preprocessing timeline...")
+ timeline = preprocess_demo(self.demo_path, player.steamid, player.name)
+
+ transitions = None
+ if self.tick_nav:
+ transitions = load_transitions(self.video_path)
+
+ # Determine output path
+ video_path = self.video_path
+ if self.output_path:
+ # Copy input to output location, trim will work on it
+ import shutil
+ shutil.copy2(self.video_path, self.output_path)
+ video_path = self.output_path
+
+ # Build a fake console log path (postprocess_video needs it for demo end detection)
+ console_log_path = self.video_path.parent / f"console_{self.demo_path.stem}.log"
+
+ self.message.emit("Trimming...")
+ result_path, keep_segments = postprocess_video(
+ video_path=video_path,
+ console_log_path=console_log_path,
+ verbose=self.verbose,
+ timeline=timeline,
+ startup_time_override=self.startup_time,
+ transitions=transitions,
+ )
+
+ self.message.emit(f"Done: {result_path}")
+ self.finished.emit((result_path, keep_segments))
+
+ except Exception as e:
+ tb = traceback.format_exc()
+ self.error.emit(f"{type(e).__name__}: {e}\n{tb}")
+ finally:
+ from ..loading import _set_gui_mode
+ _set_gui_mode(False)
diff --git a/cs2pov/loading.py b/cs2pov/loading.py
index 5c57bb9..9f18439 100644
--- a/cs2pov/loading.py
+++ b/cs2pov/loading.py
@@ -5,6 +5,15 @@ import sys
import threading
import time
+# GUI mode flag: when True, LoadingAnimation.start() is a no-op
+_gui_mode = False
+
+
+def _set_gui_mode(value: bool):
+ """Enable/disable GUI mode. When enabled, LoadingAnimation does nothing."""
+ global _gui_mode
+ _gui_mode = value
+
# =============================================================================
# Loading Messages (add more here!)
# =============================================================================
@@ -118,7 +127,9 @@ class LoadingAnimation:
self._draw()
def start(self):
- """Start the animation."""
+ """Start the animation. No-op in GUI mode."""
+ if _gui_mode:
+ return
self._stop_event.clear()
self._current_dots = self.min_dots
self._stderr = sys.stderr
@@ -154,6 +165,8 @@ class LoadingAnimation:
def stop(self):
"""Stop the animation and clear the line."""
+ if _gui_mode:
+ return
with self._lock:
self._active = False
diff --git a/setup.py b/setup.py
index 9ff49b6..1d95368 100644
--- a/setup.py
+++ b/setup.py
@@ -19,6 +19,9 @@ setup(
python_requires=">=3.10",
packages=find_packages(),
install_requires=requirements,
+ extras_require={
+ "gui": ["PySide6>=6.5"],
+ },
entry_points={
"console_scripts": [
"cs2pov=cs2pov.cli:main",