diff --git a/.gitignore b/.gitignore index efee2f9..28c038d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ -pycache/ +__pycache__/ dev/ dist/ setup.py -requirements.txt pygamevideo.egg-info/ diff --git a/LICENSE b/LICENSE index 8805d0d..bbc4370 100644 --- a/LICENSE +++ b/LICENSE @@ -1,165 +1,21 @@ -GNU LESSER GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - - This version of the GNU Lesser General Public License incorporates -the terms and conditions of version 3 of the GNU General Public -License, supplemented by the additional permissions listed below. - - 0. Additional Definitions. - - As used herein, "this License" refers to version 3 of the GNU Lesser -General Public License, and the "GNU GPL" refers to version 3 of the GNU -General Public License. - - "The Library" refers to a covered work governed by this License, -other than an Application or a Combined Work as defined below. - - An "Application" is any work that makes use of an interface provided -by the Library, but which is not otherwise based on the Library. -Defining a subclass of a class defined by the Library is deemed a mode -of using an interface provided by the Library. - - A "Combined Work" is a work produced by combining or linking an -Application with the Library. The particular version of the Library -with which the Combined Work was made is also called the "Linked -Version". - - The "Minimal Corresponding Source" for a Combined Work means the -Corresponding Source for the Combined Work, excluding any source code -for portions of the Combined Work that, considered in isolation, are -based on the Application, and not on the Linked Version. - - The "Corresponding Application Code" for a Combined Work means the -object code and/or source code for the Application, including any data -and utility programs needed for reproducing the Combined Work from the -Application, but excluding the System Libraries of the Combined Work. - - 1. Exception to Section 3 of the GNU GPL. - - You may convey a covered work under sections 3 and 4 of this License -without being bound by section 3 of the GNU GPL. - - 2. Conveying Modified Versions. - - If you modify a copy of the Library, and, in your modifications, a -facility refers to a function or data to be supplied by an Application -that uses the facility (other than as an argument passed when the -facility is invoked), then you may convey a copy of the modified -version: - - a) under this License, provided that you make a good faith effort to - ensure that, in the event an Application does not supply the - function or data, the facility still operates, and performs - whatever part of its purpose remains meaningful, or - - b) under the GNU GPL, with none of the additional permissions of - this License applicable to that copy. - - 3. Object Code Incorporating Material from Library Header Files. - - The object code form of an Application may incorporate material from -a header file that is part of the Library. You may convey such object -code under terms of your choice, provided that, if the incorporated -material is not limited to numerical parameters, data structure -layouts and accessors, or small macros, inline functions and templates -(ten or fewer lines in length), you do both of the following: - - a) Give prominent notice with each copy of the object code that the - Library is used in it and that the Library and its use are - covered by this License. - - b) Accompany the object code with a copy of the GNU GPL and this license - document. - - 4. Combined Works. - - You may convey a Combined Work under terms of your choice that, -taken together, effectively do not restrict modification of the -portions of the Library contained in the Combined Work and reverse -engineering for debugging such modifications, if you also do each of -the following: - - a) Give prominent notice with each copy of the Combined Work that - the Library is used in it and that the Library and its use are - covered by this License. - - b) Accompany the Combined Work with a copy of the GNU GPL and this license - document. - - c) For a Combined Work that displays copyright notices during - execution, include the copyright notice for the Library among - these notices, as well as a reference directing the user to the - copies of the GNU GPL and this license document. - - d) Do one of the following: - - 0) Convey the Minimal Corresponding Source under the terms of this - License, and the Corresponding Application Code in a form - suitable for, and under terms that permit, the user to - recombine or relink the Application with a modified version of - the Linked Version to produce a modified Combined Work, in the - manner specified by section 6 of the GNU GPL for conveying - Corresponding Source. - - 1) Use a suitable shared library mechanism for linking with the - Library. A suitable mechanism is one that (a) uses at run time - a copy of the Library already present on the user's computer - system, and (b) will operate properly with a modified version - of the Library that is interface-compatible with the Linked - Version. - - e) Provide Installation Information, but only if you would otherwise - be required to provide such information under section 6 of the - GNU GPL, and only to the extent that such information is - necessary to install and execute a modified version of the - Combined Work produced by recombining or relinking the - Application with a modified version of the Linked Version. (If - you use option 4d0, the Installation Information must accompany - the Minimal Corresponding Source and Corresponding Application - Code. If you use option 4d1, you must provide the Installation - Information in the manner specified by section 6 of the GNU GPL - for conveying Corresponding Source.) - - 5. Combined Libraries. - - You may place library facilities that are a work based on the -Library side by side in a single library together with other library -facilities that are not Applications and are not covered by this -License, and convey such a combined library under terms of your -choice, if you do both of the following: - - a) Accompany the combined library with a copy of the same work based - on the Library, uncombined with any other library facilities, - conveyed under the terms of this License. - - b) Give prominent notice with the combined library that part of it - is a work based on the Library, and explaining where to find the - accompanying uncombined form of the same work. - - 6. Revised Versions of the GNU Lesser General Public License. - - The Free Software Foundation may publish revised and/or new versions -of the GNU Lesser General Public License from time to time. Such new -versions will be similar in spirit to the present version, but may -differ in detail to address new problems or concerns. - - Each version is given a distinguishing version number. If the -Library as you received it specifies that a certain numbered version -of the GNU Lesser General Public License "or any later version" -applies to it, you have the option of following the terms and -conditions either of that published version or of any later version -published by the Free Software Foundation. If the Library as you -received it does not specify a version number of the GNU Lesser -General Public License, you may choose any version of the GNU Lesser -General Public License ever published by the Free Software Foundation. - - If the Library as you received it specifies that a proxy can decide -whether future versions of the GNU Lesser General Public License shall -apply, that proxy's public statement of acceptance of any version is -permanent authorization for you to choose that version for the -Library. +MIT License + +Copyright (c) 2023 Kadir Aksoy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index d722886..5a91183 100644 --- a/README.md +++ b/README.md @@ -1,192 +1,92 @@ -# Pygame Video Player -`pygamevideo` module helps developer to embed videos into their Pygame display. Audio playback doesn't use `pygame.mixer`. +# Pygame Video Player 📺 +

+ + + +

+This module provides a simple API to let developers use videos in their Pygame apps. Audio playback doesn't use `pygame.mixer`. ## Installing ``` pip install pygamevideo ``` -or just copy-paste `pygamevideo.py` to your working directory +or just copy-paste `pygamevideo.py` to your working directory. ## Usage ```py import pygame from pygamevideo import Video -window = pygame.display.set_mode() +pygame.init() +window = pygame.display.set_mode(...) +# Load the video from the specified dir video = Video("video.mp4") -# start video +# Start the video video.play() -# main loop +# Main loop while True: ... - # draw video to display surface - # this function must be called every tick + # Draw video to display surface + # this function should be called every frame video.draw_to(window, (0, 0)) - # set window title to current duration of video as hour:minute:second - t = video.current_time.format("%h:%m:%s") - pygame.display.set_caption(t) + # Update pygame display + pygame.display.flip() ``` ## Dependencies -- [pygame](https://pypi.org/project/pygame/) -- [numpy](https://pypi.org/project/numpy/) -- [opencv-python](https://pypi.org/project/opencv-python/) -- [ffpyplayer](https://pypi.org/project/ffpyplayer/) +- [Python](https://www.python.org/downloads/) 3.9+ +- [Pygame Community Edition](https://github.com/pygame-community/pygame-ce) 2.20.0+ +- [NumPy](https://pypi.org/project/numpy/) +- [OpenCV](https://pypi.org/project/opencv-python/) +- [FFPyPlayer](https://pypi.org/project/ffpyplayer/) -## Reference +# API Reference +You can just use the docstrings as well. ## `class Video(filepath)` -Pygame video player class - -### Parameters -`filepath` : Filepath of the video source - -### Methods & Attributes -`load(filepath)` : Load another video source - -
- -`release()` : Release resources - -#### Related to playback control -`play(loop=True)` : Starts video playback - - `loop` : Is video looped or not - -
- -`restart()` : Restarts already playing video - -
- -`stop()` : Stops video - -
- -`pause()` : Pauses video - -
- -`resume()` : Resumes video - -
- -`is_playing` : Whether the video is playing or not (bool) - -
- -`is_ended` : Whether the video has ended or not (bool) - -
- -`is_paused` : Whether the video is paused or not (bool) - -
- -`is_ready` : Whether the resources and video is ready to play (bool) - -### Related to audio control -`mute()` : Mutes audio - -
- -`unmute()` : Unmutes audio - -
- -`has_audio()` : **NOT IMPLEMENTED** - -
- -`set_volume(volume)` : Sets audio volume - - `volume` : Floating number between 0.0 and 1.0 - -
- -`is_muted` : Whether the audio is muted or not (bool) - -
- -`volume` : Audio volume (float) - -### Related to timing control -`duration` : Length of the video as [Time](#Time) object - -
- -`current_time` : Current time of the video as [Time](#Time) object - -
- -`remaining_time` : Remaining time till the end of the video as [Time](#Time) object - -
- -`total_frames` : Length of the video as frames (int) - -
- -`current_frame` : Current frame of the video (int) - -
- -`remaining_frames` : Remaining frames till the end of the video (int) - -
- -`seek_time(t)` : Jump into a specific time of the video - - `t` : [Time](#Time) object - - `t` : Representation of time in string, eg: "00:01:05:200" meaning 1 minute, 5 seconds and 200 milliseconds (str) - - `t` : Milliseconds (int) - -
- -`seek_frame(frame)` : Jump into a specific frame of the video -- `frame` : Frame number (int) - -### Related to resizing & frame dimensions -`get_size()` : Returns video size (tuple) - -
- -`get_width()` : Returns video width (int) - -
- -`get_height()` : Returns video height (int) - -
- -`set_size(size)` : Resizes video - - `size` : New size (tuple) - -
- -`set_width(width)` : Resizes video - - `width` : New width (int) - -
- -`set_height(height)` : Resizes video - - `height` : New height (int) - -
- -`keep_aspect_ratio` : Keeps original aspect ratio while resizing the video (bool) - -### Drawing the video -`draw_to(surface, pos)` : Draws the video onto the surface. This functions must be called every tick. - - `surface` : Destination surface (pygame.Surface) - - `pos` : Blitting position (tuple) - -
- -`get_frame()` : Returns the current video frame as pygame.Surface. This function is used by `draw_to` function, so use only one of both each tick - - -## `class Time` -Data class used to represent duration and such things by `Video` class +Pygame video player class. + +- ### Parameters + `filepath` : Filepath of the video source. + +- ### Attributes & Properties + - `is_ready` : Is the video source loaded and ready to play? + - `frame_width` : Default frame width in pixels. + - `frame_height` : Default frame height in pixels. + - `is_playing` : Is the video currently playing? + - `is_paused` : Is the video currently paused? + - `is_looped` : Is looping enabled? + - `volume` : Volume of the audio. + - `is_muted` : Is the audio muted? + - `fps` : Framerate of the video. + - `total_frames` : Total amount of frames of the video. + - `duration` : Total duration of the video in milliseconds. + - `current_time` : Current time into the video in milliseconds. + - `remaining_time` : Remaining time left in the video in milliseconds. + - `current_frame` : Current frame into the video. + - `remaining_frames` : Remaining frames left in the video. + - `current_time` : Current time into the video in milliseconds. + +- ### Methods + - `load(filepath: Union[str, os.PathLike])` : Load a video from file path. This method is also called implicitly when instantiated. + - `reload()` : Reload the video from the same filepath. + - `release()` : Release the resources used by the video player. + - `play(loop: bool = False)` : Start playing the video. + - `stop()` : Stop playing the video. + - `pause()` : Pause the video. + - `resume()` : Resume the video. + - `toggle_pause()` : Switch between paused states. + - `mute()` : Mute audio playback. + - `unmute()` : Unmute audio playback. + - `seek_time(timepoint: float)` : Seek into desired timepoint. + - `seek_frame(frame: int)` : Seek into desired frame. + - `get_frame()` : Advance the video and return the current frame. Must be called once per frame. + - `draw_to(dest_surface: pygame.Surface, position: Coordinate)` : Blit the current video frame to the surface. + +# License +[MIT](LICENSE) © Kadir Aksoy \ No newline at end of file diff --git a/examples/bunny.mp4 b/examples/bunny.mp4 new file mode 100644 index 0000000..139380e Binary files /dev/null and b/examples/bunny.mp4 differ diff --git a/examples/example.py b/examples/example.py new file mode 100644 index 0000000..fc8d41b --- /dev/null +++ b/examples/example.py @@ -0,0 +1,68 @@ +from time import perf_counter +from datetime import timedelta + +import pygame + +from pygamevideo import Video + + +pygame.init() +window = pygame.display.set_mode((1280, 720)) +clock = pygame.time.Clock() + +font18 = pygame.font.SysFont("Arial", 18) +font14 = pygame.font.SysFont("Arial", 14) + + +start = perf_counter() +video = Video("bunny.mp4") +elapsed = perf_counter() - start + +video.play(loop=True) + + +titlebar_surf = pygame.Surface((1280, 35)).convert() +titlebar_surf.set_alpha(180) + +control_surf = pygame.Surface((1280, 50)).convert() +control_surf.set_alpha(180) + +pygame.draw.rect(control_surf, (100, 100, 100), (153, 22, 1100, 4), 0) + + +while True: + clock.tick(0) + pygame.display.set_caption(f"Pygame Video Player @{round(clock.get_fps())}FPS") + + for event in pygame.event.get(): + if event.type == pygame.QUIT: + raise SystemExit(0) + + window.fill((25, 18, 41)) + + video.draw_to(window, (0, 0)) + + t = timedelta(milliseconds=video.current_time) + h, rem = divmod(t.seconds, 3600) + m, s = divmod(rem, 60) + time_formatted = f"{str(h).zfill(2)}:{str(m).zfill(2)}:{str(s).zfill(2)}" + + t = timedelta(milliseconds=video.duration) + h, rem = divmod(t.seconds, 3600) + m, s = divmod(rem, 60) + total_time = f"{str(h).zfill(2)}:{str(m).zfill(2)}:{str(s).zfill(2)}" + + window.blit(titlebar_surf, (0, 0)) + window.blit(font18.render(f"{video.filepath} {round(video.fps, 2)}FPS {video.total_frames} frames {video.frame_width}x{video.frame_height} Loaded in {round(elapsed * 1000, 1)}ms", True, (255, 255, 255)), (10, 7)) + + window.blit(control_surf, (0, 720 - 50)) + window.blit(font14.render(f"{time_formatted} / {total_time}", True, (255, 255, 255)), (5, 720 - 32)) + + percentage = video.current_frame / video.total_frames + x = percentage * 1100 + + pygame.draw.rect(window, (156, 135, 250), (153, 720 - 50 + 22, x, 4), 0) + + pygame.draw.circle(window, (255, 255, 255), (153 + x, 720 - 50 + 24), 7, 0) + + pygame.display.flip() \ No newline at end of file diff --git a/pygamevideo.py b/pygamevideo.py index efa2966..014a927 100644 --- a/pygamevideo.py +++ b/pygamevideo.py @@ -1,81 +1,52 @@ -# Pygame Video Player # -# LGPL 3.0 - Kadir Aksoy # -# https://github.com/kadir014/pygamevideo # - +from typing import Union import time import os +import atexit + import pygame import numpy import cv2 from ffpyplayer.player import MediaPlayer -__version__ = "1.0.0" - - - -class Time: - def __init__(self, hour=0, minute=0, second=0, millisecond=0): - self.hour, self.minute, self.second, self.millisecond = hour, minute, second, millisecond - - def __repr__(self): - return self.format("") - - def __add__(self, other): - return Time.from_millisecond(self.to_millisecond() + other.to_millisecond()) - - def __sub__(self, other): - return Time.from_millisecond(self.to_millisecond() - other.to_millisecond()) +__version__ = "2.0.0" - def format(self, format_string): - if "%H" in format_string: format_string = format_string.replace("%H", str(self.hour).zfill(2)) - if "%M" in format_string: format_string = format_string.replace("%M", str(self.minute).zfill(2)) - if "%S" in format_string: format_string = format_string.replace("%S", str(self.second).zfill(2)) - if "%F" in format_string: format_string = format_string.replace("%F", str(self.millisecond).zfill(2)) - if "%h" in format_string: format_string = format_string.replace("%h", str(int(self.hour)).zfill(2)) - if "%m" in format_string: format_string = format_string.replace("%m", str(int(self.minute)).zfill(2)) - if "%s" in format_string: format_string = format_string.replace("%s", str(int(self.second)).zfill(2)) - if "%f" in format_string: format_string = format_string.replace("%f", str(int(self.millisecond)).zfill(2)) - return format_string - - def to_hour(self): - return self.hour + self.minute/60 + self.second/3600 + self.millisecond/3600000 - - def to_minute(self): - return self.hour*60 + self.minute + self.second/60 + self.millisecond/60000 - - def to_second(self): - return self.hour*3600 + self.minute*60 + self.second + self.millisecond/1000 - - def to_millisecond(self): - return self.hour*3600000 + self.minute*60000 + self.second*1000 + self.millisecond - - @classmethod - def from_millisecond(cls, ms): - h = ms//3600000 - hr = ms%3600000 - - m = hr//60000 - mr = hr%60000 +class Video: + """ + Pygame video player. + """ - s = mr//1000 - sr = mr%1000 + def __init__(self, filepath: Union[str, os.PathLike]) -> None: + """ + Parameters + ---------- + @param filepath File path to the video to load. + """ - return cls(hour=h, minute=m, second=s, millisecond=sr) + self.is_ready = False + self.load(filepath) + # Release before exiting interpreter to prevent segfault + atexit.register(self.release) + def __repr__(self) -> str: + return f"<{__name__}.{self.__class__.__name__}(frame#{self.current_frame})>" + + def __del__(self) -> None: + self.release() -class Video: - def __init__(self, filepath): - self.is_ready = False - self.load(filepath) + def load(self, filepath: Union[str, os.PathLike]) -> None: + """ + Load a video from file path. + This method is also called implicitly when instantiated. - def __repr__(self): - return f"" + Parameters + ---------- + @param filepath File path to the video to load. + """ - def load(self, filepath): filepath = str(filepath) if not os.path.exists(filepath): raise FileNotFoundError(f"No such file or directory: '{filepath}'") @@ -83,209 +54,190 @@ def load(self, filepath): self.filepath = filepath self.is_playing = False - self.is_ended = True - self.is_paused = False - self.is_looped = False + self.is_paused = False + self.is_looped = False - self.draw_frame = 0 - self.start_time = 0 - self.ostart_time = 0 + self.draw_frame = 0 + self.start_time = 0 + self.__start_time = 0 - self.volume = 1 + self.__volume = 1.0 self.is_muted = False + self.__volume_before_mute = 1.0 - self.vidcap = cv2.VideoCapture(self.filepath) - self.ff = MediaPlayer(self.filepath) + self.__vidcap = cv2.VideoCapture(self.filepath) + ff_opts = {"fast": True, "framedrop": True, "paused": True} + self.__ff = MediaPlayer(self.filepath, ff_opts=ff_opts) - self.fps = self.vidcap.get(cv2.CAP_PROP_FPS) + self.fps = self.__vidcap.get(cv2.CAP_PROP_FPS) - self.total_frames = int(self.vidcap.get(cv2.CAP_PROP_FRAME_COUNT)) + self.total_frames = int(self.__vidcap.get(cv2.CAP_PROP_FRAME_COUNT)) - self.frame_width = int(self.vidcap.get(cv2.CAP_PROP_FRAME_WIDTH)) - self.frame_height = int(self.vidcap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + self.frame_width = int(self.__vidcap.get(cv2.CAP_PROP_FRAME_WIDTH)) + self.frame_height = int(self.__vidcap.get(cv2.CAP_PROP_FRAME_HEIGHT)) - self.aspect_ratio = self.frame_width / self.frame_height - self.aspect_ratio2 = self.frame_height / self.frame_width - self.keep_aspect_ratio = False - - self.frame_surf = pygame.Surface((self.frame_width, self.frame_height)) - self._aspect_surf = pygame.Surface((self.frame_width, self.frame_height)) + self.frame_surf = pygame.Surface((self.frame_width, self.frame_height)).convert() self.is_ready = True - def release(self): - self.vidcap.release() - self.ff.close_player() - self.is_ready = False + def reload(self) -> None: + """ Reload the video from the same filepath. """ + + self.release() + self.load(self.filepath) + + def release(self) -> None: + """ Release the resources used by the video player. """ + + if self.is_ready: + self.__vidcap.release() + self.__ff.close_player() + self.is_ready = False # Control methods - def play(self, loop=False): - if not self.is_playing: - if not self.is_ready: self.load(self.filepath) + def play(self, loop: bool = False) -> None: + """ + Start playing the video. + + Parameters + ---------- + @param loop Whether to loop or not when the video playback ends. + """ + if self.is_ready and not self.is_playing: self.is_playing = True self.is_looped = loop + self.seek_frame(0) + self.draw_frame = 0 self.start_time = time.time() - self.ostart_time = time.time() + self.__start_time = time.time() - def restart(self): - if self.is_playing: - self.release() - self.vidcap = cv2.VideoCapture(self.filepath) - self.ff = MediaPlayer(self.filepath) - self.is_ready = True + self.__ff.set_pause(False) - self.draw_frame = 0 - self.is_paused = False + def stop(self) -> None: + """ + Stop playing the video. + """ - self.start_time = time.time() - self.ostart_time = time.time() + self.is_playing = False + self.is_paused = False + self.__ff.set_pause(True) + + def pause(self) -> None: + """ Pause the video. """ - def stop(self): if self.is_playing: - self.is_playing = False - self.is_paused = False - self.is_ended = True - self.is_looped = False - self.draw_frame = 0 + self.is_paused = True + self.__ff.set_pause(True) - self.frame_surf = pygame.Surface((self.frame_width, self.frame_height)) + def resume(self) -> None: + """ Resume the video. """ - self.release() + if self.is_playing: + self.is_paused = False + self.__ff.set_pause(False) - def pause(self): - self.is_paused = True - self.ff.set_pause(True) + def toggle_pause(self) -> None: + """ Switch between paused states. """ - def resume(self): - self.is_paused = False - self.ff.set_pause(False) + if self.is_playing: + if self.is_paused: self.resume() + else: self.pause() # Audio methods - def mute(self): + def mute(self) -> None: + """ Mute audio playback. """ + # MediaPlayer.set_mute doesn't work! self.is_muted = True - self.ff.set_mute(True) + self.__volume_before_mute = self.volume + self.__ff.set_volume(0.0) - def unmute(self): + def unmute(self) -> None: + """ Unmute audio playback. """ self.is_muted = False - self.ff.set_mute(False) - - def has_audio(self): - pass + self.__ff.set_volume(self.__volume_before_mute) - def set_volume(self, volume): - self.volume = volume - self.ff.set_volume(volume) + @property + def volume(self) -> float: + """ Volume of the audio playback. """ + return self.__volume + + @volume.setter + def volume(self, value: float) -> None: + self.__volume = value + if not self.is_muted: + self.__ff.set_volume(value) # Duration methods & properties @property - def duration(self): - return Time.from_millisecond((self.total_frames/self.fps)*1000) + def duration(self) -> float: + """ Total duration of the video in milliseconds. """ + return (self.total_frames / self.fps) * 1000 @property - def current_time(self): - return Time.from_millisecond(self.vidcap.get(cv2.CAP_PROP_POS_MSEC)) + def current_time(self) -> float: + """ Current time into the video in milliseconds. """ + if not self.is_ready or not self.is_playing: return 0 + return self.__vidcap.get(cv2.CAP_PROP_POS_MSEC) @property - def remaining_time(self): + def remaining_time(self) -> float: + """ Remaining time left in the video in milliseconds. """ return self.duration - self.current_time @property - def current_frame(self): - return self.vidcap.get(cv2.CAP_PROP_POS_FRAMES) + def current_frame(self) -> int: + """ Current frame into the video. """ + return self.__vidcap.get(cv2.CAP_PROP_POS_FRAMES) @property - def remaining_frames(self): + def remaining_frames(self) -> int: + """ Remaining frames left in the video. """ return self.frame_count - self.current_frame - def seek_time(self, t): - if isinstance(t, Time): - _t = t.to_millisecond() - self.seek_time(_t) - - elif isinstance(t, str): - h = float(t[:2]) - m = float(t[3:5]) - s = float(t[6:8]) - f = float(t[9:]) + def seek_time(self, timepoint: float) -> None: + """ + Seek into desired timepoint. - _t = Time(hour=h, minute=m, second=s, millisecond=f) - self.seek_time(_t.to_millisecond()) + Parameters + ---------- + @param time Time in milliseconds to seek to. + """ - elif isinstance(t, (int, float)): - self.start_time = self.ostart_time + t/1000 - self.draw_frame = int((time.time() - self.start_time) * self.fps) - self.vidcap.set(cv2.CAP_PROP_POS_MSEC, t) - self.ff.seek(t/1000, relative=False) + self.start_time = self.__start_time + timepoint / 1000 + self.draw_frame = int((time.time() - self.start_time) * self.fps) + self.__vidcap.set(cv2.CAP_PROP_POS_MSEC, timepoint) + self.__ff.seek(timepoint / 1000, relative=False) - else: - raise ValueError("Time can only be represented in Time, str, int or float") + def seek_frame(self, frame: int) -> None: + """ + Seek into desired frame. - def seek_frame(self, frame): + Parameters + ---------- + @param frame Frame number to seek to. + """ self.seek_time((frame / self.fps) * 1000) - # Dimension methods - - def get_size(self): - return (self.frame_width, self.frame_height) - - def get_width(self): - return self.frame_width - - def get_height(self): - return self.frame_height - - def set_size(self, size): - self.frame_width, self.frame_height = size - self._aspect_surf = pygame.transform.scale(self._aspect_surf, (self.frame_width, self.frame_height)) - - if not (self.frame_width > 0 and self.frame_height > 0): - raise ValueError(f"Size must be positive") - - def set_width(self, width): - self.frame_width = width - self._aspect_surf = pygame.transform.scale(self._aspect_surf, (self.frame_width, self.frame_height)) - - if self.frame_width <= 0: - raise ValueError(f"Width must be positive") - - def set_height(self, height): - self.frame_height = height - self._aspect_surf = pygame.transform.scale(self._aspect_surf, (self.frame_width, self.frame_height)) - - if self.frame_height <= 0: - raise ValueError(f"Height must be positive") - # Process & draw video - def _scaled_frame(self): - if self.keep_aspect_ratio: - if self.frame_width < self.frame_height: - self._aspect_surf.fill((0, 0, 0)) - frame_surf = pygame.transform.scale(self.frame_surf, (self.frame_width, int(self.frame_width/self.aspect_ratio))) - self._aspect_surf.blit(frame_surf, (0, self.frame_height/2-frame_surf.get_height()/2)) - return self._aspect_surf - - else: - self._aspect_surf.fill((0, 0, 0)) - frame_surf = pygame.transform.scale(self.frame_surf, (int(self.frame_height/self.aspect_ratio2), self.frame_height)) - self._aspect_surf.blit(frame_surf, (self.frame_width/2-frame_surf.get_width()/2, 0)) - return self._aspect_surf - else: - return pygame.transform.scale(self.frame_surf, (self.frame_width, self.frame_height)) + def get_frame(self) -> pygame.Surface: + """ + Advance the video and return the current frame. + Must be called once per frame. + """ - def get_frame(self): - if not self.is_playing: - return self._scaled_frame() + if not self.is_playing or not self.is_ready: + return self.frame_surf elapsed_frames = int((time.time() - self.start_time) * self.fps) if self.draw_frame >= elapsed_frames: - return self._scaled_frame() + return self.frame_surf else: target_frames = elapsed_frames - self.draw_frame @@ -293,22 +245,37 @@ def get_frame(self): if not self.is_paused: for _ in range(target_frames): - success, frame = self.vidcap.read() - audio_frame, val = self.ff.get_frame() + success, frame = self.__vidcap.read() if not success: if self.is_looped: - self.restart() - return + self.seek_frame(0) + return self.frame_surf + else: self.stop() - return + return self.frame_surf + + pygame.pixelcopy.array_to_surface( + self.frame_surf, + numpy.flip(numpy.rot90(frame[::-1])) + ) + + return self.frame_surf - pygame.pixelcopy.array_to_surface(self.frame_surf, numpy.flip(numpy.rot90(frame[::-1]))) + def draw_to(self, + dest_surface: pygame.Surface, + position: Union[tuple[float, float], pygame.Vector2, pygame.Rect] + ) -> None: + """ + Blit the current video frame to the surface. - return self._scaled_frame() + Parameters + ---------- + @param dest_surface Destination surface to draw the video on. + @param position Position to draw the video frame at. + """ - def draw_to(self, surface, pos): frame = self.get_frame() - surface.blit(frame, pos) + dest_surface.blit(frame, position) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a37e846 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +pygame-ce +numpy +opencv-python +ffpyplayer