From 64e17e20f6f2b4e5426fce9d86c58aab4f89411f Mon Sep 17 00:00:00 2001 From: Schark Date: Tue, 24 Feb 2026 19:27:41 -0500 Subject: Foundational changes to new navigational flow --- README.md | 16 +-- cs2pov/automation.py | 186 +++++++++++++++++++++++++++++++++-- cs2pov/cli.py | 86 +++++++++++++--- cs2pov/config.py | 12 +++ cs2pov/navigation.py | 269 +++++++++++++++++++++++++++++++++++++++++++++++++++ cs2pov/trim.py | 70 ++++++++++++++ 6 files changed, 613 insertions(+), 26 deletions(-) create mode 100644 cs2pov/navigation.py 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, -- cgit v1.2.3-18-g5258