From a410e70385d0a11dd138aa31174dc968f6cf74b2 Mon Sep 17 00:00:00 2001 From: Leterax Date: Mon, 2 Dec 2024 13:40:23 +0100 Subject: [PATCH 1/9] video player working --- examples/video.py | 312 +++++++++++++++++++++++++++++----------------- 1 file changed, 198 insertions(+), 114 deletions(-) diff --git a/examples/video.py b/examples/video.py index 5c62c9e..192ddff 100644 --- a/examples/video.py +++ b/examples/video.py @@ -1,193 +1,277 @@ """ -Relies on the PyAV library to decode video frames and display them using a texture. - - pip install av +Video player implementation using PyAV for decoding and ModernGL for rendering. +Requires: pip install av """ -import math +import logging from pathlib import Path +import time from typing import Union import av import moderngl - +import pyglet import moderngl_window from moderngl_window import geometry +logger = logging.getLogger(__name__) + + +class VideoDecoder: + """Handles video decoding using PyAV.""" -class Decoder: def __init__(self, path: Union[str, Path]): - self.container = av.open(str(path)) - self.video = self.container.streams[0] + self._path = Path(path) + if not self._path.exists(): + raise FileNotFoundError(f"Video file not found: {self._path}") + + self.container = av.open(str(self._path)) + self.video = self.container.streams.video[0] self.video.thread_type = "AUTO" - self._last_packet = None - self._frame_step = float(self.video.time_base) + self.video.codec_context.pix_fmt = "yuv420p" + + self._current_frame = 0 @property def duration(self) -> float: - """float: Number of frames in the video""" + """Video duration in seconds.""" if self.video.duration is None: - return -1 - return self.video.duration * self.video.time_base - - @property - def end_time(self): - return self.video.end_time + return float(self.container.duration * float(self.container.time_base)) + return float(self.video.duration * float(self.video.time_base)) @property - def average_rate(self) -> float: - """The average framerate as a float""" + def framerate(self) -> float: + """Average framerate.""" rate = self.video.average_rate return rate.numerator / rate.denominator @property - def frames(self) -> int: - """int: Number of frames in the video""" - return self.video.frames - - @property - def video_size(self) -> tuple[int, int]: - """tuple[int, int]: The width and height of the video in pixels""" + def size(self) -> tuple[int, int]: + """Video dimensions (width, height).""" return self.video.width, self.video.height - @property - def current_pos(self): - """The current position in the stream""" - if self._last_packet: - return self._last_packet.pts - return 0 + def seek(self, time_seconds: float) -> None: + """Seek to specified time position.""" + try: + time_seconds = max(0, min(time_seconds, self.duration)) + timestamp = int(time_seconds / float(self.video.time_base)) + self.container.seek(timestamp, stream=self.video) + self._current_frame = int(time_seconds * self.framerate) + self.container.decode(video=0) + except Exception as e: + logger.error(f"Seek failed: {e}") + self.container.seek(0) + self._current_frame = 0 + + def get_frames(self): + """Generate video frames from current position.""" + try: + for packet in self.container.demux(video=0): + for frame in packet.decode(): + self._current_frame += 1 + yield frame.to_rgb().planes[0] + except Exception as e: + logger.error(f"Frame generation error: {e}") @property - def frame_step(self): - """Position step for each frame""" - return self._frame_step - - def time_to_pos(self, time: float) -> int: - """Converts time to stream position""" - return time * self.average_rate + def frames(self) -> int: + """Total number of frames in video.""" + return int(self.duration * self.framerate) - def seek(self, position: int): - """Seek to a position in the stream""" - self.container.seek(position, stream=self.video) - def gen_frames(self): - for packet in self.container.demux(video=0): - if packet.pts is not None: - self._last_packet = packet - for i, frame in enumerate(packet.decode()): - yield frame.to_rgb().planes[0] +class VideoPlayer: + """Handles video playback and rendering using ModernGL.""" + # Add constants at class level + FRAME_DIFF_THRESHOLD = 5 + MAX_BEHIND_COUNT = 10 + SKIP_OFFSET = 2 -class Player: def __init__(self, ctx: moderngl.Context, path: Union[str, Path]): self._ctx = ctx - self._path = path - self._decoder = Decoder(self._path) - self._texture = self._ctx.texture(self._decoder.video_size, 3) - self._frames = self._decoder.gen_frames() + self._decoder = VideoDecoder(path) + self._texture = self._ctx.texture(self._decoder.size, 3, dtype="f1") + self._frames = self._decoder.get_frames() - self._last_time = 0 - self._fps = self._decoder.average_rate + self._current_frame = 0 + self._target_frame = 0 + self._behind_count = 0 + self._paused = False @property def fps(self) -> float: - """float: Framerate of the video""" - return self._fps + return self._decoder.framerate @property def duration(self) -> float: - """float: Length of video in seconds""" return self._decoder.duration @property def frames(self) -> int: - """int: The number of frames in the video""" return self._decoder.frames @property - def video_size(self) -> tuple[int, int]: - """tuple[int, int]: Video size in pixels""" - return self._decoder.video_size + def size(self) -> tuple[int, int]: + return self._decoder.size @property def texture(self) -> moderngl.Texture: return self._texture - def update(self, time: float): - next_pos = self._decoder.time_to_pos(time) - delta = next_pos - self._decoder.current_pos - - print( - ( - f"frame_step={self._decoder.frame_step}, " - f"delta={delta}, " - f"next_pos={next_pos}, " - f"current_pos={self._decoder.current_pos}, " - f"time={time}" - ) - ) - - # Seek we are more than 3 frames off - if abs(delta) > self._decoder.frame_step * 3: - seek_pos = int(next_pos) - print("SEEK", delta, seek_pos) - self._decoder.seek(seek_pos) - # else: - # if delta < self._decoder.frame_step: - # print("SKIP") - # return - - try: - data = next(self._frames) - except StopIteration: + def update(self, time: float) -> None: + """Update video playback state.""" + if self._paused: return - self._texture.write(data) - def next_frame(self) -> av.plane.Plane: - """Get RGB data for the next frame. - A VideoPlane is returned containing the RGB data. - This objects supports the buffer protocol and can be written to a texture directly. - """ - return next(self._frames) + self._target_frame = int(time * self.fps) + frame_diff = self._target_frame - self._current_frame + + # Check if we've reached the end + if self._current_frame >= self.frames: + self.seek(0) + return True # Signal that we've reached the end + + # Handle falling behind + if frame_diff > self.FRAME_DIFF_THRESHOLD: + self._behind_count += 1 + skip_to = min(self._target_frame - self.SKIP_OFFSET, self.frames - 1) + + if self._behind_count > self.MAX_BEHIND_COUNT: + self._decoder.seek(skip_to / self.fps) + self._frames = self._decoder.get_frames() + self._current_frame = skip_to + self._behind_count = 0 + else: + try: + while self._current_frame < skip_to: + next(self._frames) + self._current_frame += 1 + except StopIteration: + self.seek(0) + return True + else: + self._behind_count = max(0, self._behind_count - 1) + if frame_diff > 0: + try: + data = next(self._frames) + self._texture.write(data) + self._current_frame += 1 + except StopIteration: + self.seek(0) + return True + + return False # Video hasn't ended + + def seek(self, time: float) -> None: + """Seek to specified time position.""" + time = max(0, min(time, self.duration)) + self._decoder.seek(time) + self._frames = self._decoder.get_frames() + self._current_frame = int(time * self.fps) + self._target_frame = self._current_frame + self._behind_count = 0 + + def toggle_pause(self) -> None: + """Toggle pause state.""" + self._paused = not self._paused + + @property + def current_frame(self) -> int: + return self._current_frame + + @property + def target_frame(self) -> int: + return self._target_frame + + @property + def is_paused(self) -> bool: + return self._paused + +class VideoPlayerWindow(moderngl_window.WindowConfig): + """ModernGL window configuration for video playback.""" -class VideoTest(moderngl_window.WindowConfig): gl_version = (3, 3) title = "Video Player" resource_dir = Path(__file__).parent.resolve() / "resources" + vsync = True + seek_time = 5.0 # Seconds to seek when using arrow keys + frame_history_size = 60 # Number of frames to keep for FPS calculation + video_file = "Lightning - 33049.mp4" # Default video file name def __init__(self, **kwargs): super().__init__(**kwargs) - self.player = Player(self.ctx, self.resource_dir / "videos/Lightning - 33049.mp4") - print("duration :", self.player.duration) - print("fps :", self.player.fps) - print("video_size :", self.player.video_size) - print("frames :", self.player.frames) - print("step :", self.player._decoder.frame_step) + # Initialize video player + video_path = self.resource_dir / "videos" / self.video_file + try: + self.player = VideoPlayer(self.ctx, video_path) + except FileNotFoundError as e: + logger.error(f"Failed to load video: {e}") + raise SystemExit(1) + + # Setup rendering self.quad = geometry.quad_fs() self.program = self.load_program("programs/texture_flipped.glsl") - def on_render(self, time: float, frametime: float): - self.player.update(math.fmod(time, 5)) + # Setup stats printing + self._last_print_time = 0 + + def on_render(self, time: float, frametime: float) -> None: + """Render frame.""" + if self.player.update(time): # Check if video ended + self.timer.time = 0 # Reset timer if video ended + + # Render video self.player.texture.use(0) self.quad.render(self.program) - def on_key_event(self, key, action, modifiers): + # Print debug stats every 0.5 seconds regardless of pause state + if (time - self._last_print_time) >= 0.5 or time < self._last_print_time: + # Get FPS values with safety checks + fps_avg = self.timer.fps_average if self.timer.time > 0 else 0.0 + + print( + f"\rMovie Target FPS: {self.player.fps:.1f} | " + f"Window FPS: {fps_avg:.1f} | " + f"Frame: {self.player.current_frame}/{self.player.frames} | " + f"Time: {self.timer.time:.2f}/{self.player.duration:.2f} | " + f"Frame Diff: {self.player.target_frame - self.player.current_frame} | " + f"Paused: {self.player.is_paused}", + end="", + flush=True, + ) + self._last_print_time = time + + def on_key_event(self, key, action, modifiers) -> None: + """Handle keyboard input.""" + super().on_key_event(key, action, modifiers) keys = self.wnd.keys - # Key presses if action == keys.ACTION_PRESS: if key == keys.LEFT: - self.timer.time = self.timer.time - 10 - - if key == keys.RIGHT: - self.timer.time = self.timer.time + 10 - - if key == keys.SPACE: + new_time = max(0, self.timer.time - self.seek_time) + if self.timer.is_paused: + # When paused, just seek the video without updating timer + self.player.seek(new_time) + else: + self.timer.time = new_time + self.player.seek(new_time) + + elif key == keys.RIGHT: + new_time = min(self.player.duration, self.timer.time + self.seek_time) + if self.timer.is_paused: + # When paused, just seek the video without updating timer + self.player.seek(new_time) + else: + self.timer.time = new_time + self.player.seek(new_time) + + elif key == keys.SPACE: self.timer.toggle_pause() + self.player.toggle_pause() if __name__ == "__main__": - VideoTest.run() + VideoPlayerWindow.run() From 2ee18ef98d68e87441d41ffa60d97d8051efc994 Mon Sep 17 00:00:00 2001 From: Leterax Date: Mon, 2 Dec 2024 13:57:01 +0100 Subject: [PATCH 2/9] Refactor video player functionality and improve seek handling --- examples/video.py | 66 +++++++++++++++++++++++------------------------ 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/examples/video.py b/examples/video.py index 192ddff..0501bff 100644 --- a/examples/video.py +++ b/examples/video.py @@ -5,12 +5,10 @@ import logging from pathlib import Path -import time -from typing import Union +from typing import Union, Literal import av import moderngl -import pyglet import moderngl_window from moderngl_window import geometry @@ -78,6 +76,16 @@ def frames(self) -> int: """Total number of frames in video.""" return int(self.duration * self.framerate) + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.container.close() + + def close(self): + """Explicitly close the video container.""" + self.container.close() + class VideoPlayer: """Handles video playback and rendering using ModernGL.""" @@ -118,10 +126,10 @@ def size(self) -> tuple[int, int]: def texture(self) -> moderngl.Texture: return self._texture - def update(self, time: float) -> None: + def update(self, time: float) -> bool: """Update video playback state.""" if self._paused: - return + return False self._target_frame = int(time * self.fps) frame_diff = self._target_frame - self._current_frame @@ -129,13 +137,13 @@ def update(self, time: float) -> None: # Check if we've reached the end if self._current_frame >= self.frames: self.seek(0) - return True # Signal that we've reached the end + return True # Handle falling behind if frame_diff > self.FRAME_DIFF_THRESHOLD: self._behind_count += 1 skip_to = min(self._target_frame - self.SKIP_OFFSET, self.frames - 1) - + if self._behind_count > self.MAX_BEHIND_COUNT: self._decoder.seek(skip_to / self.fps) self._frames = self._decoder.get_frames() @@ -160,7 +168,7 @@ def update(self, time: float) -> None: self.seek(0) return True - return False # Video hasn't ended + return False def seek(self, time: float) -> None: """Seek to specified time position.""" @@ -195,21 +203,16 @@ class VideoPlayerWindow(moderngl_window.WindowConfig): title = "Video Player" resource_dir = Path(__file__).parent.resolve() / "resources" vsync = True - seek_time = 5.0 # Seconds to seek when using arrow keys - frame_history_size = 60 # Number of frames to keep for FPS calculation - video_file = "Lightning - 33049.mp4" # Default video file name + seek_time = 1.0 # Seconds to seek when using arrow keys def __init__(self, **kwargs): super().__init__(**kwargs) # Initialize video player - video_path = self.resource_dir / "videos" / self.video_file + video_path = self.resource_dir / "videos" / "Lightning - 33049.mp4" + # video_path = r"C:\Users\Leo Mintech\Downloads\Countdown_Overlay_Timer_10_Minutes.mp4" - try: - self.player = VideoPlayer(self.ctx, video_path) - except FileNotFoundError as e: - logger.error(f"Failed to load video: {e}") - raise SystemExit(1) + self.player = VideoPlayer(self.ctx, video_path) # Setup rendering self.quad = geometry.quad_fs() @@ -244,6 +247,17 @@ def on_render(self, time: float, frametime: float) -> None: ) self._last_print_time = time + def _handle_seek(self, direction: Literal["forward", "backward"]) -> None: + """Handle seeking in video. direction: 'forward' or 'backward'""" + seek_amount = self.seek_time if direction == "forward" else -self.seek_time + new_time = max(0, min(self.player.duration, self.timer.time + seek_amount)) + + if self.timer.is_paused: + self.player.seek(new_time) + else: + self.timer.time = new_time + self.player.seek(new_time) + def on_key_event(self, key, action, modifiers) -> None: """Handle keyboard input.""" super().on_key_event(key, action, modifiers) @@ -251,23 +265,9 @@ def on_key_event(self, key, action, modifiers) -> None: if action == keys.ACTION_PRESS: if key == keys.LEFT: - new_time = max(0, self.timer.time - self.seek_time) - if self.timer.is_paused: - # When paused, just seek the video without updating timer - self.player.seek(new_time) - else: - self.timer.time = new_time - self.player.seek(new_time) - + self._handle_seek("backward") elif key == keys.RIGHT: - new_time = min(self.player.duration, self.timer.time + self.seek_time) - if self.timer.is_paused: - # When paused, just seek the video without updating timer - self.player.seek(new_time) - else: - self.timer.time = new_time - self.player.seek(new_time) - + self._handle_seek("forward") elif key == keys.SPACE: self.timer.toggle_pause() self.player.toggle_pause() From e2ff460dbd15cb8915e9638ec8d6ed7d1e903ebd Mon Sep 17 00:00:00 2001 From: Leterax Date: Mon, 2 Dec 2024 13:57:17 +0100 Subject: [PATCH 3/9] Fix Timer FPS calculation to avoid division by zero on the first frame --- moderngl_window/timers/clock.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/moderngl_window/timers/clock.py b/moderngl_window/timers/clock.py index 35b061f..68db0ae 100644 --- a/moderngl_window/timers/clock.py +++ b/moderngl_window/timers/clock.py @@ -72,7 +72,13 @@ def next_frame(self) -> tuple[float, float]: self._frames += 1 current = self.time delta, self._last_frame = current - self._last_frame, current - self._fps = 1.0 / delta + + # Avoid division by zero on first frame + if delta > 0: + self._fps = 1.0 / delta + else: + self._fps = 0.0 + return current, delta def start(self) -> None: From d95260ac6874cc69b623baed3e17f0d187308a96 Mon Sep 17 00:00:00 2001 From: Leterax Date: Mon, 2 Dec 2024 14:01:30 +0100 Subject: [PATCH 4/9] formatting --- examples/video.py | 10 +++++----- moderngl_window/timers/clock.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/video.py b/examples/video.py index 0501bff..1752408 100644 --- a/examples/video.py +++ b/examples/video.py @@ -78,10 +78,10 @@ def frames(self) -> int: def __enter__(self): return self - + def __exit__(self, exc_type, exc_val, exc_tb): self.container.close() - + def close(self): """Explicitly close the video container.""" self.container.close() @@ -143,7 +143,7 @@ def update(self, time: float) -> bool: if frame_diff > self.FRAME_DIFF_THRESHOLD: self._behind_count += 1 skip_to = min(self._target_frame - self.SKIP_OFFSET, self.frames - 1) - + if self._behind_count > self.MAX_BEHIND_COUNT: self._decoder.seek(skip_to / self.fps) self._frames = self._decoder.get_frames() @@ -209,7 +209,7 @@ def __init__(self, **kwargs): super().__init__(**kwargs) # Initialize video player - video_path = self.resource_dir / "videos" / "Lightning - 33049.mp4" + video_path = self.resource_dir / "videos" / "Lightning - 33049.mp4" # video_path = r"C:\Users\Leo Mintech\Downloads\Countdown_Overlay_Timer_10_Minutes.mp4" self.player = VideoPlayer(self.ctx, video_path) @@ -251,7 +251,7 @@ def _handle_seek(self, direction: Literal["forward", "backward"]) -> None: """Handle seeking in video. direction: 'forward' or 'backward'""" seek_amount = self.seek_time if direction == "forward" else -self.seek_time new_time = max(0, min(self.player.duration, self.timer.time + seek_amount)) - + if self.timer.is_paused: self.player.seek(new_time) else: diff --git a/moderngl_window/timers/clock.py b/moderngl_window/timers/clock.py index 68db0ae..cd83676 100644 --- a/moderngl_window/timers/clock.py +++ b/moderngl_window/timers/clock.py @@ -72,13 +72,13 @@ def next_frame(self) -> tuple[float, float]: self._frames += 1 current = self.time delta, self._last_frame = current - self._last_frame, current - + # Avoid division by zero on first frame if delta > 0: self._fps = 1.0 / delta else: self._fps = 0.0 - + return current, delta def start(self) -> None: From 9a4b14e8f75b57216ddb90b7a4f8b86da7229422 Mon Sep 17 00:00:00 2001 From: Leterax Date: Mon, 2 Dec 2024 14:10:35 +0100 Subject: [PATCH 5/9] remove old filename --- examples/video.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/video.py b/examples/video.py index 1752408..fc4184c 100644 --- a/examples/video.py +++ b/examples/video.py @@ -210,7 +210,6 @@ def __init__(self, **kwargs): # Initialize video player video_path = self.resource_dir / "videos" / "Lightning - 33049.mp4" - # video_path = r"C:\Users\Leo Mintech\Downloads\Countdown_Overlay_Timer_10_Minutes.mp4" self.player = VideoPlayer(self.ctx, video_path) From 629d9f98675674eeb00ad20b368161539c1d4a77 Mon Sep 17 00:00:00 2001 From: Leterax Date: Mon, 2 Dec 2024 15:24:23 +0100 Subject: [PATCH 6/9] debug message uses logger --- examples/video.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/examples/video.py b/examples/video.py index fc4184c..ab5a25b 100644 --- a/examples/video.py +++ b/examples/video.py @@ -13,7 +13,7 @@ from moderngl_window import geometry logger = logging.getLogger(__name__) - +logging.basicConfig(level=logging.DEBUG) class VideoDecoder: """Handles video decoding using PyAV.""" @@ -204,6 +204,7 @@ class VideoPlayerWindow(moderngl_window.WindowConfig): resource_dir = Path(__file__).parent.resolve() / "resources" vsync = True seek_time = 1.0 # Seconds to seek when using arrow keys + log_level = logging.DEBUG def __init__(self, **kwargs): super().__init__(**kwargs) @@ -234,15 +235,18 @@ def on_render(self, time: float, frametime: float) -> None: # Get FPS values with safety checks fps_avg = self.timer.fps_average if self.timer.time > 0 else 0.0 - print( - f"\rMovie Target FPS: {self.player.fps:.1f} | " - f"Window FPS: {fps_avg:.1f} | " - f"Frame: {self.player.current_frame}/{self.player.frames} | " - f"Time: {self.timer.time:.2f}/{self.player.duration:.2f} | " - f"Frame Diff: {self.player.target_frame - self.player.current_frame} | " - f"Paused: {self.player.is_paused}", - end="", - flush=True, + + + logger.debug( + "Movie Target FPS: %.1f | Window FPS: %.1f | Frame: %d/%d | Time: %.2f/%.2f | Frame Diff: %d | Paused: %s", + self.player.fps, + fps_avg, + self.player.current_frame, + self.player.frames, + self.timer.time, + self.player.duration, + self.player.target_frame - self.player.current_frame, + self.player.is_paused ) self._last_print_time = time From c773328129f6caad0f449f049d61ca7cedeb9bc2 Mon Sep 17 00:00:00 2001 From: Leterax Date: Mon, 2 Dec 2024 15:25:23 +0100 Subject: [PATCH 7/9] formatting --- examples/video.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/examples/video.py b/examples/video.py index ab5a25b..d8ed4fe 100644 --- a/examples/video.py +++ b/examples/video.py @@ -15,6 +15,7 @@ logger = logging.getLogger(__name__) logging.basicConfig(level=logging.DEBUG) + class VideoDecoder: """Handles video decoding using PyAV.""" @@ -235,8 +236,6 @@ def on_render(self, time: float, frametime: float) -> None: # Get FPS values with safety checks fps_avg = self.timer.fps_average if self.timer.time > 0 else 0.0 - - logger.debug( "Movie Target FPS: %.1f | Window FPS: %.1f | Frame: %d/%d | Time: %.2f/%.2f | Frame Diff: %d | Paused: %s", self.player.fps, @@ -246,7 +245,7 @@ def on_render(self, time: float, frametime: float) -> None: self.timer.time, self.player.duration, self.player.target_frame - self.player.current_frame, - self.player.is_paused + self.player.is_paused, ) self._last_print_time = time From 6e4a8fb47beced23a765b1c4eb9187931e9aa867 Mon Sep 17 00:00:00 2001 From: Leterax Date: Mon, 2 Dec 2024 15:27:02 +0100 Subject: [PATCH 8/9] line too long --- examples/video.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/video.py b/examples/video.py index d8ed4fe..4ba900f 100644 --- a/examples/video.py +++ b/examples/video.py @@ -237,7 +237,8 @@ def on_render(self, time: float, frametime: float) -> None: fps_avg = self.timer.fps_average if self.timer.time > 0 else 0.0 logger.debug( - "Movie Target FPS: %.1f | Window FPS: %.1f | Frame: %d/%d | Time: %.2f/%.2f | Frame Diff: %d | Paused: %s", + "Movie Target FPS: %.1f | Window FPS: %.1f | Frame: %d/%d | \ + Time: %.2f/%.2f | Frame Diff: %d | Paused: %s", self.player.fps, fps_avg, self.player.current_frame, From 75abfc091fb21ed3f298fe7b77372d13095cb313 Mon Sep 17 00:00:00 2001 From: Leterax Date: Mon, 2 Dec 2024 21:03:50 +0100 Subject: [PATCH 9/9] add clock utest --- tests/test_timers.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_timers.py b/tests/test_timers.py index 2c7734c..a68435f 100644 --- a/tests/test_timers.py +++ b/tests/test_timers.py @@ -30,3 +30,15 @@ def test_not_started(self) -> None: t, real_t = timer.stop() self.assertTrue(t == 0) self.assertTrue(real_t == 0) + + def test_zero_delta(self) -> None: + """Test that timer handles zero delta gracefully""" + timer = clock.Timer() + timer.start() + # Force a zero delta by setting the same time twice + timer.time = 1.0 + timer.next_frame() + timer.time = 1.0 + timer.next_frame() + # FPS should be 0 when delta is 0 to avoid division by zero + self.assertEqual(timer.fps, 0)