diff options
| -rw-r--r-- | cs2pov/cli.py | 42 | ||||
| -rw-r--r-- | cs2pov/preprocessor.py | 153 |
2 files changed, 183 insertions, 12 deletions
diff --git a/cs2pov/cli.py b/cs2pov/cli.py index 25d210e..efb9258 100644 --- a/cs2pov/cli.py +++ b/cs2pov/cli.py @@ -376,12 +376,17 @@ def postprocess_video( trim_periods: list[TrimPeriod] = [] - # Prefer timeline data - if timeline is not None and timeline.spawns: + # Prefer timeline data (need either spawns or rounds to calculate trims) + if timeline is not None and (timeline.spawns or timeline.rounds): print(" Using preprocessed timeline data (demoparser2)") if verbose: - print(f" Deaths: {len(timeline.deaths)}, Spawns: {len(timeline.spawns)}") + print(f" Deaths: {len(timeline.deaths)}, Spawns: {len(timeline.spawns)}, Rounds: {len(timeline.rounds)}") + print(f" Death periods: {len(timeline.death_periods)}, Round end periods: {len(timeline.round_end_periods)}") print(f" Tickrate: {timeline.tickrate}, Total ticks: {timeline.total_ticks}") + # Debug: show how many rounds have end_tick and freeze_end_tick + rounds_with_end = sum(1 for r in timeline.rounds if r.end_tick is not None) + rounds_with_freeze = sum(1 for r in timeline.rounds if r.freeze_end_tick is not None) + print(f" Rounds with end_tick: {rounds_with_end}, with freeze_end: {rounds_with_freeze}") # Step 1: Get video duration and demo duration try: @@ -439,15 +444,22 @@ def postprocess_video( print(f" Startup time (to trim): {startup_time:.2f}s") # Step 3: Build trim periods - # First: trim from 0 to (startup_time + first_spawn_time) + # First: trim from 0 to (startup_time + first_action_time) # This removes the recording startup AND the demo freeze/warmup time - first_spawn_time = timeline.spawns[0].time_seconds if timeline.spawns else 0.0 - initial_trim_end = startup_time + first_spawn_time + # Use first round's freeze_end - 5s as the cut-in point (consistent with death periods) + first_action_time = 0.0 + if timeline.rounds and timeline.rounds[0].freeze_end_time is not None: + first_action_time = timeline.rounds[0].freeze_end_time - 5.0 + first_action_time = max(0.0, first_action_time) # Don't go negative + elif timeline.spawns: + first_action_time = timeline.spawns[0].time_seconds + + initial_trim_end = startup_time + first_action_time if initial_trim_end > 0.5: # Only trim if > 0.5s trim_periods.append(TrimPeriod(start_time=0.0, end_time=initial_trim_end)) if verbose: - print(f" Initial trim: 0.00s - {initial_trim_end:.2f}s (startup + freeze)") + print(f" Initial trim: 0.00s - {initial_trim_end:.2f}s (startup + pre-action)") # Step 4: Add death periods (convert demo ticks to video time) # video_time = startup_time + demo_time @@ -464,6 +476,19 @@ def postprocess_video( duration = respawn_video_time - death_video_time print(f" Death period: {death_video_time:.2f}s - {respawn_video_time:.2f}s ({duration:.2f}s)") + # Step 5: Add round end periods (dead time between rounds when player survives) + for round_end_period in timeline.round_end_periods: + round_end_video_time = startup_time + round_end_period.round_end_time + next_round_video_time = startup_time + round_end_period.next_round_time + + # Only include if after the initial trim + if next_round_video_time > initial_trim_end: + round_end_video_time = max(round_end_video_time, initial_trim_end) + trim_periods.append(TrimPeriod(start_time=round_end_video_time, end_time=next_round_video_time)) + if verbose: + duration = next_round_video_time - round_end_video_time + print(f" Round end period: {round_end_video_time:.2f}s - {next_round_video_time:.2f}s ({duration:.2f}s)") + # Fall back to console.log parsing if not trim_periods: if timeline is not None: @@ -940,7 +965,8 @@ def cmd_trim(args) -> int: timeline = None try: timeline = preprocess_demo(demo_path, player.steamid, player.name) - print(f"Using demo timeline: {len(timeline.deaths)} deaths, {len(timeline.spawns)} spawns") + print(f"Using demo timeline: {len(timeline.deaths)} deaths, {len(timeline.spawns)} spawns, {len(timeline.rounds)} rounds") + print(f" {len(timeline.death_periods)} death periods, {len(timeline.round_end_periods)} round end periods") except Exception as e: print(f"Warning: Could not preprocess demo: {e}") print("Falling back to console.log method") diff --git a/cs2pov/preprocessor.py b/cs2pov/preprocessor.py index 0bd9325..445f2a4 100644 --- a/cs2pov/preprocessor.py +++ b/cs2pov/preprocessor.py @@ -63,6 +63,20 @@ class RoundBoundary: @dataclass +class RoundEndPeriod: + """Dead time between round end and next round start.""" + + round_end_tick: int + round_end_time: float + next_round_tick: int # freeze_end - buffer + next_round_time: float + + @property + def duration_seconds(self) -> float: + return self.next_round_time - self.round_end_time + + +@dataclass class DemoTimeline: """Complete timeline data for a player in a demo.""" @@ -76,6 +90,7 @@ class DemoTimeline: spawns: list[SpawnEvent] = field(default_factory=list) death_periods: list[DeathPeriod] = field(default_factory=list) rounds: list[RoundBoundary] = field(default_factory=list) + round_end_periods: list[RoundEndPeriod] = field(default_factory=list) def preprocess_demo(demo_path: Path, player_steamid: int, player_name: str = "") -> DemoTimeline: @@ -116,12 +131,21 @@ def preprocess_demo(demo_path: Path, player_steamid: int, player_name: str = "") # Extract spawn events for the player spawns = _extract_spawns(parser, player_steamid, tickrate) - # Compute death periods (match deaths with subsequent spawns) - death_periods = compute_death_periods(deaths, spawns) - # Extract round boundaries rounds = _extract_round_boundaries(parser, tickrate) + # Compute death periods using rounds (death -> 5s before next round freeze_end) + # This is more reliable than spawn events + death_periods = compute_death_periods_from_rounds(deaths, rounds, tickrate, buffer_seconds=5.0) + + # Fallback to spawn-based if rounds didn't work + if not death_periods and deaths and spawns: + death_periods = compute_death_periods(deaths, spawns) + + # Compute round end periods (round_end -> 5s before next round freeze_end) + # This trims dead time between rounds when the player survives + round_end_periods = compute_round_end_periods(rounds, tickrate, buffer_before_next=5.0, buffer_after_end=2.0) + return DemoTimeline( player_steamid=player_steamid, player_name=player_name, @@ -132,6 +156,7 @@ def preprocess_demo(demo_path: Path, player_steamid: int, player_name: str = "") spawns=spawns, death_periods=death_periods, rounds=rounds, + round_end_periods=round_end_periods, ) except Exception as e: @@ -229,7 +254,11 @@ def _extract_round_boundaries(parser: DemoParser, tickrate: float) -> list[Round # Parse each event type individually (parse_event only takes one event name) prestart_df = parser.parse_event("round_prestart") freeze_end_df = parser.parse_event("round_freeze_end") - round_end_df = parser.parse_event("round_officially_ended") + + # Try multiple event names for round end (CS2 may use different names) + round_end_df = parser.parse_event("round_end") + if round_end_df is None or len(round_end_df) == 0: + round_end_df = parser.parse_event("round_officially_ended") # Convert to lists of (tick, time) tuples prestart_list = [] @@ -317,6 +346,122 @@ def compute_death_periods(deaths: list[DeathEvent], spawns: list[SpawnEvent]) -> return death_periods +def compute_death_periods_from_rounds( + deaths: list[DeathEvent], + rounds: list[RoundBoundary], + tickrate: float, + buffer_seconds: float = 5.0, +) -> list[DeathPeriod]: + """Match each death with the next round's freeze_end minus a buffer. + + Instead of using spawn events, this uses round boundaries to determine + when to cut back in: (freeze_end - buffer_seconds). + + Args: + deaths: List of death events, sorted by tick + rounds: List of round boundaries, sorted by round_num + tickrate: Demo tickrate for time calculations + buffer_seconds: Seconds before freeze_end to cut back in (default 5s) + + Returns: + List of DeathPeriod objects + """ + if not deaths or not rounds: + return [] + + # Build list of freeze_end times (when rounds actually start) + freeze_end_times = [] + for r in rounds: + if r.freeze_end_tick is not None and r.freeze_end_time is not None: + freeze_end_times.append((r.freeze_end_tick, r.freeze_end_time)) + + if not freeze_end_times: + return [] + + # Sort by tick + freeze_end_times.sort(key=lambda x: x[0]) + + death_periods = [] + freeze_idx = 0 + + for death in deaths: + # Find next freeze_end after this death + while freeze_idx < len(freeze_end_times) and freeze_end_times[freeze_idx][0] <= death.tick: + freeze_idx += 1 + + if freeze_idx < len(freeze_end_times): + freeze_tick, freeze_time = freeze_end_times[freeze_idx] + + # Calculate respawn time as (freeze_end - buffer) + buffer_ticks = int(buffer_seconds * tickrate) + respawn_tick = freeze_tick - buffer_ticks + respawn_time = freeze_time - buffer_seconds + + # Only create period if respawn is after death + if respawn_tick > death.tick: + # Create a synthetic SpawnEvent for the respawn point + spawn = SpawnEvent(tick=respawn_tick, time_seconds=respawn_time) + death_periods.append(DeathPeriod(death=death, spawn=spawn)) + + return death_periods + + +def compute_round_end_periods( + rounds: list[RoundBoundary], + tickrate: float, + buffer_before_next: float = 5.0, + buffer_after_end: float = 2.0, +) -> list[RoundEndPeriod]: + """Compute dead time periods between round end and next round start. + + For each round that has an end_tick, creates a period from + (round_end + buffer_after_end) to (next_round_freeze_end - buffer_before_next). + + Args: + rounds: List of round boundaries, sorted by round_num + tickrate: Demo tickrate for time calculations + buffer_before_next: Seconds before freeze_end to cut back in (default 5s) + buffer_after_end: Seconds after round_end before starting trim (default 2s) + + Returns: + List of RoundEndPeriod objects + """ + if not rounds or len(rounds) < 2: + return [] + + round_end_periods = [] + buffer_before_ticks = int(buffer_before_next * tickrate) + buffer_after_ticks = int(buffer_after_end * tickrate) + + for i, current_round in enumerate(rounds[:-1]): # Skip last round (no next round) + next_round = rounds[i + 1] + + # Need both current round's end and next round's freeze_end + if current_round.end_tick is None or current_round.end_time is None: + continue + if next_round.freeze_end_tick is None or next_round.freeze_end_time is None: + continue + + # Calculate trim start (round_end + buffer) + trim_start_tick = current_round.end_tick + buffer_after_ticks + trim_start_time = current_round.end_time + buffer_after_end + + # Calculate trim end (freeze_end - buffer) + trim_end_tick = next_round.freeze_end_tick - buffer_before_ticks + trim_end_time = next_round.freeze_end_time - buffer_before_next + + # Only create period if there's actual dead time to trim + if trim_end_tick > trim_start_tick: + round_end_periods.append(RoundEndPeriod( + round_end_tick=trim_start_tick, + round_end_time=trim_start_time, + next_round_tick=trim_end_tick, + next_round_time=trim_end_time, + )) + + return round_end_periods + + def get_trim_periods(timeline: DemoTimeline) -> list[tuple[float, float]]: """Get periods to trim from video based on timeline data. |
