summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cs2pov/__main__.py5
-rw-r--r--cs2pov/gui/app.py26
-rw-r--r--cs2pov/gui/config_bridge.py70
-rw-r--r--cs2pov/gui/workers.py392
4 files changed, 239 insertions, 254 deletions
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 <path>` 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):