From 6f8f9ce5a6e13415bb5248ae84639eac47a79cae Mon Sep 17 00:00:00 2001 From: Schark Date: Thu, 26 Mar 2026 00:22:54 -0400 Subject: Simplify GUI backend to just write to config --- cs2pov/__main__.py | 5 + cs2pov/gui/app.py | 26 ++- cs2pov/gui/config_bridge.py | 70 ++++++++ cs2pov/gui/workers.py | 392 +++++++++++++++++--------------------------- 4 files changed, 239 insertions(+), 254 deletions(-) create mode 100644 cs2pov/__main__.py create mode 100644 cs2pov/gui/config_bridge.py diff --git a/cs2pov/__main__.py b/cs2pov/__main__.py new file mode 100644 index 0000000..b1c49db --- /dev/null +++ b/cs2pov/__main__.py @@ -0,0 +1,5 @@ +"""Allow running cs2pov as a module: python -m cs2pov.""" + +from cs2pov.cli import main + +raise SystemExit(main()) diff --git a/cs2pov/gui/app.py b/cs2pov/gui/app.py index 2ae2e2f..bfd2b36 100644 --- a/cs2pov/gui/app.py +++ b/cs2pov/gui/app.py @@ -10,7 +10,7 @@ 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 +from .workers import CLIJobRunner def _apply_dark_theme(app): @@ -59,7 +59,7 @@ class MainWindow(QMainWindow): _apply_dark_theme(QApplication.instance()) - self._worker = None + self._runner = None # Shared demo cache self._demo_cache = DemoCache(self) @@ -110,7 +110,7 @@ class MainWindow(QMainWindow): 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: + if not jobs or self._runner is not None: return # Validate jobs @@ -141,13 +141,12 @@ class MainWindow(QMainWindow): 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() + self._runner = CLIJobRunner(jobs, parent=self) + self._runner.message.connect(self._log.append_line) + self._runner.job_started.connect(self._on_job_started) + self._runner.job_finished.connect(self._on_job_finished) + self._runner.all_finished.connect(self._on_all_finished) + self._runner.start() def _on_job_started(self, index: int): cards = self._job_queue.cards() @@ -163,13 +162,8 @@ class MainWindow(QMainWindow): 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._runner = 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/config_bridge.py b/cs2pov/gui/config_bridge.py new file mode 100644 index 0000000..fe969b7 --- /dev/null +++ b/cs2pov/gui/config_bridge.py @@ -0,0 +1,70 @@ +"""Convert GUI job dicts to cs2pov.json config format.""" + +from pathlib import Path + + +def gui_jobs_to_config(jobs: list[dict]) -> dict: + """Convert a list of GUI job dicts (from JobCard.to_job_dict()) to cs2pov.json schema. + + Returns a config dict with version, empty defaults, and fully-specified jobs. + """ + config_jobs = [] + + for job in jobs: + job_type = job.get("type", "pov") + entry: dict = {"type": job_type} + + # Demo path — all job types use this + if job.get("demo_path"): + entry["demo"] = str(Path(job["demo_path"]).resolve()) + + # Player — stored as PlayerInfo object, emit steamid string + player = job.get("player") + if player is not None: + entry["player"] = str(player.steamid) + + # Output path + if job.get("output_path"): + entry["output"] = str(Path(job["output_path"]).resolve()) + + if job_type in ("pov", "record"): + # Resolution: (1920, 1080) -> "1920x1080" + res = job.get("resolution", (1920, 1080)) + entry["resolution"] = f"{res[0]}x{res[1]}" + + entry["framerate"] = job.get("framerate", 60) + entry["display"] = job.get("display_num", 0) + entry["no_hud"] = job.get("hide_hud", True) + entry["no_audio"] = not job.get("enable_audio", True) + entry["tick_nav"] = job.get("tick_nav", False) + + # For pov type, respect the trim checkbox; record never trims + if job_type == "pov": + entry["no_trim"] = not job.get("do_trim", True) + else: + entry["no_trim"] = True + + elif job_type == "trim": + if job.get("video_path"): + entry["video"] = str(Path(job["video_path"]).resolve()) + entry["tick_nav"] = job.get("tick_nav", False) + startup = job.get("startup_time") + if startup is not None: + entry["startup_time"] = startup + + elif job_type == "comms": + if job.get("video_path"): + entry["video"] = str(Path(job["video_path"]).resolve()) + if job.get("comms_audio_path"): + entry["audio"] = str(Path(job["comms_audio_path"]).resolve()) + entry["comms_r1_sync_time"] = job.get("r1_sync_time", 0.0) + entry["game_volume"] = job.get("game_volume", 1.0) + entry["comms_volume"] = job.get("comms_volume", 1.0) + + config_jobs.append(entry) + + return { + "version": 1, + "defaults": {}, + "jobs": config_jobs, + } diff --git a/cs2pov/gui/workers.py b/cs2pov/gui/workers.py index c4998fd..e13ce54 100644 --- a/cs2pov/gui/workers.py +++ b/cs2pov/gui/workers.py @@ -1,13 +1,18 @@ """Background workers for long-running operations.""" import builtins +import json +import os +import re import sys -import time +import tempfile import traceback from contextlib import contextmanager from pathlib import Path -from PySide6.QtCore import QThread, Signal, QObject +from PySide6.QtCore import QThread, Signal, QObject, QProcess, QTimer + +from .config_bridge import gui_jobs_to_config @contextmanager @@ -76,257 +81,168 @@ class ParseWorker(QThread): _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) +class CLIJobRunner(QObject): + """Run cs2pov CLI as a subprocess via QProcess. - 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 + Writes a temporary cs2pov.json config and launches + `python -u -m cs2pov --config ` to execute jobs. + """ - def run(self): - try: - from ..cli import record_demo, postprocess_video - from ..loading import _set_gui_mode + message = Signal(str) + job_started = Signal(int) # 0-based index + job_finished = Signal(int, bool) # index, success + all_finished = Signal(int, int) # succeeded, total - _set_gui_mode(True) + # Patterns from CLI _run_batch() output + _RE_JOB_HEADER = re.compile(r"^Job (\d+)/(\d+): .+ \((\w+)\)$") + _RE_BATCH_DONE = re.compile(r"^Batch complete: (\d+)/(\d+) succeeded$") + _RE_JOB_SKIP = re.compile(r"^\[SKIP\] Job (\d+):") + _RE_JOB_FAIL = re.compile(r"^\s+\[FAIL\] Job (\d+):") - 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, - ) + def __init__(self, jobs: list[dict], parent=None): + super().__init__(parent) + self._jobs = jobs + self._process: QProcess | 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.""" + config = gui_jobs_to_config(self._jobs) + + # Write config to temp file + fd, self._config_path = tempfile.mkstemp( + suffix=".json", prefix="cs2pov_gui_" + ) + with os.fdopen(fd, "w") as f: + json.dump(config, f, indent=2) - 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.message.emit(f"Config: {self._config_path}") - self.finished.emit(result) + # 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) - 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) + # Ensure unbuffered Python output + env = QProcess.systemEnvironment() + env.append("PYTHONUNBUFFERED=1") + self._process.setEnvironment(env) + args = ["-u", "-m", "cs2pov", "--config", self._config_path] + self.message.emit(f"Running: {sys.executable} {' '.join(args)}") + self._process.start(sys.executable, args) -class BatchJobWorker(QThread): - """Run a list of jobs sequentially.""" + def cancel(self): + """Terminate the subprocess.""" + if self._process is None: + return + self._process.terminate() + # Force kill after 5 seconds if still running + QTimer.singleShot(5000, 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) + + def _process_line(self, line: str): + """Parse a single output line for job progress markers.""" + self.message.emit(line) + + # Job header: "Job 1/3: demo.dem (pov)" + m = self._RE_JOB_HEADER.match(line) + if m: + self._seen_batch_markers = True + job_num = int(m.group(1)) + idx = job_num - 1 # 0-based + + # Close out previous job as success (if not already failed) + if self._current_job >= 0 and self._current_job not in self._job_results: + self._job_results[self._current_job] = True + self.job_finished.emit(self._current_job, True) + + self._current_job = idx + self.job_started.emit(idx) + return - 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 + # Job skipped: "[SKIP] Job 1: ..." + m = self._RE_JOB_SKIP.match(line) + if m: + self._seen_batch_markers = True + idx = int(m.group(1)) - 1 + self._job_results[idx] = False + self.job_started.emit(idx) + self.job_finished.emit(idx, False) + return - def __init__(self, jobs: list, parent=None): - super().__init__(parent) - self.jobs = jobs + # Job failed in summary: " [FAIL] Job 1: ..." + m = self._RE_JOB_FAIL.match(line) + if m: + idx = int(m.group(1)) - 1 + if idx not in self._job_results: + self._job_results[idx] = False + self.job_finished.emit(idx, False) + elif self._job_results.get(idx) is True: + # Was marked success but summary says fail — correct it + self._job_results[idx] = False + self.job_finished.emit(idx, False) + return - def run(self): - succeeded = 0 - last_was_recording = False + # Batch complete: "Batch complete: 2/3 succeeded" + m = self._RE_BATCH_DONE.match(line) + if m: + # Close out the last running job using the summary + if self._current_job >= 0 and self._current_job not in self._job_results: + self._job_results[self._current_job] = True + self.job_finished.emit(self._current_job, True) + return - for i, job in enumerate(self.jobs): + def _on_finished(self, exit_code: int, exit_status: QProcess.ExitStatus): + """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) + 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) + self._job_results[self._current_job] = success + self.job_finished.emit(self._current_job, success) + + succeeded = sum(1 for v in self._job_results.values() if v) + self.all_finished.emit(succeeded, total) + + # Clean up temp config + if self._config_path: 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), - ) + os.unlink(self._config_path) + except OSError: + pass - 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), - ) + self._process = None class TrimWorker(QThread): -- cgit v1.2.3-18-g5258