summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSchark <jordan@schark.online>2026-03-26 17:14:00 -0400
committerSchark <jordan@schark.online>2026-03-26 17:14:00 -0400
commit8b90588dea10bf10d603c00c667acdda8c7930ca (patch)
tree97b53d8244e38866dd6d4ee2e75110b6d031e5ea
parent78c22e03d705cf6b4c213678c71c80979df42e4a (diff)
downloadcs2pov-8b90588dea10bf10d603c00c667acdda8c7930ca.tar.gz
cs2pov-8b90588dea10bf10d603c00c667acdda8c7930ca.zip
Simplifying backend
Diffstat (limited to '')
-rw-r--r--cs2pov/cli.py16
-rw-r--r--cs2pov/gui/workers.py263
-rw-r--r--cs2pov/loading.py15
3 files changed, 105 insertions, 189 deletions
diff --git a/cs2pov/cli.py b/cs2pov/cli.py
index d80b73a..f322c2e 100644
--- a/cs2pov/cli.py
+++ b/cs2pov/cli.py
@@ -10,6 +10,7 @@ Subcommand-based CLI:
import argparse
import json
+import signal
import shutil
import sys
import time
@@ -247,8 +248,13 @@ def record_demo(
if verbose:
print(f" Deleted old console.log")
- # Launch CS2
+ # Launch CS2 (kill any stale instance first)
cs2_process = CS2Process(cs2_path, display_str, log_path=cs2_log_path)
+ existing_pid = cs2_process.find_cs2_process()
+ if existing_pid is not None:
+ print(f" Warning: Found existing CS2 process (PID: {existing_pid}), terminating...")
+ cs2_process.terminate()
+ time.sleep(2)
cs2_process.launch("cs2pov_recording.cfg")
print(" CS2 launching via Steam...")
@@ -1608,6 +1614,14 @@ def cmd_comms(args) -> int:
def main() -> int:
"""Main entry point."""
+ # Convert SIGTERM to KeyboardInterrupt so try/finally cleanup runs.
+ # Critical for GUI subprocess: QProcess.terminate() sends SIGTERM,
+ # and without this handler, CS2 and FFmpeg become orphaned processes.
+ def _handle_sigterm(signum, frame):
+ raise KeyboardInterrupt()
+
+ signal.signal(signal.SIGTERM, _handle_sigterm)
+
parser = create_parser()
args = parser.parse_args()
diff --git a/cs2pov/gui/workers.py b/cs2pov/gui/workers.py
index c72c3d0..ea01e4c 100644
--- a/cs2pov/gui/workers.py
+++ b/cs2pov/gui/workers.py
@@ -1,88 +1,82 @@
"""Background workers for long-running operations."""
-import builtins
import json
import os
import re
+import subprocess
import sys
import tempfile
-import traceback
-from contextlib import contextmanager
+import threading
from pathlib import Path
+from types import SimpleNamespace
-from PySide6.QtCore import QThread, Signal, QObject, QProcess, QTimer
+from PySide6.QtCore import QThread, Signal, QObject, QTimer
from .config_bridge import gui_jobs_to_config
-@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."""
+ """Parse a demo file by running `cs2pov info --json` as a subprocess."""
message = Signal(str)
- finished = Signal(object) # Emits (DemoInfo, DemoTimeline | None)
+ finished = Signal(object) # Emits (demo_info_ns, 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}"
+ cmd = [sys.executable, "-u", "-m", "cs2pov", "info", "--json", str(self.demo_path)]
+ self.message.emit(f"Parsing demo: {self.demo_path.name}")
+
+ result = subprocess.run(
+ cmd, capture_output=True, text=True, timeout=120
+ )
+
+ if result.returncode != 0:
+ stderr = result.stderr.strip() if result.stderr else "unknown error"
+ self.error.emit(f"Parse failed: {stderr}")
+ return
+
+ data = json.loads(result.stdout)
+
+ # Convert JSON dict to attribute-accessible objects matching DemoInfo interface
+ players = [
+ SimpleNamespace(
+ name=p["name"],
+ steamid=p["steamid"],
+ kills=p.get("kills", 0),
+ assists=p.get("assists", 0),
)
-
- 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))
-
+ for p in data.get("players", [])
+ ]
+
+ demo_info = SimpleNamespace(
+ map_name=data.get("map", ""),
+ tick_rate=data.get("tick_rate", 0),
+ total_ticks=int(data.get("duration_seconds", 0) * data.get("tick_rate", 64)),
+ players=players,
+ )
+
+ self.message.emit(
+ f" Map: {demo_info.map_name}, {len(demo_info.players)} players, "
+ f"Tickrate: {demo_info.tick_rate}"
+ )
+
+ self.finished.emit((demo_info, None))
+
+ except subprocess.TimeoutExpired:
+ self.error.emit("Parse timed out after 120 seconds")
+ except json.JSONDecodeError as e:
+ self.error.emit(f"Failed to parse CLI output: {e}")
except Exception as e:
self.error.emit(f"{type(e).__name__}: {e}")
- finally:
- from ..loading import _set_gui_mode
- _set_gui_mode(False)
class CLIJobRunner(QObject):
- """Run cs2pov CLI as a subprocess via QProcess.
+ """Run cs2pov CLI as a subprocess.
Writes a temporary cs2pov.json config and launches
`python -u -m cs2pov --config <path>` to execute jobs.
@@ -102,15 +96,15 @@ class CLIJobRunner(QObject):
def __init__(self, jobs: list[dict], parent=None):
super().__init__(parent)
self._jobs = jobs
- self._process: QProcess | None = None
+ self._proc: subprocess.Popen | None = None
+ self._reader_thread: threading.Thread | None = None
self._config_path: str | None = None
self._current_job: int = -1
self._job_results: dict[int, bool] = {} # index -> success
self._seen_batch_markers = False
- self._line_buffer = ""
def start(self):
- """Build config, write to temp file, launch QProcess."""
+ """Build config, write to temp file, launch subprocess."""
config = gui_jobs_to_config(self._jobs)
# Write config to temp file
@@ -123,41 +117,45 @@ class CLIJobRunner(QObject):
self.message.emit(f"Config: {self._config_path}")
self.message.emit(json.dumps(config, indent=2))
- # Set up QProcess
- self._process = QProcess(self)
- self._process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
- self._process.readyReadStandardOutput.connect(self._on_output)
- self._process.finished.connect(self._on_finished)
+ cmd = [sys.executable, "-u", "-m", "cs2pov", "--config", self._config_path]
+ self.message.emit(f"Running: {' '.join(cmd)}")
- # Ensure unbuffered Python output
- env = QProcess.systemEnvironment()
- env.append("PYTHONUNBUFFERED=1")
- self._process.setEnvironment(env)
+ self._proc = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ stdin=subprocess.DEVNULL,
+ )
- args = ["-u", "-m", "cs2pov", "--config", self._config_path]
- self.message.emit(f"Running: {sys.executable} {' '.join(args)}")
- self._process.start(sys.executable, args)
+ # Read output in a background thread to avoid blocking the GUI
+ self._reader_thread = threading.Thread(
+ target=self._read_output, daemon=True
+ )
+ self._reader_thread.start()
+
+ def _read_output(self):
+ """Read subprocess stdout line-by-line (runs in background thread)."""
+ try:
+ for raw_line in self._proc.stdout:
+ line = raw_line.decode("utf-8", errors="replace").rstrip("\n")
+ self._process_line(line)
+ except Exception:
+ pass
+ finally:
+ exit_code = self._proc.wait()
+ self._on_finished(exit_code)
def cancel(self):
"""Terminate the subprocess."""
- if self._process is None:
+ if self._proc is None:
return
- self._process.terminate()
- # Force kill after 5 seconds if still running
- QTimer.singleShot(5000, self._force_kill)
+ self._proc.terminate()
+ # Safety net: force-kill the Python process if still alive after 10s
+ QTimer.singleShot(10000, self._force_kill)
def _force_kill(self):
- if self._process is not None and self._process.state() != QProcess.ProcessState.NotRunning:
- self._process.kill()
-
- def _on_output(self):
- """Read and parse subprocess output line by line."""
- data = self._process.readAllStandardOutput().data().decode("utf-8", errors="replace")
- self._line_buffer += data
-
- while "\n" in self._line_buffer:
- line, self._line_buffer = self._line_buffer.split("\n", 1)
- self._process_line(line)
+ if self._proc is not None and self._proc.poll() is None:
+ self._proc.kill()
def _process_line(self, line: str):
"""Parse a single output line for job progress markers."""
@@ -211,25 +209,20 @@ class CLIJobRunner(QObject):
self.job_finished.emit(self._current_job, True)
return
- def _on_finished(self, exit_code: int, exit_status: QProcess.ExitStatus):
+ def _on_finished(self, exit_code: int):
"""Handle process completion."""
- # Flush remaining buffer
- if self._line_buffer.strip():
- self._process_line(self._line_buffer.strip())
- self._line_buffer = ""
-
total = len(self._jobs)
if not self._seen_batch_markers:
# Single-job case: CLI prints no Job X/Y markers
- success = (exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit)
+ success = exit_code == 0
self.job_started.emit(0)
self._job_results[0] = success
self.job_finished.emit(0, success)
else:
# Close out last job if still open
if self._current_job >= 0 and self._current_job not in self._job_results:
- success = (exit_code == 0)
+ success = exit_code == 0
self._job_results[self._current_job] = success
self.job_finished.emit(self._current_job, success)
@@ -243,86 +236,4 @@ class CLIJobRunner(QObject):
except OSError:
pass
- self._process = None
-
-
-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)
+ self._proc = None
diff --git a/cs2pov/loading.py b/cs2pov/loading.py
index 9f18439..291dc0a 100644
--- a/cs2pov/loading.py
+++ b/cs2pov/loading.py
@@ -5,15 +5,6 @@ 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!)
# =============================================================================
@@ -127,8 +118,8 @@ class LoadingAnimation:
self._draw()
def start(self):
- """Start the animation. No-op in GUI mode."""
- if _gui_mode:
+ """Start the animation. No-op when stderr is not a TTY (e.g. subprocess pipe)."""
+ if not sys.stderr.isatty():
return
self._stop_event.clear()
self._current_dots = self.min_dots
@@ -165,7 +156,7 @@ class LoadingAnimation:
def stop(self):
"""Stop the animation and clear the line."""
- if _gui_mode:
+ if not hasattr(self, '_original_print'):
return
with self._lock:
self._active = False