summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md16
-rw-r--r--cs2pov/automation.py186
-rw-r--r--cs2pov/cli.py86
-rw-r--r--cs2pov/config.py12
-rw-r--r--cs2pov/navigation.py269
-rw-r--r--cs2pov/trim.py70
6 files changed, 613 insertions, 26 deletions
diff --git a/README.md b/README.md
index fae1973..1f05de6 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,7 @@ Record a player's POV and automatically trim death periods.
```bash
cs2pov pov -d demo.dem -p "PlayerName" -o recording.mp4
+cs2pov pov -d demo.dem -p "PlayerName" -o recording.mp4 --tick-nav # Skip deaths in real-time (faster)
cs2pov pov -d demo.dem -p "PlayerName" -o recording.mp4 --no-trim # Skip trimming
```
@@ -93,6 +94,7 @@ cs2pov trim raw.mp4 -d demo.dem -p "PlayerName" -o trimmed.mp4
| `--audio-device` | | auto | PulseAudio device for audio capture |
| `--display` | | 0 | X display number |
| `--cs2-path` | | auto | Path to CS2 installation |
+| `--tick-nav` | | off | Skip death periods in real-time during recording |
| `--verbose` | `-v` | off | Verbose output |
### Player Identification
@@ -107,17 +109,19 @@ The `--player` argument accepts multiple formats:
## How It Works
1. **Parse demo** - Extract player list and metadata using demoparser2
-2. **Preprocess timeline** - Extract death/spawn events for accurate trimming
+2. **Preprocess timeline** - Extract death/spawn events and alive segments
3. **Generate config** - Create CS2 CFG file with spectator settings
4. **Copy demo** - Place demo in CS2's replays directory
5. **Launch CS2** - Start CS2 via Steam with the generated config
-6. **Wait for first spawn** - Monitor console.log for player spawn
+6. **Wait for demo ready** - Monitor console.log for demo load
7. **Hide demo UI** - Send Shift+F2 to hide playback controls
8. **Start capture** - Launch FFmpeg to record display + audio (PulseAudio)
9. **Recording loop** - Send F5 periodically to keep spectator locked on target player
-10. **Wait for demo end** - Monitor console.log for demo completion
+ - **Standard**: Record full demo, trim death periods in post-processing
+ - **`--tick-nav`**: Detect deaths in real-time via console.log, skip to next round with `demo_gototick`, only trim the brief seek artifacts in post-processing
+10. **Wait for demo end** - Monitor console.log for demo completion (or all segments complete with `--tick-nav`)
11. **Finalize** - Stop capture and terminate CS2
-12. **Post-process** - Trim start and death periods from video using timeline data
+12. **Post-process** - Trim death periods or seek artifacts from video
## Noteworthy Issues/Workarounds
@@ -135,8 +139,8 @@ I'm sorry for making you use Pulseaudio. The audio is captured from your default
## TODO
-- [ ] Refactor navigator to make use of tick-based navigation
+- [x] Refactor navigator to make use of tick-based navigation
-- [ ] Update trimming tool to adhere and account for new navigation system
+- [x] Update trimming tool to adhere and account for new navigation system
- [ ] Add audio overlay functionality to trimming tool
diff --git a/cs2pov/automation.py b/cs2pov/automation.py
index f8a41df..7988e43 100644
--- a/cs2pov/automation.py
+++ b/cs2pov/automation.py
@@ -4,6 +4,7 @@ import os
import re
import shutil
import subprocess
+import time
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
@@ -167,13 +168,11 @@ def wait_for_map_load(log_path: Path, timeout: float = 120, poll_interval: float
Returns:
True if map load detected, False if timeout
"""
- import time
-
map_load_pattern = re.compile(r"\[Client\] Created physics for")
- start = time.time()
+ start_t = time.time()
last_position = 0
- while time.time() - start < timeout:
+ while time.time() - start_t < timeout:
if not log_path.exists():
time.sleep(poll_interval)
continue
@@ -213,15 +212,13 @@ def wait_for_demo_ready(
Returns:
True if ready state detected, False if timeout
"""
- import time
-
ready_pattern = re.compile(
r"\[HostStateManager\] Host activate: Playing Demo"
)
- start = time.time()
+ start_t = time.time()
last_position = 0
- while time.time() - start < timeout:
+ while time.time() - start_t < timeout:
if not log_path.exists():
time.sleep(poll_interval)
continue
@@ -253,7 +250,6 @@ def wait_for_cs2_window(display: str = ":0", timeout: float = 120, poll_interval
Returns:
Window ID when found, or None if timeout
"""
- import time
start = time.time()
while time.time() - start < timeout:
window_id = find_cs2_window(display)
@@ -261,3 +257,175 @@ def wait_for_cs2_window(display: str = ":0", timeout: float = 120, poll_interval
return window_id
time.sleep(poll_interval)
return None
+
+
+# =============================================================================
+# Tick-based navigation primitives
+# =============================================================================
+
+def send_console_command(command: str, display: str, window_id: str) -> bool:
+ """Send a console command to CS2 by opening console, typing, and closing.
+
+ Opens console (grave key), types the command, presses Return, closes console.
+ Includes small delays between steps for reliability.
+
+ Args:
+ command: Console command to send (e.g. "demo_gototick 12345")
+ display: X display string
+ window_id: CS2 window ID
+
+ Returns:
+ True if all xdotool steps succeeded
+ """
+ env = os.environ.copy()
+ env["DISPLAY"] = display
+
+ steps = [
+ # Open console
+ (["xdotool", "key", "--window", window_id, "grave"], 0.1),
+ # Type command
+ (["xdotool", "type", "--window", window_id, "--clearmodifiers", command], 0.05),
+ # Press enter
+ (["xdotool", "key", "--window", window_id, "Return"], 0.1),
+ # Close console
+ (["xdotool", "key", "--window", window_id, "grave"], 0.0),
+ ]
+
+ for cmd, delay in steps:
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=5, env=env)
+ if result.returncode != 0:
+ return False
+ except Exception:
+ return False
+ if delay > 0:
+ time.sleep(delay)
+
+ return True
+
+
+def read_paused_tick(
+ console_log_path: Path,
+ last_position: int,
+ timeout: float = 5.0,
+) -> tuple[Optional[int], int]:
+ """After a demo_pause, poll console.log for the paused tick line.
+
+ Looks for: CGameRules - paused on tick X
+
+ Args:
+ console_log_path: Path to CS2 console.log
+ last_position: File position to start reading from
+ timeout: Maximum time to wait
+
+ Returns:
+ (tick, new_position) or (None, position) on timeout
+ """
+ pattern = re.compile(r"CGameRules - paused on tick (\d+)")
+ start = time.time()
+
+ while time.time() - start < timeout:
+ if not console_log_path.exists():
+ time.sleep(0.1)
+ continue
+
+ try:
+ with open(console_log_path, 'r', errors='ignore') as f:
+ f.seek(last_position)
+ content = f.read()
+ new_position = f.tell()
+
+ match = pattern.search(content)
+ if match:
+ return int(match.group(1)), new_position
+
+ last_position = new_position
+ except Exception:
+ pass
+
+ time.sleep(0.1)
+
+ return None, last_position
+
+
+def calibrate_tick_offset(
+ console_log_path: Path,
+ display: str,
+ window_id: str,
+ log_position: int,
+ verbose: bool = False,
+) -> tuple[int, int]:
+ """Calibrate the tick offset by pausing and reading actual tick.
+
+ At startup after map load, we pause, read the actual tick from console,
+ and compute the offset. All future demo_gototick calls subtract this
+ offset from their target.
+
+ Args:
+ console_log_path: Path to CS2 console.log
+ display: X display string
+ window_id: CS2 window ID
+ log_position: Current position in console.log
+ verbose: Print debug output
+
+ Returns:
+ (offset, new_log_position). offset = actual_tick read from console.
+ """
+ # Pause demo via F7 (bound to demo_pause 1)
+ send_key("F7", display, window_id)
+ time.sleep(0.5)
+
+ # Read the paused tick from console
+ actual_tick, log_position = read_paused_tick(console_log_path, log_position, timeout=5.0)
+
+ # Unpause via F6 (bound to demo_pause 0)
+ send_key("F6", display, window_id)
+
+ if actual_tick is not None:
+ offset = actual_tick
+ if verbose:
+ print(f" Tick calibration: actual_tick={actual_tick}, offset={offset}")
+ return offset, log_position
+ else:
+ if verbose:
+ print(f" Tick calibration failed, using offset=0")
+ return 0, log_position
+
+
+def check_death_in_console(
+ console_log_path: Path,
+ player_slot: int,
+ last_position: int,
+) -> tuple[bool, int]:
+ """Check console.log for player death (Shutdown prediction).
+
+ Incrementally reads console.log looking for:
+ [Prediction] Shutdown prediction for player slot {player_slot}
+
+ Args:
+ console_log_path: Path to CS2 console.log
+ player_slot: 0-based player slot index
+ last_position: File position to start reading from
+
+ Returns:
+ (detected, new_position)
+ """
+ if not console_log_path.exists():
+ return False, last_position
+
+ pattern = re.compile(
+ rf"\[Prediction\] Shutdown prediction for player slot {player_slot}\b"
+ )
+
+ try:
+ with open(console_log_path, 'r', errors='ignore') as f:
+ f.seek(last_position)
+ content = f.read()
+ new_position = f.tell()
+
+ if pattern.search(content):
+ return True, new_position
+
+ return False, new_position
+ except Exception:
+ return False, last_position
diff --git a/cs2pov/cli.py b/cs2pov/cli.py
index ee67945..537b665 100644
--- a/cs2pov/cli.py
+++ b/cs2pov/cli.py
@@ -24,6 +24,7 @@ from .capture import FFmpegCapture, get_default_audio_monitor
from .config import RecordingConfig, generate_recording_cfg
from .exceptions import CS2POVError
from .game import CS2Process, find_cs2_path, get_cfg_dir, get_demo_dir
+from .navigation import GotoTransition, NavigationState, recording_loop_tick_nav
from .parser import DemoInfo, PlayerInfo, find_player, get_player_index, parse_demo
from .preprocessor import DemoTimeline, preprocess_demo, get_trim_periods
from .trim import extract_death_periods
@@ -41,8 +42,9 @@ class RecordingResult:
console_log_path: Path
recording_start_time: float
player_slot: int
- exit_reason: str # "demo_ended", "timeout", "ffmpeg_stopped", "interrupted"
+ exit_reason: str # "demo_ended", "timeout", "ffmpeg_stopped", "interrupted", "segments_complete"
timeline: Optional[DemoTimeline] = None
+ transitions: Optional[list[GotoTransition]] = None
# =============================================================================
@@ -149,6 +151,7 @@ def record_demo(
cs2_path_override: Path | None = None,
enable_audio: bool = True,
audio_device: str | None = None,
+ tick_nav: bool = False,
) -> RecordingResult:
"""Record a player's POV from a demo file."""
# Find CS2 installation
@@ -208,6 +211,7 @@ def record_demo(
player_slot=player_slot,
resolution=resolution,
hide_hud=hide_hud,
+ tick_navigation=tick_nav,
)
generate_recording_cfg(config, cfg_path)
print(f"Generated config: {cfg_path.name}")
@@ -297,16 +301,41 @@ def record_demo(
print(f" FFmpeg capture started (video only)")
recording_start_time = time.time()
+ transitions = None
- exit_reason = recording_loop(
- display=display_str,
- console_log_path=console_log_path,
- cs2_process=cs2_process,
- ffmpeg=ffmpeg,
- timeout=timeout,
- verbose=verbose,
+ # Use tick-based navigation if enabled and timeline available
+ use_tick_nav = (
+ tick_nav
+ and timeline is not None
+ and timeline.alive_segments
)
+ if use_tick_nav:
+ nav_state = NavigationState(
+ timeline=timeline,
+ player_slot=player_slot - 1, # Convert 1-based spec slot to 0-based console slot
+ )
+ exit_reason, transitions = recording_loop_tick_nav(
+ display=display_str,
+ console_log_path=console_log_path,
+ cs2_process=cs2_process,
+ ffmpeg=ffmpeg,
+ state=nav_state,
+ timeout=timeout,
+ verbose=verbose,
+ )
+ else:
+ if tick_nav and (timeline is None or not timeline.alive_segments):
+ print(" Warning: --tick-nav requires alive segments, falling back to standard recording")
+ exit_reason = recording_loop(
+ display=display_str,
+ console_log_path=console_log_path,
+ cs2_process=cs2_process,
+ ffmpeg=ffmpeg,
+ timeout=timeout,
+ verbose=verbose,
+ )
+
except KeyboardInterrupt:
print("\n Recording interrupted by user")
exit_reason = "interrupted"
@@ -336,7 +365,7 @@ def record_demo(
print(f" Console log saved: {saved_log_path.name}")
console_log_path = saved_log_path
- success = exit_reason in ("demo_ended", "cs2_exited")
+ success = exit_reason in ("demo_ended", "cs2_exited", "segments_complete")
return RecordingResult(
success=success,
video_path=output_path,
@@ -345,6 +374,7 @@ def record_demo(
player_slot=player_index,
exit_reason=exit_reason,
timeline=timeline,
+ transitions=transitions,
)
@@ -356,10 +386,14 @@ def postprocess_video(
verbose: bool = False,
timeline: Optional[DemoTimeline] = None,
startup_time_override: Optional[float] = None,
+ transitions: Optional[list[GotoTransition]] = None,
) -> Path:
"""Post-process a recorded video to keep only alive segments.
- New simplified approach:
+ If transitions are provided (from tick-nav recording), uses lightweight
+ transition-based trimming instead of the full alive-segment approach.
+
+ Otherwise uses the standard approach:
1. Calculate startup_time = video_duration - demo_duration
2. Convert alive_segments from demo time to video time
3. Extract and concatenate only the alive segments
@@ -367,8 +401,9 @@ def postprocess_video(
Args:
startup_time_override: If provided, use this value instead of calculating
startup_time. Useful when the automatic calculation is wrong.
+ transitions: If provided, use transition-based trimming (from --tick-nav)
"""
- from .trim import get_video_duration, extract_and_concat_segments
+ from .trim import get_video_duration, extract_and_concat_segments, trim_goto_transitions
if not video_path.exists():
print(f"Error: Video file not found: {video_path}")
@@ -377,6 +412,30 @@ def postprocess_video(
raw_size_mb = video_path.stat().st_size / (1024 * 1024)
print(f"\nRaw recording: {video_path} ({raw_size_mb:.1f} MB)")
+ # Tick-nav transition-based trimming (lightweight)
+ if transitions:
+ print(f"\nPost-processing: trimming {len(transitions)} goto transitions...")
+ raw_path = video_path.parent / f"{video_path.stem}_raw{video_path.suffix}"
+ video_path.rename(raw_path)
+ print(f" Raw recording moved to: {raw_path.name}")
+
+ success = trim_goto_transitions(
+ input_path=raw_path,
+ output_path=video_path,
+ transitions=transitions,
+ verbose=verbose,
+ )
+
+ if success and video_path.exists():
+ final_size_mb = video_path.stat().st_size / (1024 * 1024)
+ print(f"\nFinal recording: {video_path} ({final_size_mb:.1f} MB)")
+ else:
+ print("\nTransition trimming failed, restoring raw recording")
+ if not video_path.exists() and raw_path.exists():
+ raw_path.rename(video_path)
+
+ return video_path
+
print("\nPost-processing: calculating segments to keep...")
video_segments: list[tuple[float, float]] = []
@@ -732,6 +791,8 @@ Examples:
help="PulseAudio device (auto-detected)")
recording_args.add_argument("--cs2-path", type=Path,
help="Custom CS2 installation path")
+ recording_args.add_argument("--tick-nav", action="store_true",
+ help="Enable tick-based navigation (skip deaths in real-time)")
verbose_args = argparse.ArgumentParser(add_help=False)
verbose_args.add_argument("-v", "--verbose", action="store_true",
@@ -864,6 +925,7 @@ def cmd_pov(args) -> int:
cs2_path_override=args.cs2_path,
enable_audio=not args.no_audio,
audio_device=args.audio_device,
+ tick_nav=args.tick_nav,
)
# Post-process
@@ -875,6 +937,7 @@ def cmd_pov(args) -> int:
recording_start_time=result.recording_start_time,
verbose=args.verbose,
timeline=result.timeline,
+ transitions=result.transitions,
)
elif result.success:
size_mb = result.video_path.stat().st_size / (1024 * 1024)
@@ -926,6 +989,7 @@ def cmd_record(args) -> int:
cs2_path_override=args.cs2_path,
enable_audio=not args.no_audio,
audio_device=args.audio_device,
+ tick_nav=args.tick_nav,
)
if result.success:
diff --git a/cs2pov/config.py b/cs2pov/config.py
index 04eab45..8294983 100644
--- a/cs2pov/config.py
+++ b/cs2pov/config.py
@@ -16,6 +16,7 @@ class RecordingConfig:
resolution: tuple[int, int] = (1920, 1080)
hide_hud: bool = True
spec_mode: int = 4 # 4 = first-person, 5 = third-person, 6 = free roam
+ tick_navigation: bool = False
@property
def player_account_id(self) -> int:
@@ -80,6 +81,14 @@ cl_draw_only_deathnotices 0
cl_drawhud 1\
"""
+# Keybinds for tick-based navigation (pause/unpause via keybind is faster
+# than typing console commands since it's a single xdotool key press)
+TICK_NAV_BINDS = """
+// Tick navigation keybinds (used by --tick-nav)
+bind "F6" "demo_pause 0"
+bind "F7" "demo_pause 1"
+"""
+
def generate_recording_cfg(config: RecordingConfig, output_path: Path) -> Path:
"""Generate a CS2 recording configuration file.
@@ -101,6 +110,9 @@ def generate_recording_cfg(config: RecordingConfig, output_path: Path) -> Path:
demo_name=config.demo_name,
)
+ if config.tick_navigation:
+ cfg_content += TICK_NAV_BINDS
+
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(cfg_content)
diff --git a/cs2pov/navigation.py b/cs2pov/navigation.py
new file mode 100644
index 0000000..fd30ebf
--- /dev/null
+++ b/cs2pov/navigation.py
@@ -0,0 +1,269 @@
+"""Tick-based demo navigation - skip death periods in real-time.
+
+Instead of recording the full demo and trimming in post-processing,
+this module detects deaths via console.log and uses demo_gototick to
+skip to the next alive segment. Transition artifacts (the pause/seek/unpause
+period) are trimmed in lightweight post-processing.
+"""
+
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Optional
+
+from .automation import (
+ check_death_in_console,
+ check_demo_ended,
+ calibrate_tick_offset,
+ find_cs2_window,
+ send_console_command,
+ send_key,
+)
+from .capture import FFmpegCapture
+from .game import CS2Process
+from .preprocessor import DemoTimeline
+
+
+@dataclass
+class GotoTransition:
+ """A demo_gototick transition during recording.
+
+ Records the video timestamps of when the demo was paused and unpaused,
+ so post-processing can cut out the transition artifact.
+ """
+ pause_video_time: float # Seconds since recording start
+ unpause_video_time: float # Seconds since recording start
+ from_tick: int
+ to_tick: int
+
+
+@dataclass
+class NavigationState:
+ """State for tick-based navigation during recording."""
+ timeline: DemoTimeline
+ player_slot: int # 0-based, for console.log pattern matching
+ tick_offset: int = 0 # Calibrated offset (actual - expected)
+ current_segment_index: int = 0
+ transitions: list[GotoTransition] = field(default_factory=list)
+ recording_start_time: float = 0.0
+ buffer_seconds: float = 5.0
+
+
+def compute_goto_tick(target_start_tick: int, tick_offset: int) -> int:
+ """Compute the adjusted tick for demo_gototick.
+
+ Args:
+ target_start_tick: The desired target tick (from alive segment)
+ tick_offset: Calibrated offset to subtract
+
+ Returns:
+ Adjusted tick value for demo_gototick command
+ """
+ return max(0, target_start_tick - tick_offset)
+
+
+def handle_death(
+ state: NavigationState,
+ display: str,
+ window_id: str,
+ console_log_path: Path,
+ log_position: int,
+ verbose: bool = False,
+) -> tuple[bool, int]:
+ """Handle a detected player death by seeking to next alive segment.
+
+ Sequence:
+ 1. Pause demo (F7)
+ 2. Find next alive segment
+ 3. demo_gototick to next segment start
+ 4. Wait for seek to complete
+ 5. Re-lock spectator (F5)
+ 6. Unpause (F6)
+
+ Args:
+ state: Current navigation state
+ display: X display string
+ window_id: CS2 window ID
+ console_log_path: Path to console.log
+ log_position: Current log file position
+ verbose: Print debug output
+
+ Returns:
+ (has_more_segments, new_log_position)
+ False means demo is effectively over (no more alive segments)
+ """
+ pause_video_time = time.time() - state.recording_start_time
+
+ # Pause demo
+ send_key("F7", display, window_id)
+ time.sleep(0.3)
+
+ # Advance to next segment
+ state.current_segment_index += 1
+
+ if state.current_segment_index >= len(state.timeline.alive_segments):
+ if verbose:
+ print(f" No more alive segments, recording complete")
+ # Record transition even for the final death (trim trailing dead time)
+ state.transitions.append(GotoTransition(
+ pause_video_time=pause_video_time,
+ unpause_video_time=pause_video_time, # No unpause, we're done
+ from_tick=0,
+ to_tick=0,
+ ))
+ return False, log_position
+
+ next_segment = state.timeline.alive_segments[state.current_segment_index]
+ target_tick = next_segment.start_tick
+ adjusted_tick = compute_goto_tick(target_tick, state.tick_offset)
+
+ if verbose:
+ print(f" Death detected! Seeking to segment {state.current_segment_index + 1} "
+ f"(tick {target_tick}, adjusted {adjusted_tick})")
+
+ # Send demo_gototick command
+ send_console_command(f"demo_gototick {adjusted_tick}", display, window_id)
+
+ # Wait for seek to complete
+ time.sleep(2.0)
+
+ # Re-lock spectator
+ send_key("F5", display, window_id)
+ time.sleep(0.3)
+
+ # Unpause
+ send_key("F6", display, window_id)
+
+ unpause_video_time = time.time() - state.recording_start_time
+
+ # Record transition
+ state.transitions.append(GotoTransition(
+ pause_video_time=pause_video_time,
+ unpause_video_time=unpause_video_time,
+ from_tick=state.timeline.alive_segments[state.current_segment_index - 1].end_tick
+ if state.current_segment_index > 0 else 0,
+ to_tick=target_tick,
+ ))
+
+ if verbose:
+ print(f" Transition: paused at {pause_video_time:.1f}s, "
+ f"unpaused at {unpause_video_time:.1f}s "
+ f"({unpause_video_time - pause_video_time:.1f}s gap)")
+
+ return True, log_position
+
+
+def recording_loop_tick_nav(
+ display: str,
+ console_log_path: Path,
+ cs2_process: CS2Process,
+ ffmpeg: FFmpegCapture,
+ state: NavigationState,
+ timeout: float,
+ verbose: bool = False,
+) -> tuple[str, list[GotoTransition]]:
+ """Recording loop with tick-based navigation (skips death periods).
+
+ Replaces the standard recording_loop when --tick-nav is enabled.
+ Detects deaths via console.log and uses demo_gototick to skip to
+ the next alive segment.
+
+ Args:
+ display: X display string
+ console_log_path: Path to CS2 console.log
+ cs2_process: Running CS2 process
+ ffmpeg: Running FFmpeg capture
+ state: Navigation state with timeline and calibration data
+ timeout: Maximum recording time in seconds
+ verbose: Print debug output
+
+ Returns:
+ (exit_reason, transitions) where exit_reason is one of:
+ "demo_ended", "segments_complete", "cs2_exited", "timeout", "ffmpeg_stopped"
+ """
+ start_time = time.time()
+ state.recording_start_time = start_time
+ last_spec_lock = 0.0
+ log_position = 0
+ death_log_position = 0
+ window_id = None
+ window_found_logged = False
+ last_status_time = start_time
+
+ print(" Recording loop started (tick navigation enabled)")
+
+ # Find CS2 window
+ window_id = find_cs2_window(display)
+ if window_id:
+ if verbose:
+ print(f" CS2 window found: {window_id}")
+ else:
+ print(" Warning: CS2 window not found for tick navigation")
+
+ # Calibrate tick offset
+ if window_id:
+ print(" Calibrating tick offset...")
+ state.tick_offset, log_position = calibrate_tick_offset(
+ console_log_path, display, window_id, log_position, verbose
+ )
+ death_log_position = log_position
+ print(f" Tick offset: {state.tick_offset}")
+
+ print(f" {len(state.timeline.alive_segments)} alive segments to navigate")
+
+ while True:
+ elapsed = time.time() - start_time
+
+ if elapsed > timeout:
+ print(f" Timeout reached ({timeout / 60:.1f} min)")
+ return "timeout", state.transitions
+
+ if not cs2_process.is_running():
+ print(" CS2 exited")
+ return "cs2_exited", state.transitions
+
+ if not ffmpeg.is_running():
+ print(" FFmpeg stopped unexpectedly")
+ return "ffmpeg_stopped", state.transitions
+
+ # Check for demo end
+ demo_ended, log_position = check_demo_ended(console_log_path, log_position)
+ if demo_ended:
+ print(" Demo end detected in console.log")
+ return "demo_ended", state.transitions
+
+ # Check for death (only if we have a window to navigate with)
+ if window_id:
+ death_detected, death_log_position = check_death_in_console(
+ console_log_path, state.player_slot, death_log_position
+ )
+ if death_detected:
+ has_more, death_log_position = handle_death(
+ state, display, window_id, console_log_path,
+ death_log_position, verbose
+ )
+ if not has_more:
+ print(" All alive segments complete")
+ return "segments_complete", state.transitions
+
+ # Send spec_lock (F5) every 3 seconds
+ if elapsed - last_spec_lock >= 3.0:
+ if window_id is None:
+ window_id = find_cs2_window(display)
+ if window_id and not window_found_logged:
+ if verbose:
+ print(f" CS2 window found: {window_id}")
+ window_found_logged = True
+
+ if window_id:
+ send_key("F5", display, window_id)
+ last_spec_lock = elapsed
+
+ if elapsed - last_status_time >= 60:
+ seg = state.current_segment_index + 1
+ total = len(state.timeline.alive_segments)
+ print(f" Still recording... ({elapsed / 60:.1f} min, "
+ f"segment {seg}/{total})")
+ last_status_time = elapsed
+
+ time.sleep(0.5)
diff --git a/cs2pov/trim.py b/cs2pov/trim.py
index 534dae6..0edda07 100644
--- a/cs2pov/trim.py
+++ b/cs2pov/trim.py
@@ -427,6 +427,76 @@ def trim_video_with_periods(
return trim_death_periods(input_path, output_path, death_periods, verbose)
+def trim_goto_transitions(
+ input_path: Path,
+ output_path: Path,
+ transitions: list,
+ verbose: bool = False,
+) -> bool:
+ """Trim goto transition artifacts from a tick-nav recording.
+
+ When using tick-based navigation, each death triggers a pause/seek/unpause
+ cycle. The video between pause_video_time and unpause_video_time contains
+ the seek artifact and should be removed.
+
+ This converts transitions into "keep segments" (the gaps between transitions)
+ and uses extract_and_concat_segments to do the actual trimming.
+
+ Args:
+ input_path: Path to input video
+ output_path: Path for output video
+ transitions: List of GotoTransition objects
+ verbose: Print debug output
+
+ Returns:
+ True if trimming was performed, False on failure or nothing to trim
+ """
+ if not transitions:
+ if verbose:
+ print(" [Trim] No transitions to trim")
+ return False
+
+ # Get video duration
+ try:
+ video_duration = get_video_duration(input_path)
+ except CaptureError as e:
+ if verbose:
+ print(f" [Trim] Failed to get video duration: {e}")
+ return False
+
+ # Build keep segments from the gaps between transitions
+ keep_segments: list[tuple[float, float]] = []
+ current_pos = 0.0
+
+ # Sort transitions by pause time
+ sorted_transitions = sorted(transitions, key=lambda t: t.pause_video_time)
+
+ for t in sorted_transitions:
+ # Keep segment before this transition's pause
+ if t.pause_video_time > current_pos + 0.5:
+ keep_segments.append((current_pos, t.pause_video_time))
+
+ # Skip past the transition artifact
+ current_pos = max(current_pos, t.unpause_video_time)
+
+ # Keep segment after last transition
+ if current_pos < video_duration - 0.5:
+ keep_segments.append((current_pos, video_duration))
+
+ if not keep_segments:
+ if verbose:
+ print(" [Trim] No segments to keep after transition removal")
+ return False
+
+ if verbose:
+ total_keep = sum(end - start for start, end in keep_segments)
+ total_trim = video_duration - total_keep
+ print(f" [Trim] {len(transitions)} transitions → {len(keep_segments)} keep segments")
+ print(f" [Trim] Keep: {total_keep:.1f}s, Trim: {total_trim:.1f}s")
+
+ return extract_and_concat_segments(input_path, output_path, keep_segments, verbose)
+
+
def extract_and_concat_segments(
input_path: Path,
output_path: Path,