├── pyvidplayer2 ├── _version.py ├── error.py ├── decord_reader.py ├── imageio_reader.py ├── cv_reader.py ├── mixer_handler.py ├── post_processing.py ├── __init__.py ├── video_pyglet.py ├── video_tkinter.py ├── ffmpeg_reader.py ├── video_raylib.py ├── video_pyqt.py ├── video_pyside.py ├── video_pygame.py ├── video_wx.py ├── video_reader.py ├── subtitles.py ├── webcam.py ├── pyaudio_handler.py └── video_player.py ├── logo.png ├── requirements.txt ├── requirements_all.txt ├── tests ├── readme.txt ├── test_previews.py ├── test_webcam.py ├── test_subtitles.py ├── test_youtube.py └── test_video_player.py ├── examples ├── playback_speed_demo.py ├── audio_track_demo.py ├── iterate_frames_demo.py ├── muting_demo.py ├── variable_frame_rate_demo.py ├── from_memory_demo.py ├── seeking_demo.py ├── reversed_playback_demo.py ├── youtube_streaming_demo.py ├── webcam_demo.py ├── tkinter_demo.py ├── gif_demo.py ├── raylib_demo.py ├── all_previews_demo.py ├── pyglet_demo.py ├── pyqt6_demo.py ├── looping_demo.py ├── pyside6_demo.py ├── youtube_subtitles_demo.py ├── audio_devices_demo.py ├── interpolations_demo.py ├── subtitles_demo.py ├── many_videos_demo.py ├── queue_demo.py ├── preview_thumbnails_demo.py ├── wxpython_demo.py ├── videoplayer_demo.py ├── video_demo.py ├── post_processing_demo.py ├── pip_demo.py └── webcam_app_demo.py ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── release.yml ├── LICENSE ├── pyproject.toml ├── .gitignore └── documentation.md /pyvidplayer2/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.9.28" 2 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anrayliu/pyvidplayer2/HEAD/logo.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | opencv_python 3 | pygame 4 | pysubs2 5 | PyAudio -------------------------------------------------------------------------------- /requirements_all.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | opencv_python 3 | pygame 4 | PyAudio 5 | pysubs2 6 | yt_dlp 7 | decord 8 | imageio 9 | av 10 | pyglet 11 | PySide6 12 | PyQt6 13 | raylib 14 | wxPython -------------------------------------------------------------------------------- /tests/readme.txt: -------------------------------------------------------------------------------- 1 | Full unit tests for pyvidplayer2 v0.9.28 2 | 3 | Requires the resources folder which contains all the test videos 4 | Download the videos here: https://github.com/anrayliu/pyvidplayer2-test-resources 5 | Requires all optional dependencies: `pip install pyvidplayer2[all]` 6 | 7 | Note: Some tests can be inconsistent, depending on your computer specs 8 | My 5600x has no issues, while my 7530U sometimes struggles -------------------------------------------------------------------------------- /examples/playback_speed_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This example shows how you can control the playback speed of videos 3 | ''' 4 | 5 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 6 | 7 | 8 | from pyvidplayer2 import Video 9 | 10 | 11 | # speed is capped between 0.25x and 10x the original speed 12 | 13 | with Video(r"resources\trailer1.mp4", speed=2) as v: 14 | v.preview() # plays video twice as fast 15 | -------------------------------------------------------------------------------- /examples/audio_track_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This example shows how you can choose the audio track to play in a video 3 | ''' 4 | 5 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 6 | 7 | 8 | from pyvidplayer2 import Video 9 | 10 | 11 | # audio_track = 0 will choose the first track, = 1 will choose the second, and so on 12 | 13 | with Video(r"your_video_with_multiple_audio_tracks", audio_track=0) as v: 14 | v.preview() 15 | -------------------------------------------------------------------------------- /examples/iterate_frames_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This example shows how the Video object can be used as a generator to iterate through each frame 3 | ''' 4 | 5 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 6 | 7 | 8 | from pyvidplayer2 import Video 9 | 10 | 11 | with Video("resources/clip.mp4") as v: 12 | for frame in Video("resources/clip.mp4"): 13 | 14 | # each frame is returned as a numpy.ndarray 15 | 16 | print(frame.shape) 17 | -------------------------------------------------------------------------------- /examples/muting_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This example shows how the audio for any video can be disabled 3 | ''' 4 | 5 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 6 | 7 | 8 | from pyvidplayer2 import Video 9 | 10 | 11 | # no_audio will be automatically detected and set for silent videos, 12 | # but you can also use the option to forcefully silence video playback 13 | 14 | with Video(r"resources\billiejean.mp4", no_audio=True) as v: 15 | v.preview() 16 | -------------------------------------------------------------------------------- /examples/variable_frame_rate_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This example shows how to play vfr videos 3 | Unfortunately, none of the example videos are vfr - try using your own! 4 | ''' 5 | 6 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 7 | 8 | 9 | from pyvidplayer2 import Video 10 | 11 | 12 | # normal videos can still be playedin vfr mode 13 | 14 | v = Video("resources/billiejean.mp4", vfr=True) 15 | 16 | print(v.min_fr) 17 | print(v.max_fr) 18 | print(v.avg_fr) 19 | 20 | v.preview() 21 | -------------------------------------------------------------------------------- /examples/from_memory_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This example shows how videos can be played from memory instead of disk 3 | pip install decord before using 4 | ''' 5 | 6 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 7 | 8 | 9 | from pyvidplayer2 import Video 10 | 11 | 12 | # experimental feature, still a little buggy 13 | 14 | with open("resources/ocean.mkv", "rb") as f: 15 | vid_in_bytes = f.read() # loads file into memory 16 | with Video(vid_in_bytes, as_bytes=True) as v: 17 | v.preview() 18 | -------------------------------------------------------------------------------- /examples/seeking_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This example shows the two ways of seeking 3 | ''' 4 | 5 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 6 | 7 | 8 | from pyvidplayer2 import Video 9 | 10 | 11 | with Video("resources/billiejean.mp4") as v: 12 | # skip ahead 60 seconds 13 | # accepts floats as well 14 | 15 | v.seek(60, relative=True) 16 | v.preview() 17 | 18 | 19 | with Video("resources/trailer2.mp4") as v: 20 | 21 | # seek to 500th frame 22 | 23 | v.seek_frame(499, relative=False) 24 | v.preview() 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ✨ Feature Request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE] " 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Is your feature request related to a problem? Please describe. 11 | A clear and concise description of what the problem is. (e.g., "I'm always frustrated when...") 12 | 13 | ### Describe the Solution You'd Like 14 | A clear and concise description of what you want to happen. 15 | 16 | ### Describe Alternatives You've Considered 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | -------------------------------------------------------------------------------- /examples/reversed_playback_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This example shows how you can play videos in reverse 3 | 4 | IMPORTANT: 5 | Playing videos in reverse requires a large amount of memory 6 | This particular example needs around 1.3gb 7 | Reversing longer videos can temporarily brick your computer if there isn't enough memory 8 | ''' 9 | 10 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 11 | 12 | 13 | from pyvidplayer2 import Video 14 | 15 | 16 | # reversing can also be combined with other video settings like speed changes 17 | 18 | with Video(r"resources\birds.avi", reverse=True) as v: 19 | v.preview() 20 | -------------------------------------------------------------------------------- /pyvidplayer2/error.py: -------------------------------------------------------------------------------- 1 | class Pyvidplayer2Error(Exception): 2 | """Base Exception""" 3 | pass 4 | 5 | 6 | class AudioDeviceError(Pyvidplayer2Error): 7 | pass 8 | 9 | 10 | class AudioStreamError(Pyvidplayer2Error): 11 | pass 12 | 13 | 14 | class SubtitleError(Pyvidplayer2Error): 15 | pass 16 | 17 | 18 | class VideoStreamError(Pyvidplayer2Error): 19 | pass 20 | 21 | 22 | class FFmpegNotFoundError(Pyvidplayer2Error, FileNotFoundError): 23 | pass 24 | 25 | 26 | class OpenCVError(Pyvidplayer2Error): 27 | pass 28 | 29 | 30 | class YTDLPError(Pyvidplayer2Error): 31 | pass 32 | 33 | 34 | class WebcamNotFoundError(Pyvidplayer2Error): 35 | pass 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug Report 3 | about: Report a bug to help us improve 4 | title: "[BUG] " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Describe the Bug 11 | A clear and concise description of what the bug is. 12 | 13 | ### To Reproduce 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | ### Expected Behavior 21 | A clear and concise description of what you expected to happen. 22 | 23 | ### Environment (please complete the following information): 24 | - OS: [e.g., Windows, macOS, Linux] 25 | - Python version [e.g., 3.12.1] 26 | - Pyvidplayer2 version [e.g., 0.9.27] 27 | -------------------------------------------------------------------------------- /examples/youtube_streaming_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This example shows how to stream videos from youtube in 720p 3 | pip install yt-dlp before using 4 | ''' 5 | 6 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 7 | 8 | 9 | from pyvidplayer2 import Video 10 | 11 | 12 | # chunk_size must be at least 60 for a smooth experience 13 | # max_threads is forced to 1 14 | 15 | Video("https://www.youtube.com/watch?v=K8PoK3533es", youtube=True, max_res=480, chunk_size=60).preview() 16 | 17 | # increasing chunks_size is better for long videos 18 | 19 | Video("https://www.youtube.com/watch?v=KJwYBJMSbPI&t=1s", youtube=True, max_res=720, chunk_size=300).preview() 20 | -------------------------------------------------------------------------------- /examples/webcam_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This is an example showing off webcam streaming 3 | ''' 4 | 5 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 6 | 7 | 8 | import pygame 9 | from pyvidplayer2 import Webcam 10 | 11 | # you can change these values to whatever your webcam supports 12 | webcam = Webcam(fps=30, capture_size=(480, 640)) 13 | 14 | win = pygame.display.set_mode(webcam.current_size) 15 | pygame.display.set_caption("webcam_demo") 16 | clock = pygame.time.Clock() 17 | 18 | while True: 19 | for event in pygame.event.get(): 20 | if event.type == pygame.QUIT: 21 | webcam.close() 22 | pygame.quit() 23 | exit() 24 | 25 | clock.tick(60) 26 | 27 | webcam.draw(win, (0, 0), force_draw=False) 28 | 29 | pygame.display.update() 30 | -------------------------------------------------------------------------------- /examples/tkinter_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This is a quick example of integrating a video into a tkinter project 3 | ''' 4 | 5 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 6 | 7 | 8 | import tkinter 9 | from pyvidplayer2 import VideoTkinter 10 | 11 | video = VideoTkinter(r"resources\trailer1.mp4") 12 | 13 | def update(): 14 | video.draw(canvas, (video.current_size[0] / 2, video.current_size[1] / 2), force_draw=False) 15 | if video.active: 16 | root.after(16, update) # for around 60 fps 17 | else: 18 | root.destroy() 19 | 20 | root = tkinter.Tk() 21 | root.title(f"tkinter support demo") 22 | 23 | canvas = tkinter.Canvas(root, width=video.current_size[0], height=video.current_size[1], highlightthickness=0) 24 | canvas.pack() 25 | 26 | update() 27 | root.mainloop() 28 | 29 | video.close() 30 | -------------------------------------------------------------------------------- /examples/gif_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This is an example of playing gifs 3 | 4 | Gifs are essentially treated as videos with no sound 5 | Uses looping_demo.py to loop the gif 6 | ''' 7 | 8 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 9 | 10 | 11 | import pygame 12 | from pyvidplayer2 import VideoPlayer, Video 13 | 14 | v = Video("some-gif.gif") 15 | player = VideoPlayer(v, (0, 0, *v.current_size), loop=True) 16 | 17 | win = pygame.display.set_mode(v.current_size) 18 | pygame.display.set_caption("gif demo") 19 | 20 | while True: 21 | events = pygame.event.get() 22 | for event in events: 23 | if event.type == pygame.QUIT: 24 | player.close() 25 | pygame.quit() 26 | exit() 27 | 28 | pygame.time.wait(16) 29 | 30 | player.update(events) 31 | player.draw(win) 32 | 33 | pygame.display.update() 34 | -------------------------------------------------------------------------------- /examples/raylib_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This is a quick example of integrating a video into a raylib project 3 | ''' 4 | 5 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 6 | 7 | 8 | import pyray 9 | from pyvidplayer2 import VideoRaylib 10 | 11 | # 1. create video 12 | video = VideoRaylib("resources/trailer1.mp4") 13 | 14 | # disables logs 15 | pyray.set_trace_log_level(pyray.TraceLogLevel.LOG_NONE) 16 | 17 | pyray.init_window(*video.original_size,f"raylib - {video.name}") 18 | pyray.set_target_fps(60) 19 | 20 | while not pyray.window_should_close() and video.active: 21 | pyray.begin_drawing() 22 | 23 | # 2. draw video onto window 24 | if video.draw((0, 0), force_draw=False): 25 | pyray.end_drawing() # updates screen 26 | 27 | pyray.close_window() 28 | 29 | # 3. close video when done, also releases raylib textures 30 | video.close() 31 | -------------------------------------------------------------------------------- /examples/all_previews_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This shows off each graphics api and their respective preview methods 3 | Must install pygame, tkinter, pyglet, and pyqt6, pyside, and raylib for this example 4 | ''' 5 | 6 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 7 | 8 | 9 | from pyvidplayer2 import Video, VideoTkinter, VideoPyglet, VideoPyQT, VideoPySide, VideoRaylib, VideoWx 10 | 11 | PATH = r"resources\trailer1.mp4" 12 | 13 | # previews are just a quick demonstration of the video 14 | # you can test different video settings (speed, reverse, youtube, etc) 15 | # but remember that everything shown in a preview can also be integrated 16 | # into your chosen graphics library (see demos) 17 | 18 | Video(PATH).preview() 19 | VideoTkinter(PATH).preview() 20 | VideoPyglet(PATH).preview() 21 | VideoPyQT(PATH).preview() 22 | VideoPySide(PATH).preview() 23 | VideoRaylib(PATH).preview() 24 | VideoWx(PATH).preview() 25 | -------------------------------------------------------------------------------- /examples/pyglet_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This is a quick example of integrating a video into a pyglet project 3 | ''' 4 | 5 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 6 | 7 | 8 | import pyglet 9 | from pyvidplayer2 import VideoPyglet 10 | 11 | video = VideoPyglet(r"resources\trailer1.mp4") 12 | 13 | def update(dt): 14 | # unfortunately, I could not find a way to run force_draw=False without visual jitter, 15 | # even with double buffering turned off 16 | # I'd love to work with anyone more experienced in pyglet to find a way to optimize 17 | # this code more 18 | 19 | video.draw((0, 0), force_draw=True) 20 | if not video.active: 21 | win.close() 22 | 23 | win = pyglet.window.Window(width=video.current_size[0], height=video.current_size[1], caption=f"pyglet support demo") 24 | 25 | pyglet.clock.schedule_interval(update, 1/60.0) 26 | 27 | pyglet.app.run() 28 | video.close() 29 | -------------------------------------------------------------------------------- /examples/pyqt6_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This is a quick example of integrating a video into a pyqt6 project 3 | ''' 4 | 5 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 6 | 7 | 8 | from PyQt6.QtWidgets import QApplication, QMainWindow, QWidget 9 | from PyQt6.QtCore import QTimer 10 | from pyvidplayer2 import VideoPyQT 11 | 12 | 13 | class Window(QMainWindow): 14 | def __init__(self): 15 | super().__init__() 16 | self.canvas = QWidget(self) 17 | self.setCentralWidget(self.canvas) 18 | 19 | self.timer = QTimer(self) 20 | self.timer.timeout.connect(self.update) 21 | self.timer.start(16) 22 | 23 | def paintEvent(self, _): 24 | video.draw(self, (0, 0)) 25 | 26 | 27 | video = VideoPyQT(r"resources\trailer1.mp4") 28 | 29 | app = QApplication([]) 30 | win = Window() 31 | win.setWindowTitle(f"pyqt6 support demo") 32 | win.setFixedSize(*video.current_size) 33 | win.show() 34 | app.exec() 35 | 36 | video.close() 37 | -------------------------------------------------------------------------------- /examples/looping_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This is an example of seamless looping 3 | 4 | v0.9.26 brought significant improvements to looping 5 | looping is now much more seamless 6 | ''' 7 | 8 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 9 | 10 | 11 | import pygame 12 | from pyvidplayer2 import VideoPlayer, Video 13 | 14 | v = Video(r"resources\clip.mp4") 15 | player = VideoPlayer(v, (0, 0, *v.current_size), loop=True) 16 | 17 | win = pygame.display.set_mode(v.current_size) 18 | pygame.display.set_caption("looping demo") 19 | 20 | while True: 21 | events = pygame.event.get() 22 | for event in events: 23 | if event.type == pygame.QUIT: 24 | player.close() 25 | pygame.quit() 26 | exit() 27 | elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE: 28 | player.video.restart() 29 | 30 | pygame.time.wait(16) 31 | 32 | player.update(events) 33 | player.draw(win) 34 | 35 | pygame.display.update() 36 | -------------------------------------------------------------------------------- /examples/pyside6_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copy and pasted from the pyqt6 demo, but changed names to pyside6 3 | Pyqt6 and pyside6 have very similar interfaces 4 | ''' 5 | 6 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 7 | 8 | 9 | from PySide6.QtWidgets import QApplication, QMainWindow, QWidget 10 | from PySide6.QtCore import QTimer 11 | from pyvidplayer2 import VideoPySide 12 | 13 | 14 | class Window(QMainWindow): 15 | def __init__(self): 16 | super().__init__() 17 | self.canvas = QWidget(self) 18 | self.setCentralWidget(self.canvas) 19 | 20 | self.timer = QTimer(self) 21 | self.timer.timeout.connect(self.update) 22 | self.timer.start(16) 23 | 24 | def paintEvent(self, _): 25 | video.draw(self, (0, 0)) 26 | 27 | 28 | video = VideoPySide(r"resources/trailer1.mp4") 29 | 30 | app = QApplication([]) 31 | win = Window() 32 | win.setWindowTitle(f"pyside6 support demo") 33 | win.setFixedSize(*video.current_size) 34 | win.show() 35 | app.exec() 36 | 37 | video.close() 38 | -------------------------------------------------------------------------------- /examples/youtube_subtitles_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This demo shows how subtitle files from Youtube can be fetched and used 3 | If a subtitle file in the preferred language is not available, automatic captions are used 4 | ''' 5 | 6 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 7 | 8 | 9 | from pyvidplayer2 import Video, Subtitles 10 | import pygame 11 | 12 | 13 | # if you don't know Google's language code for a particular area, which can be pretty 14 | # difficult to find sometimes, I've found success asking chatGPT for them 15 | 16 | # if no subtitles are provided for the video, automatically generated captions are used 17 | # generated captions are automatically translated, so you can request captions from any language 18 | 19 | # that being said, the default subtitles font cannot display many characters like Korean or Japanese 20 | 21 | with Video("https://www.youtube.com/watch?v=qyCVCGg_3Ec", youtube=True, max_res=720, 22 | subs=Subtitles("https://www.youtube.com/watch?v=qyCVCGg_3Ec", youtube=True, pref_lang="en")) as v: 23 | v.preview() 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 anrayliu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/audio_devices_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This demo shows how the audio output device can be specified for each video 3 | pip install sounddevice before using 4 | ''' 5 | 6 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 7 | 8 | import sounddevice 9 | from pyvidplayer2 import Video 10 | 11 | # FOR PYGAME 12 | # Changing the output device is not related to pyvidplayer2 13 | # Refer to https://stackoverflow.com/questions/57099246/set-output-device-for-pygame-mixer 14 | # to see how the output device can be specified during mixer initialization 15 | 16 | # FOR PYAUDIO 17 | # see instructions below 18 | 19 | # find the index of the device you want 20 | # not all hostapis work, but MME for Windows should be fine 21 | 22 | # e.g 23 | # 0 Microsoft Sound Mapper - Input, MME (2 in, 0 out) 24 | # 1 Microsoft Sound Mapper - Output, MME (0 in, 2 out) 25 | 26 | 27 | print(sounddevice.query_devices()) 28 | 29 | # replace None with the index of the chosen device (first number listed by sd) 30 | # e.g Video("resources/trailer1.mp4", audio_index=0).preview() 31 | 32 | with Video("resources/trailer1.mp4", audio_index=None) as v: 33 | v.preview() 34 | -------------------------------------------------------------------------------- /examples/interpolations_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This example compares the default interpolation technique with the best 3 | Can you tell a difference? 4 | ''' 5 | 6 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 7 | 8 | 9 | import pygame 10 | from pyvidplayer2 import Video 11 | 12 | 13 | win = pygame.display.set_mode((1280, 480)) 14 | pygame.display.set_caption("interpolations demo") 15 | 16 | # default interpolation technique 17 | 18 | vid1 = Video(r"resources\medic.mov", interp="linear") 19 | vid1.change_resolution(480) # automatically resizes video to maintain aspect ratio 20 | 21 | # sharpest but least performant interpolation technique 22 | 23 | vid2 = Video(r"resources\medic.mov", interp="lanczos4") 24 | vid2.resize((854, 480)) # alternatively, can set a custom size 25 | 26 | 27 | while True: 28 | for event in pygame.event.get(): 29 | if event.type == pygame.QUIT: 30 | vid1.close() 31 | vid2.close() 32 | pygame.quit() 33 | exit() 34 | 35 | pygame.time.wait(16) 36 | 37 | vid1.draw(win, (0, 0)) 38 | vid2.draw(win, (640, 0)) 39 | 40 | pygame.display.update() 41 | -------------------------------------------------------------------------------- /examples/subtitles_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This is an example showing how to add subtitles to a video 3 | ''' 4 | 5 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 6 | 7 | 8 | from pygame import Font 9 | from pyvidplayer2 import Subtitles, Video 10 | 11 | 12 | # regular subtitles playback 13 | 14 | with Video(r"resources\trailer2.mp4", subs=Subtitles(r"resources\subs2.srt")) as v: 15 | v.preview() 16 | 17 | # multiple subtitle tracks 18 | 19 | with Video(r"resources\trailer1.mp4", subs=[Subtitles(r"resources\subs1.srt"), Subtitles(r"resources\subs2.srt")]) as v: 20 | v.preview() 21 | 22 | # custom subtitles 23 | 24 | subs = Subtitles(r"resources\subs2.srt", font=Font(r"resources\font.ttf", 60), highlight=(255, 0, 0, 128), offset=500) 25 | with Video(r"resources\trailer2.mp4", subs=subs) as v: 26 | v.preview() 27 | 28 | # set a delay for subtitles 29 | 30 | with Video(r"resources\trailer2.mp4", subs=Subtitles(r"resources\subs2.srt", delay=-3)) as v: 31 | v.preview() 32 | 33 | # you can also use subtitles from within a video by specifying which track it belongs to 34 | # e.g Subtitles("video_file_with_subs", track_index=0) # uses the subtitles from the first track 35 | -------------------------------------------------------------------------------- /pyvidplayer2/decord_reader.py: -------------------------------------------------------------------------------- 1 | import decord 2 | from io import BytesIO 3 | from .video_reader import VideoReader 4 | from .error import * 5 | 6 | 7 | class DecordReader(VideoReader): 8 | def __init__(self, path): 9 | VideoReader.__init__(self, path, False) 10 | 11 | self._colour_format = "RGB" 12 | 13 | self._path = path 14 | self._as_bytes = isinstance(path, bytes) 15 | 16 | try: 17 | self._vid_reader = decord.VideoReader(BytesIO(path) if self._as_bytes else path) 18 | except RuntimeError: 19 | raise VideoStreamError("Could not determine video.") 20 | 21 | VideoReader._probe(self, path, self._as_bytes) 22 | 23 | def seek(self, index): 24 | self._vid_reader.seek_accurate(index) 25 | self.frame = index 26 | 27 | def read(self): 28 | frame = None 29 | has_frame = False 30 | try: 31 | frame = self._vid_reader.next().asnumpy() 32 | except StopIteration: 33 | pass 34 | else: 35 | has_frame = True 36 | self.frame += 1 37 | 38 | return has_frame, frame 39 | 40 | def release(self): 41 | self._path = b'' 42 | VideoReader.release(self) 43 | -------------------------------------------------------------------------------- /pyvidplayer2/imageio_reader.py: -------------------------------------------------------------------------------- 1 | import imageio.v3 as iio 2 | from .video_reader import VideoReader 3 | 4 | 5 | class IIOReader(VideoReader): 6 | def __init__(self, path): 7 | VideoReader.__init__(self, path, False) 8 | 9 | self._colour_format = "RGB" 10 | 11 | self._path = path 12 | self._gen = None 13 | self._as_bytes = isinstance(path, bytes) 14 | 15 | VideoReader._probe(self, path, self._as_bytes) 16 | self.seek(0) 17 | 18 | def seek(self, index): 19 | del self._gen 20 | 21 | # thread_type="FRAME" sets multithreading 22 | 23 | new_gen = iio.imiter(self._path, plugin="pyav", thread_type="FRAME") 24 | try: 25 | for i in range(int(index)): 26 | next(new_gen) 27 | except StopIteration: 28 | index = self.frame_count - 1 29 | 30 | self.frame = index 31 | self._gen = new_gen 32 | 33 | def read(self): 34 | has = False 35 | frame = None 36 | try: 37 | frame = next(self._gen) 38 | except StopIteration: 39 | pass 40 | except AttributeError: # for pyav v14 and imageio bug 41 | pass 42 | else: 43 | has = True 44 | self.frame += 1 45 | 46 | return has, frame if has else None 47 | 48 | def release(self): 49 | self._path = b'' 50 | self._gen = None 51 | VideoReader.release(self) 52 | -------------------------------------------------------------------------------- /examples/many_videos_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This is an example of playing many videos at once 3 | ''' 4 | 5 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 6 | 7 | 8 | import pygame 9 | from pyvidplayer2 import Video, VideoPlayer 10 | 11 | win = pygame.display.set_mode((1066, 744)) 12 | pygame.display.set_caption("many videos demo") 13 | 14 | # simultaneous playback is only possible when using PyAudio for audio 15 | 16 | videos = [VideoPlayer(Video(r"resources\billiejean.mp4"), (0, 0, 426, 240)), 17 | VideoPlayer(Video(r"resources\trailer1.mp4"), (426, 0, 256, 144)), 18 | VideoPlayer(Video(r"resources\medic.mov"), (682, 0, 256, 144)), 19 | VideoPlayer(Video(r"resources\trailer2.mp4"), (426, 144, 640, 360)), 20 | VideoPlayer(Video(r"resources\clip.mp4"), (0, 240, 256, 144)), 21 | VideoPlayer(Video(r"resources\birds.avi"), (0, 384, 426, 240)), 22 | VideoPlayer(Video(r"resources\ocean.mkv"), (426, 504, 426, 240))] 23 | 24 | while True: 25 | key = None 26 | for event in pygame.event.get(): 27 | if event.type == pygame.QUIT: 28 | [video.close() for video in videos] 29 | pygame.quit() 30 | exit() 31 | elif event.type == pygame.KEYDOWN: 32 | key = pygame.key.name(event.key) 33 | 34 | pygame.time.wait(16) 35 | 36 | win.fill("white") 37 | 38 | [video.update() for video in videos] 39 | [video.draw(win) for video in videos] 40 | 41 | pygame.display.update() 42 | -------------------------------------------------------------------------------- /pyvidplayer2/cv_reader.py: -------------------------------------------------------------------------------- 1 | from .error import * 2 | import cv2 3 | from .video_reader import VideoReader 4 | 5 | 6 | class CVReader(VideoReader): 7 | def __init__(self, path, probe=False): 8 | VideoReader.__init__(self, path, probe) 9 | 10 | self._colour_format = "BGR" 11 | 12 | self._vidcap = cv2.VideoCapture(path) 13 | if not self.isOpened(): 14 | return 15 | 16 | if not probe: 17 | self.frame_count = int(self._vidcap.get(cv2.CAP_PROP_FRAME_COUNT)) 18 | self.frame_rate = self._vidcap.get(cv2.CAP_PROP_FPS) 19 | self.original_size = (int(self._vidcap.get(cv2.CAP_PROP_FRAME_WIDTH)), 20 | int(self._vidcap.get(cv2.CAP_PROP_FRAME_HEIGHT))) 21 | self.duration = self.frame_count / self.frame_rate 22 | 23 | # webm videos have negative frame counts 24 | if self.frame_count < 0: 25 | VideoReader._probe(self, path, False) 26 | 27 | def isOpened(self): 28 | return self._vidcap.isOpened() 29 | 30 | def seek(self, index): 31 | self._vidcap.set(cv2.CAP_PROP_POS_FRAMES, index) 32 | self.frame = int(self._vidcap.get(cv2.CAP_PROP_POS_FRAMES)) 33 | if self.frame < 0: 34 | raise OpenCVError("Failed to seek.") 35 | 36 | def read(self): 37 | has, frame = self._vidcap.read() 38 | if has: 39 | self.frame += 1 40 | return has, frame 41 | 42 | def release(self): 43 | self._vidcap.release() 44 | VideoReader.release(self) 45 | -------------------------------------------------------------------------------- /pyvidplayer2/mixer_handler.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | from io import BytesIO 3 | 4 | 5 | class MixerHandler: 6 | def __init__(self): 7 | self.muted = False 8 | self.loaded = False 9 | self.volume = 1 10 | 11 | pygame.mixer.music.unload() 12 | 13 | def get_busy(self): 14 | return pygame.mixer.music.get_busy() 15 | 16 | def load(self, bytes_): 17 | pygame.mixer.music.load(BytesIO(bytes_)) 18 | self.loaded = True 19 | 20 | def get_num_channels(self): 21 | return pygame.mixer.get_num_channels() 22 | 23 | def unload(self): 24 | self.stop() 25 | pygame.mixer.music.unload() 26 | self.loaded = False 27 | 28 | def play(self): 29 | pygame.mixer.music.play() 30 | 31 | def set_volume(self, vol): 32 | self.volume = vol 33 | pygame.mixer.music.set_volume(min(1.0, max(0.0, vol))) 34 | 35 | def get_volume(self): 36 | return self.volume 37 | 38 | def get_pos(self): 39 | return max(0, pygame.mixer.music.get_pos()) / 1000.0 40 | 41 | def stop(self): 42 | pygame.mixer.music.stop() 43 | 44 | def pause(self): 45 | pygame.mixer.music.pause() 46 | 47 | def unpause(self): 48 | # unpausing the mixer when nothing has been loaded causes weird behaviour 49 | if pygame.mixer.music.get_pos() != -1: 50 | pygame.mixer.music.unpause() 51 | 52 | def mute(self): 53 | self.muted = True 54 | pygame.mixer.music.set_volume(0) 55 | 56 | def unmute(self): 57 | self.muted = False 58 | self.set_volume(self.volume) 59 | -------------------------------------------------------------------------------- /examples/queue_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This example shows how videos can be queued and skipped through with the VideoPlayer object 3 | ''' 4 | 5 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 6 | 7 | 8 | import pygame 9 | from pyvidplayer2 import VideoPlayer, Video 10 | 11 | win = pygame.display.set_mode((1280, 720)) 12 | pygame.display.set_caption("queue demo") 13 | 14 | # with loop=True and videos in the queue, each video will be added 15 | # back into the queue after it finishes playing 16 | 17 | vid = VideoPlayer(Video(r"resources\clip.mp4"), (0, 0, 1280, 720), loop=True) 18 | 19 | # queue video objects 20 | # when the current video finishes playing, the next video will automatically start 21 | vid.queue(Video(r"resources\ocean.mkv")) 22 | vid.queue(Video(r"resources\birds.avi")) 23 | # can also queue video paths to prevent resources from loading before needed 24 | vid.queue("resources/trailer2.mp4") 25 | 26 | # you can also access the list of queued videos with the following 27 | # note that this list does not include the currently loaded video in the player 28 | vid.get_queue() 29 | 30 | while True: 31 | events = pygame.event.get() 32 | for event in events: 33 | if event.type == pygame.QUIT: 34 | vid.close() 35 | pygame.quit() 36 | exit() 37 | elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE: 38 | # skips a video in the queue 39 | vid.skip() 40 | 41 | pygame.time.wait(16) 42 | 43 | vid.update(events) 44 | vid.draw(win) 45 | 46 | pygame.display.update() 47 | -------------------------------------------------------------------------------- /examples/preview_thumbnails_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This example shows off preview thumbnails in the video player 3 | ''' 4 | 5 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 6 | 7 | 8 | import pygame 9 | from pyvidplayer2 import VideoPlayer, Video 10 | 11 | video = Video(r"resources\medic.mov") 12 | 13 | # specify the number of loaded preview_thumbnails during instantiation 14 | # the video will load the specified number of frames before running, and when 15 | # seeking, the closest preloaded frame will be displayed as a preview thumbnail 16 | # therefore, how many frames you choose to load depends on the length of your video, 17 | # how accurate you want the thumbnails to be, and how much you value speed and memory 18 | 19 | # Adding preview_thumbnails from the videoplayer is expensive 20 | # In some situations, it may be faster to preload all the frames 21 | # in the video object itself before adding it to the videoplayer, 22 | # which will speed up preview_thumbnails considerably 23 | # 24 | # e.g. video._preload_frames() 25 | 26 | player = VideoPlayer(video, (0, 0, *video.original_size), interactable=True, preview_thumbnails=100) 27 | 28 | win = pygame.display.set_mode(video.original_size) 29 | pygame.display.set_caption("preview thumbnails demo") 30 | 31 | 32 | while True: 33 | events = pygame.event.get() 34 | for event in events: 35 | if event.type == pygame.QUIT: 36 | player.close() 37 | pygame.quit() 38 | exit() 39 | 40 | pygame.time.wait(16) 41 | 42 | player.update(events) 43 | player.draw(win) 44 | 45 | pygame.display.update() 46 | -------------------------------------------------------------------------------- /examples/wxpython_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This is a quick example of integrating a video into a wxpython project 3 | ''' 4 | 5 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 6 | 7 | 8 | from pyvidplayer2 import VideoWx 9 | import wx 10 | 11 | 12 | class Window(wx.Frame): 13 | def __init__(self): 14 | super(Window, self).__init__(None, title=f"wx - {vid.name}") 15 | 16 | self.panel = wx.Panel(self, size=wx.Size(*vid.current_size)) 17 | self.panel.SetBackgroundStyle(wx.BG_STYLE_PAINT) 18 | 19 | # for some reason, setting the size of the frame in constructor 20 | # still clips off some of the video 21 | # this seems to work for now 22 | 23 | sizer = wx.BoxSizer(wx.HORIZONTAL) 24 | sizer.Add(self.panel) 25 | sizer.Fit(self) 26 | sizer = wx.BoxSizer(wx.VERTICAL) 27 | sizer.Add(self.panel) 28 | sizer.Fit(self) 29 | 30 | self.timer = wx.Timer(self) 31 | self.Bind(wx.EVT_TIMER, self.update, self.timer) 32 | self.panel.Bind(wx.EVT_PAINT, self.draw) 33 | 34 | vid.play() 35 | self.timer.Start(int(1000 / vid.frame_rate)) 36 | 37 | self.Show() 38 | 39 | def update(self, event): 40 | if not vid.active: 41 | wx.CallAfter(self.Close) 42 | 43 | self.panel.Refresh(eraseBackground=False) 44 | 45 | def draw(self, event): 46 | vid.draw(self.panel, (0, 0), False) 47 | 48 | 49 | class MyApp(wx.App): 50 | def OnInit(self): 51 | Window() 52 | return True 53 | 54 | 55 | vid = VideoWx("resources//clip.mp4") 56 | 57 | app = MyApp(False) 58 | app.MainLoop() 59 | vid.close() 60 | -------------------------------------------------------------------------------- /examples/videoplayer_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This is an example of the built in GUI for videos 3 | ''' 4 | 5 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 6 | 7 | 8 | import pygame 9 | from pyvidplayer2 import VideoPlayer, Video 10 | 11 | video = Video(r"resources\ocean.mkv") 12 | 13 | win = pygame.display.set_mode(video.original_size, pygame.RESIZABLE) 14 | pygame.display.set_caption("video player demo") 15 | 16 | # 1. create video player with a video object and a space on screen 17 | player = VideoPlayer(video, (0, 0, *video.original_size), interactable=True) 18 | 19 | # can also take youtube videos 20 | # player = VideoPlayer(Video("https://www.youtube.com/watch?v=K8PoK3533es", youtube=True, max_res=720), (0, 0, 1280, 720), interactable=True) 21 | 22 | while True: 23 | events = pygame.event.get() 24 | for event in events: 25 | if event.type == pygame.QUIT: 26 | # 4. close when done, also closing the video inside 27 | player.close() 28 | pygame.quit() 29 | exit() 30 | elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE: 31 | # can toggle between zoom to fill and whole video 32 | player.toggle_zoom() 33 | elif event.type == pygame.VIDEORESIZE: 34 | # vide player will always ensure that none of the video is cut off after resizing 35 | player.resize(win.get_size()) 36 | 37 | pygame.time.wait(16) 38 | 39 | win.fill("white") 40 | 41 | # 2. update video player with events list 42 | player.update(events) 43 | # 3. draw video player 44 | player.draw(win) 45 | 46 | pygame.display.update() 47 | 48 | # alternatively, use VideoPlayer.preview() 49 | -------------------------------------------------------------------------------- /examples/video_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This is the same example from the original pyvidplayer 3 | The video class still does everything it did, but with many more features 4 | ''' 5 | 6 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 7 | 8 | 9 | import pygame 10 | from pyvidplayer2 import Video 11 | 12 | pygame.init() 13 | win = pygame.display.set_mode((1280, 720)) 14 | clock = pygame.time.Clock() 15 | 16 | # 1. provide video class with the path to your file 17 | vid = Video(r"resources\medic.mov") 18 | 19 | while True: 20 | key = None 21 | for event in pygame.event.get(): 22 | if event.type == pygame.QUIT: 23 | # 3. close video when done to release resources 24 | vid.close() 25 | pygame.quit() 26 | exit() 27 | elif event.type == pygame.KEYDOWN: 28 | key = pygame.key.name(event.key) 29 | 30 | #your program frame rate does not affect video playback 31 | clock.tick(60) 32 | 33 | if key == "r": 34 | vid.restart() #rewind video to beginning 35 | elif key == "p": 36 | vid.toggle_pause() #pause/plays video 37 | elif key == "right": 38 | vid.seek(15) #skip 15 seconds in video 39 | elif key == "left": 40 | vid.seek(-15) #rewind 15 seconds in video 41 | elif key == "up": 42 | vid.set_volume(1.0) #max volume 43 | elif key == "down": 44 | vid.set_volume(0.0) #min volume 45 | 46 | # 2. draw the video to the given surface, at the given position 47 | 48 | # with force_draw=False, only new frames will be drawn, saving 49 | # resources by not drawing already existing frames 50 | 51 | vid.draw(win, (0, 0), force_draw=False) 52 | 53 | pygame.display.update() 54 | -------------------------------------------------------------------------------- /examples/post_processing_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This example gives a side by side comparison between a few available post process effects 3 | ''' 4 | 5 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 6 | 7 | 8 | import pygame 9 | from pyvidplayer2 import Video, PostProcessing 10 | 11 | PATH = r"resources\ocean.mkv" 12 | 13 | win = pygame.display.set_mode((960, 240)) 14 | pygame.display.set_caption("post processing demo") 15 | 16 | # supply a post processing function here 17 | # you can use provided ones from the PostProcessing class, or you can make your own 18 | # all post processing functions must accept and return a numpy ndarray 19 | # post processing functions are applied to each frame before rendering 20 | # both the frame_data and frame_surf properties have post processing applied to them 21 | 22 | def custom_post_processing(data): 23 | return data # do nothing with the frame 24 | 25 | videos = [Video(PATH, post_process=PostProcessing.sharpen), 26 | Video(PATH, post_process=custom_post_processing), 27 | Video(PATH, post_process=PostProcessing.blur)] 28 | 29 | font = pygame.font.SysFont("arial", 30) 30 | surfs = [font.render("Sharpen", True, "white"), 31 | font.render("Normal", True, "white"), 32 | font.render("Blur", True, "white")] 33 | 34 | 35 | while True: 36 | key = None 37 | for event in pygame.event.get(): 38 | if event.type == pygame.QUIT: 39 | [video.close() for video in videos] 40 | pygame.quit() 41 | exit() 42 | elif event.type == pygame.KEYDOWN: 43 | key = pygame.key.name(event.key) 44 | 45 | pygame.time.wait(16) 46 | 47 | for i, surf in enumerate(surfs): 48 | x = 320 * i 49 | videos[i].draw(win, (x, 0)) 50 | pygame.draw.rect(win, "black", (x, 0, *surf.get_size())) 51 | win.blit(surf, (x, 0)) 52 | 53 | pygame.display.update() 54 | -------------------------------------------------------------------------------- /pyvidplayer2/post_processing.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | try: 4 | import cv2 5 | except ImportError: 6 | CV = 0 7 | else: 8 | CV = 1 9 | 10 | 11 | class PostProcessing: 12 | def none(data: np.ndarray) -> np.ndarray: 13 | return data 14 | 15 | if CV: 16 | def blur(data: np.ndarray) -> np.ndarray: 17 | return cv2.blur(data, (5, 5)) 18 | 19 | def sharpen(data: np.ndarray) -> np.ndarray: 20 | return cv2.filter2D(data, -1, np.array([[-1, -1, -1], [-1, 9, -1], [-1, -1, -1]])) 21 | 22 | def greyscale(data: np.ndarray) -> np.ndarray: 23 | return np.stack((cv2.cvtColor(data, cv2.COLOR_BGR2GRAY),) * 3, axis=-1) 24 | 25 | def noise(data: np.ndarray) -> np.ndarray: 26 | noise = np.zeros(data.shape, dtype=np.uint8) 27 | cv2.randn(noise, (0,) * 3, (20,) * 3) 28 | return data + noise 29 | 30 | def letterbox(data: np.ndarray) -> np.ndarray: 31 | background = np.zeros((*data.shape[:2], 3), dtype=np.uint8) 32 | 33 | x1, y1 = 0, int(data.shape[0] * 0.1) # topleft crop 34 | x2, y2 = data.shape[1], int(data.shape[0] * 0.9) # bottomright crop 35 | data = data[y1:y2, x1:x2] # crops image 36 | background[y1:y1 + data.shape[0], x1:x1 + data.shape[1]] = data # draws image onto background 37 | 38 | return background 39 | 40 | def cel_shading(data: np.ndarray) -> np.ndarray: 41 | return cv2.subtract(data, cv2.blur(cv2.merge((cv2.Canny(data, 150, 200),) * 3), (2, 2))) 42 | 43 | def flipup(data: np.ndarray) -> np.ndarray: 44 | return np.flipud(data) 45 | 46 | def fliplr(data: np.ndarray) -> np.ndarray: 47 | return np.fliplr(data) 48 | 49 | def rotate90(data: np.ndarray) -> np.ndarray: 50 | return np.rot90(data, k=3) 51 | 52 | def rotate270(data: np.ndarray) -> np.ndarray: 53 | return np.rot90(data, k=1) 54 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "setuptools_scm", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pyvidplayer2" 7 | dynamic = ["version"] 8 | description = "Reliable, easy, and fast video playback in Python" 9 | readme = {file = "README.md", content-type = "text/markdown"} 10 | authors = [ 11 | {name = "Anray Liu", email = "anrayliu@gmail.com"} 12 | ] 13 | license = "MIT" 14 | requires-python = ">=3.8" 15 | dependencies = [ 16 | "numpy", 17 | "opencv-python", 18 | "pygame", 19 | "pysubs2", 20 | "PyAudio" 21 | ] 22 | keywords = ["pygame", "video", "playback", "tkinter", "pyqt", "pyside", "pyglet", "wxpython"] 23 | classifiers = [ 24 | "Development Status :: 4 - Beta", 25 | 26 | "Intended Audience :: Developers", 27 | "Topic :: Multimedia :: Video", 28 | "Topic :: Multimedia :: Video :: Display", 29 | "Topic :: Software Development :: Libraries :: pygame", 30 | 31 | "Programming Language :: Python :: 3.8", 32 | "Programming Language :: Python :: 3.9", 33 | "Programming Language :: Python :: 3.10", 34 | "Programming Language :: Python :: 3.11", 35 | "Programming Language :: Python :: 3.12", 36 | "Programming Language :: Python :: 3.13" 37 | ] 38 | 39 | [project.optional-dependencies] 40 | all = [ 41 | "numpy", 42 | "opencv-python", 43 | "pygame", 44 | "PyAudio", 45 | "pysubs2", 46 | "yt-dlp", 47 | "decord", 48 | "imageio", 49 | "av", 50 | "pyglet", 51 | "PySide6", 52 | "PyQt6", 53 | "raylib", 54 | "wxPython" 55 | ] 56 | 57 | [tool.setuptools.dynamic] 58 | version = {attr = "pyvidplayer2._version.__version__"} 59 | 60 | [project.urls] 61 | Homepage = "https://github.com/anrayliu/pyvidplayer2" 62 | Documentation = "https://github.com/anrayliu/pyvidplayer2/blob/main/documentation.md" 63 | Repository = "https://github.com/anrayliu/pyvidplayer2.git" 64 | Issues = "https://github.com/anrayliu/pyvidplayer2/issues" 65 | -------------------------------------------------------------------------------- /pyvidplayer2/__init__.py: -------------------------------------------------------------------------------- 1 | from pyvidplayer2._version import __version__ 2 | 3 | VERSION = __version__ # for older versions of pyvidplayer2 4 | FFMPEG_LOGLVL = "quiet" 5 | 6 | from subprocess import run 7 | from .video import READER_FFMPEG, READER_DECORD, READER_OPENCV, READER_IMAGEIO, READER_AUTO 8 | from .error import * 9 | from .post_processing import PostProcessing 10 | 11 | try: 12 | import tkinter 13 | except ImportError: 14 | pass 15 | else: 16 | from .video_tkinter import VideoTkinter 17 | 18 | try: 19 | import PySide6 20 | except ImportError: 21 | pass 22 | else: 23 | from .video_pyside import VideoPySide 24 | 25 | try: 26 | import PyQt6 27 | except ImportError: 28 | pass 29 | else: 30 | from .video_pyqt import VideoPyQT 31 | 32 | try: 33 | import pyray 34 | except ImportError: 35 | pass 36 | else: 37 | from .video_raylib import VideoRaylib 38 | 39 | try: 40 | import wx 41 | except ImportError: 42 | pass 43 | else: 44 | from .video_wx import VideoWx 45 | 46 | try: 47 | import pygame 48 | except ImportError: 49 | pass 50 | else: 51 | pygame.init() 52 | 53 | from .video_pygame import VideoPygame as Video 54 | from .video_player import VideoPlayer 55 | 56 | try: 57 | import cv2 58 | except ImportError: 59 | pass 60 | else: 61 | from .webcam import Webcam 62 | 63 | try: 64 | import pysubs2 65 | except ImportError: 66 | pass 67 | else: 68 | from .subtitles import Subtitles 69 | 70 | try: 71 | import pyglet 72 | except ImportError: 73 | pass 74 | else: 75 | from .video_pyglet import VideoPyglet 76 | 77 | 78 | # cv2.setLogLevel(0) # silent 79 | 80 | def get_version_info(): 81 | try: 82 | pygame_ver = pygame.version.ver 83 | except NameError: 84 | pygame_ver = "not installed" 85 | 86 | try: 87 | ffmpeg_ver = run(["ffmpeg", "-version"], capture_output=True, universal_newlines=True).stdout.split(" ")[2] 88 | except FileNotFoundError: 89 | ffmpeg_ver = "not installed" 90 | 91 | return {"pyvidplayer2": __version__, 92 | "ffmpeg": ffmpeg_ver, 93 | "pygame": pygame_ver} 94 | -------------------------------------------------------------------------------- /examples/pip_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | A quick example showing how pyvidplayer2 can be used in more complicated applications 3 | This is a Picture-in-Picture app 4 | 5 | install pywin32 via pip before using 6 | ''' 7 | 8 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 9 | 10 | 11 | import pygame 12 | from win32gui import SetWindowPos, GetCursorPos, GetWindowRect, GetForegroundWindow, SetForegroundWindow 13 | from win32api import GetSystemMetrics 14 | from win32con import SWP_NOSIZE, HWND_TOPMOST 15 | from win32com.client import Dispatch 16 | from pyvidplayer2 import VideoPlayer, Video 17 | 18 | 19 | SIZE = (426, 240) 20 | FILE = r"resources\billiejean.mp4" 21 | 22 | win = pygame.display.set_mode(SIZE, pygame.NOFRAME) 23 | pygame.display.set_caption("pip demo") 24 | 25 | # creates the video player 26 | 27 | vid = VideoPlayer(Video(FILE, interp="area"), (0, 0, *SIZE), interactable=True) 28 | 29 | # moves the window to the bottom right corner and pins it above other windows 30 | 31 | hwnd = pygame.display.get_wm_info()["window"] 32 | SetWindowPos(hwnd, HWND_TOPMOST, GetSystemMetrics(0) - SIZE[0], GetSystemMetrics(1) - SIZE[1] - 48, 0, 0, SWP_NOSIZE) 33 | 34 | clock = pygame.time.Clock() 35 | 36 | shell = Dispatch("WScript.Shell") 37 | 38 | while True: 39 | events = pygame.event.get() 40 | for event in events: 41 | if event.type == pygame.QUIT: 42 | vid.close() 43 | pygame.quit() 44 | quit() 45 | 46 | clock.tick(60) 47 | 48 | # allows the ui to be seamlessly interacted with 49 | 50 | try: 51 | touching = pygame.Rect(GetWindowRect(hwnd)).collidepoint(GetCursorPos()) 52 | except: # windows is buggy 53 | touching = False 54 | 55 | if touching and GetForegroundWindow() != hwnd: 56 | 57 | # weird behaviour with SetForegroundWindow that requires the alt key to be pressed before it's called 58 | 59 | shell.SendKeys("%") 60 | try: 61 | SetForegroundWindow(hwnd) 62 | except: # catches weird errors 63 | pass 64 | 65 | # handles video playback 66 | 67 | vid.update(events, show_ui=touching) 68 | vid.draw(win) 69 | 70 | pygame.display.update() 71 | -------------------------------------------------------------------------------- /pyvidplayer2/video_pyglet.py: -------------------------------------------------------------------------------- 1 | import pyglet 2 | import numpy as np 3 | from typing import Union, Callable, Tuple 4 | from .video import Video, READER_AUTO 5 | from .post_processing import PostProcessing 6 | 7 | 8 | class VideoPyglet(Video): 9 | """ 10 | Refer to "https://github.com/anrayliu/pyvidplayer2/blob/main/documentation.md" for detailed documentation. 11 | """ 12 | 13 | def __init__(self, path: Union[str, bytes], chunk_size: float = 10, max_threads: int = 1, max_chunks: int = 1, 14 | post_process: Callable[[np.ndarray], np.ndarray] = PostProcessing.none, 15 | interp: Union[str, int] = "linear", use_pygame_audio: bool = False, reverse: bool = False, 16 | no_audio: bool = False, speed: float = 1, youtube: bool = False, 17 | max_res: int = 720, as_bytes: bool = False, audio_track: int = 0, vfr: bool = False, 18 | pref_lang: str = "en", audio_index: int = None, reader: int = READER_AUTO, cuda_device: int = -1) -> None: 19 | Video.__init__(self, path, chunk_size, max_threads, max_chunks, None, post_process, interp, use_pygame_audio, 20 | reverse, no_audio, speed, youtube, max_res, 21 | as_bytes, audio_track, vfr, pref_lang, audio_index, reader, cuda_device) 22 | 23 | def _create_frame(self, data): 24 | return pyglet.image.ImageData(*self.current_size, self._vid._colour_format, np.flip(data, 0).tobytes()) 25 | 26 | def _render_frame(self, pos): 27 | self.frame_surf.blit(*pos) 28 | 29 | def draw(self, pos: Tuple[int, int], force_draw: bool = True) -> bool: 30 | if (self._update() or force_draw) and self.frame_surf is not None: 31 | self._render_frame(pos) # (0, 0) pos draws the video bottomleft 32 | return True 33 | return False 34 | 35 | def preview(self, max_fps: int = 60) -> None: 36 | self.play() 37 | 38 | def update(): 39 | self.draw((0, 0), force_draw=True) 40 | if not self.active: 41 | win.close() 42 | 43 | win = pyglet.window.Window(width=self.current_size[0], height=self.current_size[1], 44 | config=pyglet.gl.Config(double_buffer=True), caption=f"pyglet - {self.name}") 45 | pyglet.clock.schedule_interval(update, 1 / float(max_fps)) 46 | pyglet.app.run() 47 | self.close() 48 | -------------------------------------------------------------------------------- /pyvidplayer2/video_tkinter.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import numpy as np 3 | from typing import Callable, Union, Tuple 4 | from .video import Video, READER_AUTO 5 | from .post_processing import PostProcessing 6 | 7 | 8 | class VideoTkinter(Video): 9 | """ 10 | Refer to "https://github.com/anrayliu/pyvidplayer2/blob/main/documentation.md" for detailed documentation. 11 | """ 12 | 13 | def __init__(self, path: Union[str, bytes], chunk_size: float = 10, max_threads: int = 1, max_chunks: int = 1, 14 | post_process: Callable[[np.ndarray], np.ndarray] = PostProcessing.none, 15 | interp: Union[str, int] = "linear", use_pygame_audio: bool = False, reverse: bool = False, 16 | no_audio: bool = False, speed: float = 1, youtube: bool = False, 17 | max_res: int = 720, as_bytes: bool = False, audio_track: int = 0, vfr: bool = False, 18 | pref_lang: str = "en", audio_index: int = None, reader: int = READER_AUTO, cuda_device: int = -1) -> None: 19 | Video.__init__(self, path, chunk_size, max_threads, max_chunks, None, post_process, interp, use_pygame_audio, 20 | reverse, no_audio, speed, youtube, max_res, 21 | as_bytes, audio_track, vfr, pref_lang, audio_index, reader, cuda_device) 22 | 23 | def _create_frame(self, data): 24 | h, w = data.shape[:2] 25 | if self.colour_format == "BGR": 26 | data = data[..., ::-1] # converts to RGB 27 | return tk.PhotoImage(width=w, height=h, data=f"P6 {w} {h} 255 ".encode() + data.tobytes(), format='PPM') 28 | 29 | def _render_frame(self, canvas, pos): 30 | canvas.create_image(*pos, image=self.frame_surf) 31 | 32 | def draw(self, surf: tk.Canvas, pos: Tuple[int, int], force_draw: bool = True) -> bool: 33 | return Video.draw(self, surf, pos, force_draw) 34 | 35 | def preview(self, max_fps: int = 60) -> None: 36 | self.play() 37 | 38 | def update(): 39 | self.draw(canvas, (self.current_size[0] / 2, self.current_size[1] / 2), force_draw=False) 40 | if self.active: 41 | root.after(int(1 / float(max_fps) * 1000), update) # for around 60 fps 42 | else: 43 | root.destroy() 44 | 45 | root = tk.Tk() 46 | root.title(f"tkinter - {self.name}") 47 | canvas = tk.Canvas(root, width=self.current_size[0], height=self.current_size[1], highlightthickness=0) 48 | canvas.pack() 49 | update() 50 | root.mainloop() 51 | self.close() 52 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package to PyPI when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | release-build: 20 | runs-on: windows-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - uses: actions/setup-python@v5 26 | with: 27 | python-version: "3.12" 28 | 29 | - name: Build release distributions 30 | run: | 31 | # NOTE: put your own distribution build steps here. 32 | python -m pip install build 33 | python -m pip install -r requirements_all.txt 34 | python -m build 35 | 36 | - name: Upload distributions 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: release-dists 40 | path: dist/ 41 | 42 | pypi-publish: 43 | runs-on: ubuntu-latest 44 | needs: 45 | - release-build 46 | permissions: 47 | # IMPORTANT: this permission is mandatory for trusted publishing 48 | id-token: write 49 | 50 | # Dedicated environments with protections for publishing are strongly recommended. 51 | # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules 52 | environment: 53 | name: pypi 54 | # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status: 55 | # url: https://pypi.org/p/YOURPROJECT 56 | # 57 | # ALTERNATIVE: if your GitHub Release name is the PyPI project version string 58 | # ALTERNATIVE: exactly, uncomment the following line instead: 59 | # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }} 60 | 61 | steps: 62 | - name: Retrieve release distributions 63 | uses: actions/download-artifact@v4 64 | with: 65 | name: release-dists 66 | path: dist/ 67 | 68 | - name: Publish release distributions to PyPI 69 | uses: pypa/gh-action-pypi-publish@release/v1 70 | with: 71 | packages-dir: dist/ 72 | -------------------------------------------------------------------------------- /pyvidplayer2/ffmpeg_reader.py: -------------------------------------------------------------------------------- 1 | # Object that mimics cv2.VideoCapture to read frames 2 | 3 | import numpy as np 4 | import subprocess 5 | from . import FFMPEG_LOGLVL 6 | from .video_reader import VideoReader 7 | from .error import * 8 | 9 | 10 | class FFMPEGReader(VideoReader): 11 | def __init__(self, path, probe=True, cuda_device=-1): 12 | VideoReader.__init__(self, path, probe) 13 | 14 | self.cuda_device = cuda_device 15 | 16 | self._colour_format = "BGR" 17 | 18 | self._path = path 19 | 20 | try: 21 | command = self._get_command() 22 | 23 | self._process = subprocess.Popen(command, stdout=subprocess.PIPE) 24 | except FileNotFoundError: 25 | raise FFmpegNotFoundError("Could not find FFmpeg. Make sure FFmpeg is installed and accessible via PATH.") 26 | 27 | def _get_command(self, index=None): 28 | return [ 29 | "ffmpeg", 30 | *(["-hwaccel", "cuda"] if self.cuda_device >= 0 else []), # nvidia hardware acceleration 31 | *(["-init_hw_device", f"cuda:{self.cuda_device}"] if self.cuda_device >= 0 else []), # select device 32 | *(["-ss", self._convert_seconds(index / self.frame_rate)] if index is not None else []), 33 | "-i", self._path, 34 | "-loglevel", FFMPEG_LOGLVL, 35 | "-map", "0:v:0", 36 | "-f", "rawvideo", 37 | "-vf", "format=bgr24", 38 | "-sn", 39 | "-an", 40 | "-" 41 | ] 42 | 43 | def _convert_seconds(self, seconds): 44 | seconds = abs(seconds) 45 | d = str(seconds).split('.')[-1] if '.' in str(seconds) else 0 46 | h = int(seconds // 3600) 47 | seconds = seconds % 3600 48 | m = int(seconds // 60) 49 | s = int(seconds % 60) 50 | return f"{h}:{m}:{s}.{d}" 51 | 52 | def read(self): 53 | b = self._process.stdout.read(self.original_size[0] * self.original_size[1] * 3) 54 | if not b: 55 | has = False 56 | else: 57 | has = True 58 | self.frame += 1 59 | 60 | return (has, 61 | np.frombuffer(b, np.uint8).reshape((self.original_size[1], self.original_size[0], 3)) if has else None) 62 | 63 | def seek(self, index): 64 | self.frame = index 65 | self._process.kill() 66 | # uses input seeking for very fast reading 67 | 68 | self._process = subprocess.Popen(self._get_command(index=index), stdout=subprocess.PIPE) 69 | 70 | def release(self): 71 | self._process.kill() 72 | VideoReader.release(self) 73 | -------------------------------------------------------------------------------- /pyvidplayer2/video_raylib.py: -------------------------------------------------------------------------------- 1 | import io 2 | import pyray 3 | import numpy as np 4 | from typing import Callable, Union, Tuple 5 | from .video import Video, READER_AUTO 6 | from .post_processing import PostProcessing 7 | from PIL import Image 8 | 9 | 10 | class VideoRaylib(Video): 11 | """ 12 | Refer to "https://github.com/anrayliu/pyvidplayer2/blob/main/documentation.md" for detailed documentation. 13 | """ 14 | 15 | def __init__(self, path: Union[str, bytes], chunk_size: float = 10, max_threads: int = 1, max_chunks: int = 1, 16 | post_process: Callable[[np.ndarray], np.ndarray] = PostProcessing.none, 17 | interp: Union[str, int] = "linear", use_pygame_audio: bool = False, reverse: bool = False, 18 | no_audio: bool = False, speed: float = 1, youtube: bool = False, 19 | max_res: int = 720, as_bytes: bool = False, audio_track: int = 0, vfr: bool = False, 20 | pref_lang: str = "en", audio_index: int = None, reader: int = READER_AUTO, cuda_device: int = -1) -> None: 21 | Video.__init__(self, path, chunk_size, max_threads, max_chunks, None, post_process, interp, use_pygame_audio, 22 | reverse, no_audio, speed, youtube, max_res, 23 | as_bytes, audio_track, vfr, pref_lang, audio_index, reader, cuda_device) 24 | 25 | def _create_frame(self, data): 26 | if self.frame_surf is not None: 27 | pyray.unload_texture(self.frame_surf) 28 | buffer = io.BytesIO() 29 | Image.fromarray(data[..., ::-1]).save(buffer, format="BMP") 30 | img = pyray.load_image_from_memory(".bmp", str(buffer.getvalue()), len(buffer.getvalue())) 31 | texture = pyray.load_texture_from_image(img) 32 | pyray.unload_image(img) 33 | return texture 34 | 35 | def _render_frame(self, _, pos): 36 | pyray.draw_texture(self.frame_surf, *pos, pyray.WHITE) 37 | 38 | def draw(self, pos: Tuple[int, int], force_draw: bool = True) -> bool: 39 | return Video.draw(self, None, pos, force_draw) 40 | 41 | def preview(self, max_fps: int = 60) -> None: 42 | pyray.init_window(self.original_size[0], self.original_size[1], f"raylib - {self.name}") 43 | pyray.set_target_fps(max_fps) 44 | self.play() 45 | while not pyray.window_should_close() and self.active: 46 | pyray.begin_drawing() 47 | if self.draw((0, 0), force_draw=False): 48 | pyray.end_drawing() 49 | if self.frame_surf is not None: 50 | pyray.unload_texture(self.frame_surf) 51 | pyray.close_window() 52 | self.close() 53 | 54 | def close(self) -> None: 55 | if self.frame_surf is not None: 56 | pyray.unload_texture(self.frame_surf) 57 | Video.close(self) 58 | -------------------------------------------------------------------------------- /examples/webcam_app_demo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This is an example showing off a draggable webcam app 3 | 4 | install pywin32 via pip before using 5 | ''' 6 | 7 | # Sample videos can be found here: https://github.com/anrayliu/pyvidplayer2-test-resources/tree/main/resources 8 | 9 | 10 | import pygame 11 | from pyvidplayer2 import Webcam 12 | import win32api, win32gui, win32con 13 | from win32com.client import Dispatch 14 | 15 | 16 | webcam = Webcam(interp="area", capture_size=(1920, 1080)) 17 | webcam.change_resolution(240) # scales video without changing aspect ratio 18 | 19 | print(f"Webcam capturing at {webcam.original_size[1]}p resolution, scaling to {webcam.current_size[1]}p for display") 20 | 21 | win = pygame.display.set_mode(webcam.current_size, pygame.NOFRAME) 22 | pygame.display.set_caption("webcam app demo") 23 | clock = pygame.time.Clock() 24 | 25 | dragging = False 26 | temp_pos = (0, 0) 27 | window_rect = pygame.Rect(win32api.GetSystemMetrics(0) - win.get_width(), win32api.GetSystemMetrics(1) - win.get_height() - 48, *win.get_size()) 28 | shell = Dispatch("WScript.Shell") # required workaround for a windows bug 29 | 30 | HWND = pygame.display.get_wm_info()["window"] 31 | 32 | # makes window topmost 33 | win32gui.SetWindowPos(HWND, win32con.HWND_TOPMOST, *window_rect, win32con.SWP_NOSIZE) 34 | 35 | while True: 36 | mouse_movement = (0, 0) 37 | 38 | for event in pygame.event.get(): 39 | if event.type == pygame.QUIT: 40 | webcam.close() 41 | pygame.quit() 42 | exit() 43 | elif event.type == pygame.MOUSEBUTTONDOWN: 44 | dragging = True 45 | temp_pos = pygame.mouse.get_pos() 46 | elif event.type == pygame.MOUSEMOTION: 47 | mouse_movement = event.pos 48 | 49 | if dragging: 50 | if not pygame.mouse.get_pressed()[0]: 51 | dragging = False 52 | elif mouse_movement != (0, 0): 53 | window_rect.x += mouse_movement[0] - temp_pos[0] 54 | window_rect.y += mouse_movement[1] - temp_pos[1] 55 | 56 | # moves window while keeping topmost 57 | win32gui.SetWindowPos(HWND, win32con.HWND_TOPMOST, *window_rect, win32con.SWP_NOSIZE) 58 | 59 | # keeps the webcam focused when hovered over for seamless dragging 60 | try: 61 | touching = window_rect.collidepoint(win32api.GetCursorPos()) 62 | except: # catches access denied when pc goes to sleep 63 | touching = False 64 | 65 | if win32gui.GetForegroundWindow() != HWND and touching: 66 | shell.SendKeys("%") # windows is weird 67 | 68 | # windows is buggy 69 | try: 70 | win32gui.SetForegroundWindow(HWND) 71 | except: 72 | pass 73 | 74 | clock.tick(60) 75 | 76 | webcam.draw(win, (0, 0), force_draw=False) 77 | 78 | pygame.display.update() 79 | -------------------------------------------------------------------------------- /pyvidplayer2/video_pyqt.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from .video import Video, READER_AUTO 3 | from typing import Callable, Union, Tuple 4 | from PyQt6.QtGui import QImage, QPixmap, QPainter 5 | from PyQt6.QtWidgets import QApplication, QMainWindow, QWidget 6 | from PyQt6.QtCore import QTimer 7 | from .post_processing import PostProcessing 8 | 9 | 10 | class VideoPyQT(Video): 11 | """ 12 | Refer to "https://github.com/anrayliu/pyvidplayer2/blob/main/documentation.md" for detailed documentation. 13 | """ 14 | 15 | def __init__(self, path: Union[str, bytes], chunk_size: float = 10, max_threads: int = 1, max_chunks: int = 1, 16 | post_process: Callable[[np.ndarray], np.ndarray] = PostProcessing.none, 17 | interp: Union[str, int] = "linear", use_pygame_audio: bool = False, reverse: bool = False, 18 | no_audio: bool = False, speed: float = 1, youtube: bool = False, 19 | max_res: int = 720, as_bytes: bool = False, audio_track: int = 0, vfr: bool = False, 20 | pref_lang: str = "en", audio_index: int = None, reader: int = READER_AUTO, cuda_device: int = -1) -> None: 21 | Video.__init__(self, path, chunk_size, max_threads, max_chunks, None, post_process, interp, use_pygame_audio, 22 | reverse, no_audio, speed, youtube, max_res, 23 | as_bytes, audio_track, vfr, pref_lang, audio_index, reader, cuda_device) 24 | 25 | def _create_frame(self, data): 26 | # only BGR and RGB formats in readers right now 27 | f = QImage.Format.Format_BGR888 if self.colour_format == "BGR" else QImage.Format.Format_RGB888 28 | return QImage(data, data.shape[1], data.shape[0], data.strides[0], f) 29 | 30 | def _render_frame(self, win, pos): # must be called in paintEvent 31 | QPainter(win).drawPixmap(*pos, QPixmap.fromImage(self.frame_surf)) 32 | 33 | def draw(self, surf: QWidget, pos: Tuple[int, int], force_draw: bool = True) -> bool: 34 | return Video.draw(self, surf, pos, force_draw) 35 | 36 | def preview(self, max_fps: int = 60) -> None: 37 | self.play() 38 | 39 | class Window(QMainWindow): 40 | def __init__(self): 41 | super().__init__() 42 | self.canvas = QWidget(self) 43 | self.setCentralWidget(self.canvas) 44 | self.timer = QTimer(self) 45 | self.timer.timeout.connect(self.update) 46 | self.timer.start(int(1 / float(max_fps) * 1000)) 47 | 48 | def paintEvent(self_, _): 49 | self.draw(self_, (0, 0)) 50 | if not self.active: 51 | QApplication.quit() 52 | 53 | app = QApplication([]) 54 | win = Window() 55 | win.setWindowTitle(f"pyqt6 - {self.name}") 56 | win.setFixedSize(*self.current_size) 57 | win.show() 58 | app.exec() 59 | self.close() 60 | -------------------------------------------------------------------------------- /pyvidplayer2/video_pyside.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from typing import Callable, Union, Tuple 3 | from PySide6.QtGui import QImage, QPixmap, QPainter 4 | from PySide6.QtWidgets import QApplication, QMainWindow, QWidget 5 | from PySide6.QtCore import QTimer 6 | from .post_processing import PostProcessing 7 | from .video import Video, READER_AUTO 8 | 9 | 10 | class VideoPySide(Video): 11 | """ 12 | Refer to "https://github.com/anrayliu/pyvidplayer2/blob/main/documentation.md" for detailed documentation. 13 | """ 14 | 15 | def __init__(self, path: Union[str, bytes], chunk_size: float = 10, max_threads: int = 1, max_chunks: int = 1, 16 | post_process: Callable[[np.ndarray], np.ndarray] = PostProcessing.none, 17 | interp: Union[str, int] = "linear", use_pygame_audio: bool = False, reverse: bool = False, 18 | no_audio: bool = False, speed: float = 1, youtube: bool = False, 19 | max_res: int = 720, as_bytes: bool = False, audio_track: int = 0, vfr: bool = False, 20 | pref_lang: str = "en", audio_index: int = None, reader: int = READER_AUTO, cuda_device: int = -1) -> None: 21 | Video.__init__(self, path, chunk_size, max_threads, max_chunks, None, post_process, interp, use_pygame_audio, 22 | reverse, no_audio, speed, youtube, max_res, 23 | as_bytes, audio_track, vfr, pref_lang, audio_index, reader, cuda_device) 24 | 25 | def _create_frame(self, data): 26 | # only BGR and RGB formats in readers right now 27 | f = QImage.Format.Format_BGR888 if self.colour_format == "BGR" else QImage.Format.Format_RGB888 28 | return QImage(data, data.shape[1], data.shape[0], data.strides[0], f) 29 | 30 | def _render_frame(self, win, pos): # must be called in paintEvent 31 | QPainter(win).drawPixmap(*pos, QPixmap.fromImage(self.frame_surf)) 32 | 33 | def draw(self, surf: QWidget, pos: Tuple[int, int], force_draw: bool = True) -> bool: 34 | return Video.draw(self, surf, pos, force_draw) 35 | 36 | def preview(self, max_fps: int = 60) -> None: 37 | self.play() 38 | 39 | class Window(QMainWindow): 40 | def __init__(self): 41 | super().__init__() 42 | self.canvas = QWidget(self) 43 | self.setCentralWidget(self.canvas) 44 | self.timer = QTimer(self) 45 | self.timer.timeout.connect(self.update) 46 | self.timer.start(int(1 / float(max_fps) * 1000)) 47 | 48 | def paintEvent(self_, _): 49 | self.draw(self_, (0, 0)) 50 | if not self.active: 51 | QApplication.quit() 52 | 53 | app = QApplication([]) 54 | win = Window() 55 | win.setWindowTitle(f"pyside6 - {self.name}") 56 | win.setFixedSize(*self.current_size) 57 | win.show() 58 | app.exec() 59 | self.close() 60 | -------------------------------------------------------------------------------- /pyvidplayer2/video_pygame.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | import numpy as np 3 | from typing import Callable, Union, Tuple 4 | from .video import Video, READER_AUTO 5 | from .post_processing import PostProcessing 6 | 7 | 8 | class VideoPygame(Video): 9 | """ 10 | Refer to "https://github.com/anrayliu/pyvidplayer2/blob/main/documentation.md" for detailed documentation. 11 | """ 12 | 13 | def __init__(self, path: Union[str, bytes], chunk_size: float = 10, max_threads: int = 1, max_chunks: int = 1, 14 | subs: "pyvidplayer2.Subtitles" = None, 15 | post_process: Callable[[np.ndarray], np.ndarray] = PostProcessing.none, 16 | interp: Union[str, int] = "linear", use_pygame_audio: bool = False, reverse: bool = False, 17 | no_audio: bool = False, speed: float = 1, youtube: bool = False, max_res: int = 720, 18 | as_bytes: bool = False, audio_track: int = 0, vfr: bool = False, pref_lang: str = "en", 19 | audio_index: int = None, reader: int = READER_AUTO, cuda_device: int = -1) -> None: 20 | Video.__init__(self, path, chunk_size, max_threads, max_chunks, subs, post_process, interp, use_pygame_audio, 21 | reverse, no_audio, speed, youtube, max_res, 22 | as_bytes, audio_track, vfr, pref_lang, audio_index, reader, cuda_device) 23 | 24 | def _create_frame(self, data): 25 | return pygame.image.frombuffer(data.tobytes(), (data.shape[1], data.shape[0]), self._vid._colour_format) 26 | 27 | def _render_frame(self, surf, pos): 28 | surf.blit(self.frame_surf, pos) 29 | 30 | def draw(self, surf: pygame.Surface, pos: Tuple[int, int], force_draw: bool = True) -> bool: 31 | return Video.draw(self, surf, pos, force_draw) 32 | 33 | def preview(self, show_fps: bool = False, max_fps: int = 60) -> None: 34 | win = pygame.display.set_mode(self.current_size) 35 | clock = pygame.time.Clock() 36 | pygame.display.set_caption(f"pygame - {self.name}") 37 | self.play() 38 | timer = 0 39 | fps = 0 40 | frames = 0 41 | font = pygame.font.SysFont("arial", 30) 42 | while self.active: 43 | for event in pygame.event.get(): 44 | if event.type == pygame.QUIT: 45 | self.stop() 46 | dt = clock.tick(max_fps) 47 | if show_fps: 48 | timer += dt 49 | if timer >= 1000: 50 | fps = frames 51 | timer = 0 52 | frames = 0 53 | if self.draw(win, (0, 0), force_draw=False): 54 | if show_fps: 55 | frames += 1 56 | surf = font.render(str(fps), True, "white") 57 | pygame.draw.rect(win, "black", surf.get_rect(topleft=(0, 0))) 58 | win.blit(surf, (0, 0)) 59 | pygame.display.update() 60 | pygame.display.quit() 61 | self.close() 62 | 63 | def show_subs(self) -> None: 64 | self.subs_hidden = False 65 | if self.frame_data is not None: 66 | self.frame_surf = self._create_frame(self.frame_data) 67 | if self.subs: 68 | self._write_subs(self.frame / self.frame_rate) 69 | 70 | def hide_subs(self) -> None: 71 | self.subs_hidden = True 72 | if self.frame_data is not None: 73 | self.frame_surf = self._create_frame(self.frame_data) 74 | 75 | def set_subs(self, subs: "pyvidplayer2.Subtitles") -> None: 76 | self.subs = self._filter_subs(subs) 77 | -------------------------------------------------------------------------------- /pyvidplayer2/video_wx.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import numpy as np 3 | from typing import Callable, Union, Tuple 4 | from .video import Video, READER_AUTO 5 | from .post_processing import PostProcessing 6 | 7 | 8 | class VideoWx(Video): 9 | """ 10 | Refer to "https://github.com/anrayliu/pyvidplayer2/blob/main/documentation.md" for detailed documentation. 11 | """ 12 | 13 | def __init__(self, path: Union[str, bytes], chunk_size: float = 10, max_threads: int = 1, max_chunks: int = 1, 14 | post_process: Callable[[np.ndarray], np.ndarray] = PostProcessing.none, 15 | interp: Union[str, int] = "linear", use_pygame_audio: bool = False, reverse: bool = False, 16 | no_audio: bool = False, speed: float = 1, youtube: bool = False, 17 | max_res: int = 720, as_bytes: bool = False, audio_track: int = 0, vfr: bool = False, 18 | pref_lang: str = "en", audio_index: int = None, reader: int = READER_AUTO, cuda_device: int = -1) -> None: 19 | Video.__init__(self, path, chunk_size, max_threads, max_chunks, None, post_process, interp, use_pygame_audio, 20 | reverse, no_audio, speed, youtube, max_res, 21 | as_bytes, audio_track, vfr, pref_lang, audio_index, reader, cuda_device) 22 | 23 | def _create_frame(self, data: np.ndarray): 24 | h, w = data.shape[:2] 25 | if self.colour_format == "BGR": 26 | data = data[..., ::-1] # converts to RGB 27 | return wx.Image(w, h, data.flatten().tobytes()).ConvertToBitmap() 28 | 29 | def _render_frame(self, panel: wx.Panel, pos: Tuple[int, int]): 30 | dc = wx.PaintDC(panel) 31 | dc.DrawBitmap(self.frame_surf, pos[0], pos[1], True) 32 | 33 | def draw(self, panel: wx.Panel, pos: Tuple[int, int], force_draw: bool = True) -> bool: 34 | if (self._update() or force_draw) and self.frame_surf is not None: 35 | self._render_frame(panel, pos) 36 | return True 37 | return False 38 | 39 | def preview(self_video, max_fps: int = 60) -> None: 40 | class Window(wx.Frame): 41 | def __init__(self_frame): 42 | super(Window, self_frame).__init__(None, title=f"wx - {self_video.name}") 43 | 44 | self_frame.panel = wx.Panel(self_frame, size=wx.Size(*self_video.current_size)) 45 | self_frame.panel.SetBackgroundStyle(wx.BG_STYLE_PAINT) 46 | 47 | # for some reason, setting the size of the frame in constructor 48 | # still clips off some of the video 49 | # this seems to work for now 50 | 51 | sizer = wx.BoxSizer(wx.HORIZONTAL) 52 | sizer.Add(self_frame.panel) 53 | sizer.Fit(self_frame) 54 | sizer = wx.BoxSizer(wx.VERTICAL) 55 | sizer.Add(self_frame.panel) 56 | sizer.Fit(self_frame) 57 | 58 | self_frame.timer = wx.Timer(self_frame) 59 | self_frame.Bind(wx.EVT_TIMER, self_frame.update, self_frame.timer) 60 | self_frame.panel.Bind(wx.EVT_PAINT, self_frame.draw) 61 | 62 | self_video.play() 63 | self_frame.timer.Start(int(1000 / self_video.frame_rate)) 64 | 65 | self_frame.Show() 66 | 67 | def update(self_frame, event): 68 | if not self_video.active: 69 | wx.CallAfter(self_frame.Close) 70 | 71 | self_frame.panel.Refresh(eraseBackground=False) 72 | 73 | def draw(self_frame, event): 74 | self_video.draw(self_frame.panel, (0, 0), False) 75 | 76 | class MyApp(wx.App): 77 | def OnInit(self): 78 | Window() 79 | return True 80 | 81 | app = MyApp(False) 82 | app.MainLoop() 83 | self_video.close() 84 | -------------------------------------------------------------------------------- /pyvidplayer2/video_reader.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import json 3 | from . import FFMPEG_LOGLVL 4 | from .error import * 5 | 6 | 7 | class VideoReader: 8 | def __init__(self, path, probe=False): 9 | self.frame_count = 0 10 | self.frame_rate = 0 11 | self.original_size = (0, 0) 12 | self.duration = 0 13 | self.frame = 0 14 | self._colour_format = "" 15 | 16 | self.released = False 17 | 18 | if probe: 19 | self._probe(path) 20 | 21 | # as it turns out, obtaining video data such as frame count and dimensions is actually very inconsistent between 22 | # different videos and encoders 23 | def _probe(self, path, as_bytes=False): 24 | # strangely for ffprobe, - is not required to indicate output 25 | 26 | try: 27 | # this method counts the number of packets as a substitute for frames, which is much too slow 28 | # p = subprocess.Popen(f"ffprobe -i {'-' if as_bytes else path} -show_streams -select_streams v -loglevel {FFMPEG_LOGLVL} -print_format json", stdin=subprocess.PIPE if as_bytes else None, stdout=subprocess.PIPE) 29 | 30 | command = [ 31 | "ffprobe", 32 | "-i", "-" if as_bytes else path, 33 | "-show_streams", 34 | "-count_packets", 35 | "-select_streams", "v:0", 36 | "-loglevel", FFMPEG_LOGLVL, 37 | "-print_format", "json" 38 | ] 39 | 40 | p = subprocess.Popen(command, stdin=subprocess.PIPE if as_bytes else None, stdout=subprocess.PIPE) 41 | except FileNotFoundError: 42 | raise FFmpegNotFoundError( 43 | "Could not find FFprobe (should be bundled with FFmpeg). Make sure FFprobe is installed and accessible via PATH.") 44 | 45 | info = json.loads(p.communicate(input=path if as_bytes else None)[0]) 46 | 47 | if len(info) == 0: 48 | raise VideoStreamError("Could not determine video.") 49 | info = info["streams"] 50 | if len(info) == 0: 51 | raise VideoStreamError("No video tracks found.") 52 | info = info[0] 53 | 54 | self.original_size = int(info["width"]), int(info["height"]) 55 | 56 | if self.original_size == (0, 0): 57 | raise VideoStreamError("FFmpeg failed to read video.") 58 | 59 | self.frame_rate = float(info["r_frame_rate"].split("/")[0]) / float(info["r_frame_rate"].split("/")[1]) 60 | 61 | # this detects duration instead 62 | 63 | '''try: 64 | p = subprocess.Popen(f"ffprobe -i {'-' if as_bytes else path} -show_format -loglevel {FFMPEG_LOGLVL} -print_format json", 65 | stdin=subprocess.PIPE if as_bytes else None, stdout=subprocess.PIPE) 66 | except FileNotFoundError: 67 | raise FileNotFoundError( 68 | "Could not find FFprobe (should be bundled with FFmpeg). Make sure FFprobe is installed and accessible via PATH.") 69 | 70 | info = json.loads(p.communicate(input=path if as_bytes else None)[0])["format"] 71 | self.duration = float(info["duration"]) 72 | self.frame_count = int(self.duration * self.frame_rate)''' 73 | 74 | # use header information if available, which should be more accurate than counting packets 75 | try: 76 | self.frame_count = int(info["nb_frames"]) 77 | except KeyError: 78 | self.frame_count = int(info["nb_read_packets"]) 79 | 80 | try: 81 | self.duration = float(info["duration"]) 82 | except KeyError: 83 | self.duration = self.frame_count / self.frame_rate 84 | 85 | def isOpened(self): 86 | return True 87 | 88 | def seek(self, index): 89 | pass 90 | 91 | def read(self): 92 | pass 93 | 94 | def release(self): 95 | self.released = True 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | 163 | # pyvidplayer2-specific patterns 164 | build.txt 165 | test.py 166 | resources/ 167 | tests/resources 168 | .VSCodeCounter/ 169 | yt/ 170 | devnote.txt 171 | .idea 172 | .venv/ 173 | .DS_Store 174 | .vscode/ -------------------------------------------------------------------------------- /tests/test_previews.py: -------------------------------------------------------------------------------- 1 | # test resources: https://github.com/anrayliu/pyvidplayer2-test-resources 2 | # use pip install pyvidplayer2[all] to install all dependencies 3 | 4 | 5 | import unittest 6 | import time 7 | import sys 8 | from threading import Thread 9 | from pyvidplayer2 import * 10 | from test_video import VIDEO_PATH 11 | from test_youtube import YOUTUBE_PATH 12 | 13 | 14 | # macos and linux os' do not like preview tests, so I've isolated them here 15 | # can still be buggy on windows, so this test file may be omitted 16 | 17 | @unittest.skip 18 | class TestPreviews(unittest.TestCase): 19 | # tests that looping is seamless 20 | # also tests that video does indeed loop by timing out otherwise 21 | def test_seamless_loop(self): 22 | v = Video("resources/loop.mp4") 23 | vp = VideoPlayer(v, (0, 0, *v.original_size), loop=True) 24 | 25 | self.assertTrue(v._buffer_first_chunk) 26 | 27 | thread = Thread(target=lambda: vp.preview()) 28 | thread.start() 29 | 30 | t = 0 31 | track = False 32 | buffers = [] 33 | timeout = time.time() 34 | while True: 35 | if time.time() - timeout > 30: 36 | raise TimeoutError("Test timed out.") 37 | if not v.active and not track: 38 | t = time.time() 39 | track = True 40 | elif v.active and track: 41 | track = False 42 | buffers.append(time.time() - t) 43 | if len(buffers) == 10: 44 | self.assertTrue(v.frame_delay > sum(buffers) / 10.0) 45 | break 46 | 47 | # gracefully shut down thread which would otherwise loop forever 48 | vp.loop = False 49 | pygame.event.post(pygame.event.Event(pygame.QUIT)) 50 | thread.join() 51 | 52 | vp.close() 53 | 54 | # tests for a bug where previews would never end if video was looping 55 | # for some reason, this fails if ran with the rest, but passes when ran individually 56 | @unittest.skip 57 | def test_looping_preview(self): 58 | v = Video(VIDEO_PATH) 59 | vp = VideoPlayer(v, (0, 0, *v.original_size), loop=True) 60 | 61 | thread = Thread(target=lambda: vp.preview()) 62 | thread.start() 63 | 64 | time.sleep(0.5) 65 | pygame.event.post(pygame.event.Event(pygame.QUIT)) 66 | time.sleep(0.5) 67 | 68 | if v.active: 69 | # gracefully shut down thread which would otherwise loop forever 70 | vp.loop = False 71 | pygame.event.post(pygame.event.Event(pygame.QUIT)) 72 | thread.join() 73 | 74 | # make v active again to raise an assertion error 75 | v.active = True 76 | 77 | self.assertFalse(v.active) 78 | 79 | vp.close() 80 | thread.join() 81 | 82 | # tests that previews behave correctly 83 | @unittest.skip 84 | def test_preview(self): 85 | v = Video(VIDEO_PATH) 86 | vp = VideoPlayer(v, (0, 0, 1280, 720)) 87 | v.seek(v.duration) 88 | thread = Thread(target=lambda: vp.preview()) 89 | thread.start() 90 | time.sleep(1) 91 | self.assertFalse(thread.is_alive()) 92 | self.assertTrue(vp.closed) 93 | thread.join() 94 | 95 | # tests that previews start from where the video position is, and that they close the video afterwards 96 | def test_previews(self): 97 | for lib in (Video, VideoTkinter, VideoPyglet, VideoRaylib, VideoPyQT, VideoPySide, VideoWx): 98 | v = lib(VIDEO_PATH) 99 | v.seek(v.duration) 100 | v.preview() 101 | self.assertTrue(v.closed) 102 | 103 | # tests pyav dependency message 104 | def test_imageio_needs_pyav(self): 105 | # mocks away av 106 | dict_ = {key: None for key in sys.modules.keys() if key.startswith("av.")} 107 | dict_.update({"av": None}) 108 | with unittest.mock.patch.dict("sys.modules", dict_): 109 | with self.assertRaises(ImportError) as context: 110 | Video("resources/clip.mp4", reader=READER_IMAGEIO).preview() 111 | 112 | # tests for a bug where the last frame would hang in situations like this 113 | def test_frame_bug(self): 114 | v = Video(VIDEO_PATH, speed=5) 115 | v.seek(65.19320347222221, False) 116 | thread = Thread(target=lambda: v.preview()) 117 | thread.start() 118 | time.sleep(1) 119 | self.assertFalse(thread.is_alive()) 120 | thread.join() 121 | 122 | # tests for videos with special characters in their title (e.g spaces, symbols, etc) 123 | def test_special_filename(self): 124 | v = Video("resources/specia1 video$% -.mp4", speed=5) 125 | thread = Thread(target=lambda: v.preview()) 126 | thread.start() 127 | time.sleep(2) 128 | self.assertFalse(thread.is_alive()) 129 | thread.join() 130 | 131 | # test that gifs can be played 132 | def test_gif(self): 133 | v = Video("resources/myGif.gif") 134 | thread = Thread(target=lambda: v.preview()) 135 | thread.start() 136 | time.sleep(1.5) 137 | self.assertFalse(thread.is_alive()) 138 | thread.join() 139 | 140 | # tests that video players work with youtube videos 141 | def test_youtube_player(self): 142 | v = Video(YOUTUBE_PATH, youtube=True) 143 | vp = VideoPlayer(v, (0, 0, *v.original_size)) 144 | v.seek(v.duration) 145 | thread = Thread(target=lambda: vp.preview()) 146 | thread.start() 147 | time.sleep(1) 148 | self.assertFalse(thread.is_alive()) 149 | self.assertTrue(vp.closed) 150 | thread.join() 151 | -------------------------------------------------------------------------------- /tests/test_webcam.py: -------------------------------------------------------------------------------- 1 | # test resources: https://github.com/anrayliu/pyvidplayer2-test-resources 2 | # use pip install pyvidplayer2[all] to install all dependencies 3 | 4 | 5 | import unittest 6 | import time 7 | from pyvidplayer2 import * 8 | from test_video import while_loop, timed_loop, check_same_frames, VIDEO_PATH 9 | 10 | 11 | class TestWebcam(unittest.TestCase): 12 | # tests default webcam 13 | def test_open_webcam(self): 14 | w = Webcam() 15 | self.assertNotEqual(w.original_size, (0, 0)) 16 | self.assertEqual(w.original_size, w.current_size) 17 | self.assertEqual(w.aspect_ratio, (w.original_size[0] / w.original_size[1])) 18 | self.assertIs(w.frame_data, None) 19 | self.assertIs(w.frame_surf, None) 20 | self.assertFalse(w.closed) 21 | self.assertTrue(w.active) 22 | self.assertIs(w.post_func, PostProcessing.none) 23 | self.assertEqual(w.interp, cv2.INTER_LINEAR) 24 | self.assertEqual(w.fps, 30) 25 | self.assertEqual(w.cam_id, 0) 26 | w.close() 27 | 28 | # tests __str__ 29 | def test_str_magic_method(self): 30 | w = Webcam() 31 | self.assertEqual("", str(w)) 32 | w.close() 33 | 34 | # tests that webcam plays without errors 35 | def test_webcam_playback(self): 36 | w = Webcam() 37 | timed_loop(5, lambda: (w.update(), self.assertIsNot(w.frame_surf, None))) 38 | w.close() 39 | 40 | # tests webcam resizing features 41 | def test_webcam_resize(self): 42 | w = Webcam(capture_size=(640, 480)) 43 | self.assertEqual(w.original_size, (640, 480)) 44 | self.assertEqual(w.original_size[0], w._vid.get(cv2.CAP_PROP_FRAME_WIDTH)) 45 | self.assertEqual(w.original_size[1], w._vid.get(cv2.CAP_PROP_FRAME_HEIGHT)) 46 | w.resize((1280, 720)) 47 | self.assertEqual(w.original_size, (640, 480)) 48 | self.assertEqual(w.current_size, (1280, 720)) 49 | w.update() # captures a frame 50 | self.assertEqual(w.frame_data.shape, (720, 1280, 3)) 51 | w.resize((1920, 1080)) 52 | self.assertEqual(w.original_size, (640, 480)) 53 | self.assertEqual(w.current_size, (1920, 1080)) 54 | self.assertEqual(w.frame_data.shape, (1080, 1920, 3)) 55 | w.change_resolution(480) 56 | self.assertEqual(w.original_size, (640, 480)) 57 | self.assertEqual(w.current_size, (640, 480)) 58 | self.assertEqual(w.frame_data.shape, (480, 640, 3)) 59 | w.close() 60 | 61 | # tests webcam position accuracy 62 | # right now is not very accurate, will improve soon 63 | def test_webcam_get_pos(self): 64 | w = Webcam() 65 | t = time.time() 66 | while time.time() - t < 10: 67 | w.update() 68 | self.assertTrue(w.get_pos() > 9) 69 | 70 | # tests that webcam plays and stops properly 71 | def test_webcam_active(self): 72 | w = Webcam() 73 | self.assertTrue(w.active) 74 | w.play() 75 | self.assertTrue(w.active) 76 | w.stop() 77 | self.assertFalse(w.active) 78 | w.stop() 79 | self.assertFalse(w.active) 80 | w.play() 81 | self.assertTrue(w.active) 82 | w.close() 83 | self.assertTrue(w.closed) 84 | 85 | # tests that webcam can achieve 60 fps 86 | # can only succeed if you have a 60 fps webcam 87 | def test_webcam_60_fps(self): 88 | w = Webcam(fps=30) 89 | t = time.time() 90 | while time.time() - t < 10: 91 | w.update() 92 | self.assertTrue(w._frames / 30 > 8) 93 | w = Webcam(fps=60) 94 | t = time.time() 95 | while time.time() - t < 10: 96 | w.update() 97 | self.assertTrue(w._frames / 60 > 8) 98 | 99 | # tests the set_interp method 100 | def test_set_interp(self): 101 | w = Webcam(interp="linear") 102 | self.assertEqual(w.interp, cv2.INTER_LINEAR) 103 | w.close() 104 | w = Webcam(interp="cubic") 105 | self.assertEqual(w.interp, cv2.INTER_CUBIC) 106 | w.close() 107 | w = Webcam(interp="area") 108 | self.assertEqual(w.interp, cv2.INTER_AREA) 109 | w.close() 110 | w = Webcam(interp="lanczos4") 111 | self.assertEqual(w.interp, cv2.INTER_LANCZOS4) 112 | w.close() 113 | w = Webcam(interp="nearest") 114 | self.assertEqual(w.interp, cv2.INTER_NEAREST) 115 | 116 | w.set_interp("linear") 117 | self.assertEqual(w.interp, cv2.INTER_LINEAR) 118 | w.set_interp("cubic") 119 | self.assertEqual(w.interp, cv2.INTER_CUBIC) 120 | w.set_interp("area") 121 | self.assertEqual(w.interp, cv2.INTER_AREA) 122 | w.set_interp("lanczos4") 123 | self.assertEqual(w.interp, cv2.INTER_LANCZOS4) 124 | w.set_interp("nearest") 125 | self.assertEqual(w.interp, cv2.INTER_NEAREST) 126 | 127 | w.set_interp(cv2.INTER_LINEAR) 128 | self.assertEqual(w.interp, cv2.INTER_LINEAR) 129 | w.set_interp(cv2.INTER_CUBIC) 130 | self.assertEqual(w.interp, cv2.INTER_CUBIC) 131 | w.set_interp(cv2.INTER_AREA) 132 | self.assertEqual(w.interp, cv2.INTER_AREA) 133 | w.set_interp(cv2.INTER_LANCZOS4) 134 | self.assertEqual(w.interp, cv2.INTER_LANCZOS4) 135 | w.set_interp(cv2.INTER_NEAREST) 136 | self.assertEqual(w.interp, cv2.INTER_NEAREST) 137 | 138 | self.assertRaises(ValueError, w.set_interp, "unrecognized interp") 139 | 140 | w.close() 141 | 142 | # tests that produced resampled images are the same that a video class produces 143 | def test_resampling(self): 144 | v = Video(VIDEO_PATH) 145 | original_frame = next(v) 146 | 147 | w = Webcam() 148 | 149 | SIZES = ( 150 | (426, 240), (640, 360), (854, 480), (1280, 720), (1920, 1080), (2560, 1440), (3840, 2160), (7680, 4320)) 151 | 152 | for size in SIZES: 153 | for flag in (cv2.INTER_LINEAR, cv2.INTER_NEAREST, cv2.INTER_CUBIC, cv2.INTER_LANCZOS4, cv2.INTER_AREA): 154 | new_frame = v._resize_frame(original_frame, size, flag, False) 155 | webcam_resized = w._resize_frame(original_frame, size, flag) 156 | self.assertTrue(check_same_frames(new_frame, webcam_resized)) 157 | 158 | v.close() 159 | w.close() 160 | 161 | 162 | if __name__ == "__main__": 163 | unittest.main() 164 | -------------------------------------------------------------------------------- /pyvidplayer2/subtitles.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import pygame 3 | import pysubs2 4 | import re 5 | import os 6 | from typing import Union, Tuple 7 | from . import FFMPEG_LOGLVL 8 | from .error import * 9 | 10 | try: 11 | import yt_dlp 12 | except ModuleNotFoundError: 13 | YTDLP = 0 14 | else: 15 | YTDLP = 1 16 | 17 | 18 | class Subtitles: 19 | """ 20 | Refer to "https://github.com/anrayliu/pyvidplayer2/blob/main/documentation.md" for detailed documentation. 21 | """ 22 | 23 | def __init__(self, path: str, colour: Union[str, pygame.Color, Tuple[int, int, int, int]] = "white", 24 | highlight: Union[str, pygame.Color, Tuple[int, int, int, int]] = (0, 0, 0, 128), 25 | font: Union[pygame.font.SysFont, pygame.font.Font] = None, encoding: str = "utf-8", offset: int = 50, 26 | delay: float = 0, youtube: bool = False, pref_lang: str = "en", track_index: int = None) -> None: 27 | 28 | self.path = path 29 | self.track_index = track_index 30 | self.encoding = encoding 31 | 32 | self.youtube = youtube 33 | self.pref_lang = pref_lang 34 | self._auto_cap = False 35 | self.buffer = "" 36 | if youtube: 37 | if YTDLP: 38 | self.buffer = self._extract_youtube_subs() 39 | else: 40 | 41 | raise ModuleNotFoundError("Unable to fetch subtitles because YTDLP is not installed. " 42 | "YTDLP can be installed via pip.") 43 | else: 44 | if not os.path.exists(self.path): 45 | raise FileNotFoundError(f"[Errno 2] No such file or directory: '{self.path}'") 46 | 47 | if track_index is not None: 48 | if not os.path.exists(self.path): 49 | raise FileNotFoundError(f"[Errno 2] No such file or directory: '{self.path}'") 50 | 51 | self.buffer = self._extract_internal_subs() 52 | if self.buffer == "": 53 | raise SubtitleError("Could not find selected subtitle track in video.") 54 | 55 | self._subs = self._load() 56 | 57 | self.start = 0 58 | self.end = 0 59 | self.text = "" 60 | self.surf = pygame.Surface((0, 0)) 61 | self.offset = offset 62 | self.delay = delay 63 | 64 | self.colour = colour 65 | self.highlight = highlight 66 | 67 | self.font = None 68 | self.set_font(pygame.font.SysFont("arial", 30) if font is None else font) 69 | 70 | def __str__(self): 71 | return f"" 72 | 73 | def _load(self): 74 | try: 75 | if self.buffer != "": 76 | return iter(pysubs2.SSAFile.from_string(self.buffer)) 77 | return iter(pysubs2.load(self.path, encoding=self.encoding)) 78 | except (pysubs2.exceptions.FormatAutodetectionError, UnicodeDecodeError): 79 | raise SubtitleError("Could not load subtitles. Unknown format or corrupt file. " 80 | "Check that the proper encoding format is set.") 81 | 82 | def _to_surf(self, text): 83 | h = self.font.get_height() 84 | 85 | lines = text.splitlines() 86 | surfs = [self.font.render(line, True, self.colour) for line in lines] 87 | 88 | surface = pygame.Surface((max([s.get_width() for s in surfs]), len(surfs) * h), pygame.SRCALPHA) 89 | surface.fill(self.highlight) 90 | for i, surf in enumerate(surfs): 91 | surface.blit(surf, (surface.get_width() / 2 - surf.get_width() / 2, i * h)) 92 | 93 | return surface 94 | 95 | def _extract_internal_subs(self): 96 | command = [ 97 | "ffmpeg", 98 | "-i", self.path, 99 | "-loglevel", FFMPEG_LOGLVL, 100 | "-map", f"0:s:{self.track_index}", 101 | "-f", "srt", 102 | "-" 103 | ] 104 | 105 | try: 106 | p = subprocess.Popen(command, stdout=subprocess.PIPE) 107 | except FileNotFoundError: 108 | raise FFmpegNotFoundError("Could not find FFmpeg. Make sure FFmpeg is installed and accessible via PATH.") 109 | 110 | return "\n".join(p.communicate()[0].decode(self.encoding).splitlines()) 111 | 112 | def _extract_youtube_subs(self): 113 | cfg = { 114 | "quiet": True, 115 | "skip_download": True, 116 | "writeautomaticsub": True, 117 | "subtitleslangs": [self.pref_lang], 118 | "subtitlesformat": "vtt" 119 | } 120 | 121 | with yt_dlp.YoutubeDL(cfg) as ydl: 122 | info = ydl.extract_info(self.path, download=False) 123 | 124 | subs = info.get("subtitles", {}) 125 | if not self.pref_lang in subs: 126 | subs = info.get("automatic_captions", {}) 127 | 128 | self._auto_cap = True 129 | 130 | if self.pref_lang in subs: 131 | for i, s in enumerate(subs[self.pref_lang]): 132 | if s["ext"] == "vtt": 133 | return ydl.urlopen(subs[self.pref_lang][i]["url"]).read().decode("utf-8") 134 | else: 135 | raise SubtitleError("Could not find subtitles in the specified language.") 136 | 137 | def _get_next(self): 138 | try: 139 | s = next(self._subs) 140 | except StopIteration: 141 | self.start = 0 + self.delay 142 | self.end = 0 + self.delay 143 | self.text = "" 144 | self.surf = pygame.Surface((0, 0)) 145 | return False 146 | else: 147 | self.start = s.start / 1000 + self.delay 148 | self.end = s.end / 1000 + self.delay 149 | self.text = (re.sub(r"<\b\d+:\d+:\d+(?:\.\d+)?\b>", "", 150 | s.plaintext.split("\n")[1] if "\n" in s.plaintext else s.plaintext).replace( 151 | "[ __ ]", "[__]") if self._auto_cap else s.plaintext).strip() 152 | if self.text != "": 153 | self.surf = self._to_surf(self.text) 154 | return True 155 | 156 | def _seek(self, time): 157 | self._subs = self._load() 158 | 159 | self.end = 0 + self.delay 160 | self.start = 0 + self.delay 161 | self.text = "" 162 | self.surf = pygame.Surface((0, 0)) 163 | 164 | while self.end < time: 165 | if not self._get_next(): 166 | break 167 | 168 | def _write_subs(self, surf): 169 | surf.blit(self.surf, ( 170 | surf.get_width() / 2 - self.surf.get_width() / 2, surf.get_height() - self.surf.get_height() - self.offset)) 171 | 172 | def set_font(self, font: Union[pygame.font.SysFont, pygame.font.Font]) -> None: 173 | """ 174 | Accepts a pygame font object to use to render subtitles. Same as font parameter. 175 | """ 176 | self.font = font 177 | if not isinstance(self.font, pygame.font.Font): 178 | raise ValueError("Font must be a pygame.font.Font or pygame.font.SysFont object.") 179 | 180 | def get_font(self) -> Union[pygame.font.SysFont, pygame.font.Font]: 181 | """ 182 | Gets the pygame font object used to render subtitles. 183 | """ 184 | return self.font 185 | -------------------------------------------------------------------------------- /pyvidplayer2/webcam.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import pygame 3 | import time 4 | import numpy as np 5 | from typing import Callable, Union, Tuple 6 | from .post_processing import PostProcessing 7 | from .error import * 8 | 9 | 10 | class Webcam: 11 | """ 12 | Refer to "https://github.com/anrayliu/pyvidplayer2/blob/main/documentation.md" for detailed documentation. 13 | """ 14 | 15 | def __init__(self, post_process: Callable[[np.ndarray], np.ndarray] = PostProcessing.none, 16 | interp: Union[str, int] = "linear", fps: int = 30, cam_id: int = 0, 17 | capture_size: Tuple[int, int] = (0, 0)) -> None: 18 | self._vid = cv2.VideoCapture(cam_id) 19 | 20 | if not self._vid.isOpened(): 21 | raise WebcamNotFoundError("Failed to find a webcam.") 22 | 23 | self.original_size = (0, 0) 24 | if capture_size == (0, 0): 25 | self.original_size = ( 26 | int(self._vid.get(cv2.CAP_PROP_FRAME_WIDTH)), int(self._vid.get(cv2.CAP_PROP_FRAME_HEIGHT))) 27 | else: 28 | self.resize_capture(capture_size) 29 | 30 | self.current_size = self.original_size 31 | self.aspect_ratio = self.original_size[0] / self.original_size[1] 32 | 33 | self.frame_data = None 34 | self.frame_surf = None 35 | 36 | self.active = False 37 | self.closed = False 38 | 39 | self.post_func = post_process 40 | self.interp = interp 41 | self.fps = fps 42 | self.cam_id = cam_id 43 | 44 | self._frames = 0 45 | self._last_tick = 0 46 | 47 | self.set_interp(self.interp) 48 | 49 | self.play() 50 | 51 | def __str__(self): 52 | return f"" 53 | 54 | def _resize_frame(self, data, size, interp): 55 | return cv2.resize(data, dsize=size, interpolation=interp) 56 | 57 | def _update(self): 58 | if self.active: 59 | 60 | if time.time() - self._last_tick > 1 / self.fps: 61 | self._last_tick = time.time() 62 | 63 | has_frame, data = self._vid.read() 64 | 65 | if has_frame: 66 | if self.original_size != self.current_size: 67 | data = self._resize_frame(data, self.current_size, self.interp) 68 | data = self.post_func(data) 69 | 70 | self.frame_data = data 71 | self.frame_surf = self._create_frame(data) 72 | 73 | self._frames += 1 74 | 75 | return True 76 | 77 | return False 78 | 79 | def update(self) -> bool: 80 | """ 81 | Allows webcam to perform required operations. Draw method already calls this method, so it's usually not used. Returns True if a new frame is ready to be displayed. 82 | """ 83 | return self._update() 84 | 85 | def set_post_func(self, func: Callable[[np.ndarray], np.ndarray]) -> None: 86 | """ 87 | Changes the post processing function. Works the same as the post_func parameter. 88 | """ 89 | self.post_func = func 90 | 91 | def set_interp(self, interp: Union[str, int]) -> None: 92 | """ 93 | Changes the interpolation technique that OpenCV uses. Works the same as the interp parameter. Does nothing if OpenCV is not installed. 94 | """ 95 | # cv2 will always be installed for webcam 96 | 97 | if interp in ("nearest", 0): 98 | self.interp = cv2.INTER_NEAREST 99 | elif interp in ("linear", 1): 100 | self.interp = cv2.INTER_LINEAR 101 | elif interp in ("area", 3): 102 | self.interp = cv2.INTER_AREA 103 | elif interp in ("cubic", 2): 104 | self.interp = cv2.INTER_CUBIC 105 | elif interp in ("lanczos4", 4): 106 | self.interp = cv2.INTER_LANCZOS4 107 | else: 108 | raise ValueError("Interpolation technique not recognized.") 109 | 110 | def play(self) -> None: 111 | """ 112 | Sets video active to True. 113 | """ 114 | self.active = True 115 | 116 | def stop(self) -> None: 117 | """ 118 | Sets video active to False. 119 | """ 120 | self.active = False 121 | self.frame_data = None 122 | self.frame_surf = None 123 | 124 | def resize(self, size: Tuple[int, int]) -> None: 125 | """ 126 | Sets the current size of the video. This will also resize the current frame information, so no need 127 | to buffer a new frame. 128 | """ 129 | self.current_size = size 130 | if self.frame_data is not None: 131 | self.frame_data = self._resize_frame(self.frame_data, self.current_size, self.interp) 132 | self.frame_surf = self._create_frame(self.frame_data) 133 | 134 | def resize_capture(self, size: Tuple[int, int]) -> bool: 135 | """ 136 | Changes the resolution at which frames are captured from the webcam. Returns True if a resolution was found that matched the given size exactly. Otherwise, False will be returned and the closest matching resolution will be used. 137 | """ 138 | self._vid.set(cv2.CAP_PROP_FRAME_WIDTH, size[0]) 139 | self._vid.set(cv2.CAP_PROP_FRAME_HEIGHT, size[1]) 140 | self.original_size = ( 141 | int(self._vid.get(cv2.CAP_PROP_FRAME_WIDTH)), int(self._vid.get(cv2.CAP_PROP_FRAME_HEIGHT))) 142 | return self.original_size == size 143 | 144 | def change_resolution(self, height: int) -> None: 145 | """ 146 | Given a height, webcam will scale its dimensions while maintaining aspect ratio. 147 | Will scale width to an even number. Otherwise same as resize method. 148 | """ 149 | w = int(height * self.aspect_ratio) 150 | if w % 2 == 1: 151 | w += 1 152 | self.resize((w, height)) 153 | 154 | def close(self) -> None: 155 | """ 156 | Releases resources. Always recommended to call when done. Attempting to use video after it has been closed 157 | may cause unexpected behaviour. 158 | """ 159 | self.stop() 160 | self._vid.release() 161 | self.closed = True 162 | 163 | def get_pos(self) -> float: 164 | """ 165 | Returns how long the webcam has been active. Is not reset if webcam is stopped. 166 | """ 167 | return self._frames / self.fps 168 | 169 | def draw(self, surf: pygame.Surface, pos: Tuple[int, int], force_draw: bool = True) -> bool: 170 | """ 171 | Draws the current video frame onto the given surface, at the given position. 172 | If force_draw is True, a surface will be drawn every time this is called. 173 | Otherwise, only new frames will be drawn. 174 | This reduces CPU usage but will cause flickering if anything is drawn under or above the video. 175 | This method also returns whether a frame was drawn. 176 | """ 177 | if (self._update() or force_draw) and self.frame_surf is not None: 178 | self._render_frame(surf, pos) 179 | return True 180 | return False 181 | 182 | def _create_frame(self, data): 183 | return pygame.image.frombuffer(data.tobytes(), self.current_size, "BGR") 184 | 185 | def _render_frame(self, surf, pos): 186 | surf.blit(self.frame_surf, pos) 187 | 188 | def preview(self, max_fps: int = 60) -> None: 189 | """ 190 | Opens a window and plays the webcam. This method will hang until the window is closed. 191 | Videos are played at whatever fps the webcam object is set to. 192 | """ 193 | win = pygame.display.set_mode(self.current_size) 194 | pygame.display.set_caption(f"webcam") 195 | self.play() 196 | clock = pygame.time.Clock() 197 | while self.active: 198 | for event in pygame.event.get(): 199 | if event.type == pygame.QUIT: 200 | self.stop() 201 | clock.tick(max_fps) 202 | self.draw(win, (0, 0), force_draw=False) 203 | pygame.display.update() 204 | pygame.display.quit() 205 | self.close() 206 | -------------------------------------------------------------------------------- /tests/test_subtitles.py: -------------------------------------------------------------------------------- 1 | # test resources: https://github.com/anrayliu/pyvidplayer2-test-resources 2 | # use pip install pyvidplayer2[all] to install all dependencies 3 | 4 | 5 | import unittest 6 | import random 7 | from pyvidplayer2 import * 8 | from test_video import while_loop, timed_loop, check_same_frames 9 | 10 | SUBS = ( 11 | (0.875, 1.71, "Oh, my God!"), 12 | (5.171, 5.88, "Hang on!"), 13 | (6.297, 8.383, "- Ethan!\n- Go, go, go!"), 14 | (8.383, 11.761, "Audiences and critics can't believe\nwhat they're seeing."), 15 | (14.139, 15.015, "Listen to me."), 16 | (15.015, 16.85, "The world's coming after you."), 17 | (16.85, 18.101, "Stay out of my way."), 18 | (18.935, 21.187, "“Tom Cruise has outdone himself.”"), 19 | (22.105, 25.025, "With a 99% on Rotten Tomatoes."), 20 | (25.025, 25.483, "Yes!"), 21 | (25.483, 29.446, "“Mission: Impossible - Dead Reckoning is filled with ‘holy shit’ moments.”"), 22 | (29.446, 30.488, "What is happening?"), 23 | (30.488, 32.49, "“This is why we go to the movies.”"), 24 | (33.908, 35.577, "Oh, I like her."), 25 | (35.577, 37.287, "“It's pulse pounding.”"), 26 | (37.746, 39.622, "“It will rock your world.”"), 27 | (40.081, 41.875, "With “jaw dropping action.”"), 28 | (43.209, 44.085, "Is this where we run?"), 29 | (44.085, 44.919, "Go, go, go, go!"), 30 | (45.253, 45.92, "Probably."), 31 | (46.421, 49.132, "“It's one of the best action movies\never made.”"), 32 | (49.466, 50.508, "What more can I say?"), 33 | (50.925, 54.888, "“See it in the biggest, most seat-shaking theater you can find.”"), 34 | (55.138, 57.891, "“It will take your breath away.”"), 35 | (58.808, 59.893, "Ethan, did you make it?"), 36 | (59.893, 60.852, "Are you okay?") 37 | ) 38 | 39 | 40 | class TestSubtitles(unittest.TestCase): 41 | def test_str_magic_method(self): 42 | s = Subtitles("resources/subs1.srt") 43 | self.assertEqual("", str(s)) 44 | 45 | # tests that subtitle tracks from videos can also be read 46 | def test_embedded_subtitles(self): 47 | s = Subtitles("resources/wSubs.mp4", track_index=0) 48 | 49 | self.assertEqual(s.track_index, 0) 50 | 51 | for i in range(len(SUBS)): 52 | s._get_next() 53 | self.assertEqual(s.start, SUBS[i][0]) 54 | self.assertEqual(s.end, SUBS[i][1]) 55 | self.assertEqual(s.text, SUBS[i][2]) 56 | 57 | # tests minor features of the subtitles for crashes 58 | def test_additional_tests(self): 59 | s1 = Subtitles("resources/subs1.srt", colour="blue", highlight="red", 60 | font=pygame.font.SysFont("arial", 35), offset=70, delay=-1) 61 | Subtitles("resources/subs2.srt", colour=pygame.Color("pink"), highlight=(129, 12, 31, 128), 62 | font=pygame.font.SysFont("arial", 20)) 63 | Subtitles("resources/subs2.srt", colour=(123, 13, 52, 128), highlight=(4, 131, 141, 200), 64 | font=pygame.font.SysFont("arial", 40), delay=1) 65 | Subtitles("resources/subs1.srt", delay=10000) 66 | font = pygame.font.SysFont("arial", 10) 67 | self.assertRaises(ValueError, lambda: s1.set_font(pygame.font.Font)) 68 | s1.set_font(font) 69 | self.assertIs(font, s1.get_font()) 70 | 71 | # tests opening subtitle files with different encodings 72 | def test_subtitle_encoding(self): 73 | self.assertRaises(SubtitleError, lambda: Subtitles("resources/utf16.srt")) 74 | Subtitles("resources/utf16.srt", encoding="utf16") 75 | 76 | # tests appropriate error messages when opening subtitles 77 | def test_open_subtitles(self): 78 | Subtitles("resources/subs1.srt") 79 | 80 | # ffprobe can also read subtitle files 81 | Subtitles("resources/subs1.srt", track_index=0) 82 | 83 | with self.assertRaises(SubtitleError) as context: 84 | Subtitles("resources/subs1.srt", track_index=1) 85 | self.assertEqual(str(context.exception), "Could not find selected subtitle track in video.") 86 | 87 | with self.assertRaises(SubtitleError) as context: 88 | Subtitles("resources/fake.txt") 89 | self.assertEqual(str(context.exception), 90 | "Could not load subtitles. Unknown format or corrupt file. Check that the proper encoding format is set.") 91 | 92 | with self.assertRaises(SubtitleError) as context: 93 | Subtitles("resources/fake.txt", track_index=0) 94 | self.assertEqual(str(context.exception), "Could not find selected subtitle track in video.") 95 | 96 | with self.assertRaises(SubtitleError) as context: 97 | Subtitles("resources/wSubs.mp4") 98 | self.assertEqual(str(context.exception), 99 | "Could not load subtitles. Unknown format or corrupt file. Check that the proper encoding format is set.") 100 | 101 | Subtitles("resources/wSubs.mp4", track_index=0) 102 | 103 | with self.assertRaises(SubtitleError) as context: 104 | Subtitles("resources/wSubs.mp4", track_index=1) 105 | self.assertEqual(str(context.exception), "Could not find selected subtitle track in video.") 106 | 107 | with self.assertRaises(SubtitleError) as context: 108 | Subtitles("resources/trailer1.mp4") 109 | self.assertEqual(str(context.exception), 110 | "Could not load subtitles. Unknown format or corrupt file. Check that the proper encoding format is set.") 111 | 112 | with self.assertRaises(SubtitleError) as context: 113 | Subtitles("resources/trailer1.mp4", track_index=1) 114 | self.assertEqual(str(context.exception), "Could not find selected subtitle track in video.") 115 | 116 | with self.assertRaises(FileNotFoundError): 117 | Subtitles("resources/badpath") 118 | 119 | with self.assertRaises(FileNotFoundError) as context: 120 | Subtitles("resources/badpath", track_index=0) 121 | self.assertEqual(str(context.exception), "[Errno 2] No such file or directory: 'resources/badpath'") 122 | 123 | with self.assertRaises(FileNotFoundError) as context: 124 | Subtitles("https://www.youtube.com/watch?v=HurjfO_TDlQ") 125 | self.assertEqual(str(context.exception), 126 | "[Errno 2] No such file or directory: 'https://www.youtube.com/watch?v=HurjfO_TDlQ'") 127 | 128 | with self.assertRaises(FileNotFoundError) as context: 129 | Subtitles("https://www.youtube.com/watch?v=HurjfO_TDlQ", track_index=0) 130 | self.assertEqual(str(context.exception), 131 | "[Errno 2] No such file or directory: 'https://www.youtube.com/watch?v=HurjfO_TDlQ'") 132 | 133 | # tests that subtitles are properly read and displayed 134 | def test_subtitles(self): 135 | # running video in x6 to speed up test 136 | v = Video("resources/trailer1.mp4", subs=Subtitles("resources/subs1.srt"), speed=6) 137 | 138 | def check_subs(): 139 | v.update() 140 | 141 | timestamp = v._update_time 142 | # skip when frame has not been rendered yet 143 | if v.frame_data is None: 144 | return 145 | 146 | in_interval = False 147 | for start, end, text in SUBS: 148 | if start <= timestamp <= end: 149 | in_interval = True 150 | self.assertEqual(check_same_frames(pygame.surfarray.array3d(v.frame_surf), 151 | pygame.surfarray.array3d(v._create_frame( 152 | v.frame_data))), v.subs_hidden) 153 | 154 | # check the correct subtitle was generated 155 | if not v.subs_hidden: 156 | self.assertTrue(check_same_frames(pygame.surfarray.array3d(v.subs[0]._to_surf(text)), 157 | pygame.surfarray.array3d(v.subs[0].surf))) 158 | 159 | if not in_interval: 160 | self.assertTrue( 161 | check_same_frames(pygame.surfarray.array3d(v.frame_surf), pygame.surfarray.array3d(v._create_frame( 162 | v.frame_data)))) 163 | 164 | self.assertFalse(v.subs_hidden) 165 | 166 | def randomized_test(): 167 | rand = random.randint(1, 3) 168 | if v.subs_hidden: 169 | v.show_subs() 170 | self.assertFalse(v.subs_hidden) 171 | else: 172 | v.hide_subs() 173 | self.assertTrue(v.subs_hidden) 174 | timed_loop(rand, check_subs) 175 | 176 | # test playback while turning on and off subs 177 | while_loop(lambda: v.active, randomized_test, 120) 178 | 179 | # test that seeking works for subtitles 180 | for i in range(3): 181 | v.seek(random.uniform(0, v.duration), relative=False) 182 | v.play() 183 | timed_loop(1, v.update) 184 | 185 | v.close() 186 | 187 | 188 | if __name__ == "__main__": 189 | unittest.main() 190 | -------------------------------------------------------------------------------- /pyvidplayer2/pyaudio_handler.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import pyaudio 3 | import wave 4 | import math 5 | import time 6 | import numpy as np 7 | from threading import Thread 8 | from io import BytesIO 9 | from .error import * 10 | 11 | 12 | class PyaudioHandler: 13 | """A simple wrapper for PyAudio to play streams from memory. 14 | 15 | Attributes: 16 | audio_devices (list[dict]): A list of audio devices where 17 | the index is a valid value for the output_device_index 18 | argument of self.p.open. 19 | preferred_device_names (list[str]): A list of audio device names 20 | that are preferred and will only be used if one exists (in 21 | order from most to least preferred). If none is found, the 22 | first output device will be used (Stutters and works only 23 | intermittently on Ubuntu Studio with pipewire+jack). 24 | NOTE: The "jack" device can only use a specific sample rate, 25 | otherwise "Failed to open stream with selected device 13: 26 | OSError: [Errno -9997] Invalid sample rate (name=jack)" 27 | occurs in self.stream.write. 28 | stream (pyaudio.PyAudio.Stream): The open audio device's output 29 | stream obtained by self.p.open(...). 30 | """ 31 | 32 | def __init__(self): 33 | self.stream = None 34 | self.wave = None 35 | 36 | self.thread = None 37 | self.stop_thread = False 38 | 39 | self.position = 0 40 | self.chunks_played = 0 41 | 42 | self.loaded = False 43 | self.paused = False 44 | self.active = False 45 | 46 | self.volume = 1.0 47 | self.muted = False 48 | 49 | self.p = pyaudio.PyAudio() 50 | self.stream = None 51 | self.audio_devices = [] 52 | self.preferred_device_names = [ 53 | "pulse", 54 | "pipewire", # pipewire stutters with jack on Ubuntu Studio 24.04 55 | # (sample rate related? but QSynth stutters if pipewire is 56 | # selected with 48000 Hz sample rate matching jack) 57 | "default", # no sound & freezes on Ubuntu Studio 24.04 58 | # "sysdefault", # not an output device 59 | ] 60 | self.device_index = self.choose_device() 61 | 62 | self._buffer = None # used for testing purposes 63 | 64 | def _set_device_index(self, index): 65 | try: 66 | self.audio_devices[index] 67 | except IndexError: 68 | raise AudioDeviceError(f"Audio device with index {index} does not exist.") 69 | else: 70 | self.device_index = index 71 | 72 | def get_busy(self): 73 | return self.active 74 | 75 | # def callback(in_data, frame_count, time_info, status): 76 | # # based on 77 | # # 78 | # 79 | # data = self.wave.readframes(frame_count) 80 | # return (data, pyaudio.paContinue) 81 | 82 | def choose_device(self): 83 | device_index = -1 84 | # List available devices 85 | self.refresh_devices() 86 | 87 | for try_name in self.preferred_device_names: 88 | device_index = self.find_device_by_name(try_name) 89 | if device_index != -1: 90 | # warnings.warn("Detected {}".format(try_name)) 91 | break 92 | # if device_index < 0: 93 | # warnings.warn( 94 | # "No preferred device was present: {}" 95 | # .format(self.preferred_device_names)) 96 | 97 | if device_index < 0: 98 | # If no device was present, load the first output device 99 | # (may stutter and fail under pipewire+jack): 100 | for i, info in enumerate(self.audio_devices): 101 | if info["maxOutputChannels"] > 0: 102 | # warnings.warn("- selected (first output device)") 103 | device_index = i 104 | break 105 | 106 | if device_index < 0: 107 | raise AudioDeviceError("No audio devices found.") 108 | 109 | return device_index 110 | 111 | def refresh_devices(self): 112 | self.audio_devices = [] # indices must match output_device_index 113 | for i in range(self.p.get_device_count()): 114 | info = self.p.get_device_info_by_index(i) 115 | self.audio_devices.append(copy.deepcopy(info)) 116 | 117 | # warnings.warn("Device {}: {}".format(i, info['name'])) 118 | 119 | def find_device_by_name(self, name): 120 | if self.audio_devices is None: 121 | raise RuntimeError("find_device_by_name was called before refresh_devices") 122 | for i in range(len(self.audio_devices)): 123 | # self.audio_devices[i] = self.p.get_device_info_by_index(i) 124 | # ^ Commenting this assumes refresh_devices was called 125 | # before devices were added or removed. 126 | info = self.audio_devices[i] 127 | if info["name"] == name: 128 | if info['maxOutputChannels'] > 0: 129 | return i 130 | # else: 131 | # warnings.warn( 132 | # "Warning: preferred device '{}' is invalid" 133 | # " (has no output)".format(info['name'])) 134 | return -1 135 | 136 | def load(self, bytes_): 137 | self.unload() 138 | 139 | try: 140 | self.wave = wave.open(BytesIO(bytes_), "rb") 141 | except EOFError: 142 | raise EOFError( 143 | "Audio is empty. This may mean the file is corrupted." 144 | " If your video has no audio track," 145 | " try initializing it with no_audio=True." 146 | " If it has several tracks, make sure the correct one" 147 | " is selected with the audio_track parameter." 148 | ) 149 | 150 | if self.stream is None: 151 | try: 152 | self.stream = self.p.open( 153 | format=self.p.get_format_from_width( 154 | self.wave.getsampwidth() 155 | ), 156 | channels=self.wave.getnchannels(), 157 | rate=self.wave.getframerate(), 158 | output=True, 159 | output_device_index=self.device_index, 160 | # stream_callback=self.callback, 161 | ) 162 | 163 | except Exception as e: 164 | raise AudioDeviceError("Failed to open audio stream with device \"{}\": {}".format( 165 | self.audio_devices[self.device_index]["name"], e)) 166 | 167 | self.loaded = True 168 | 169 | # only get_num_channels from mixer handler is used for now 170 | # pyaudio channels are handled by video class 171 | def get_num_channels(self): 172 | return self.audio_devices[self.device_index]["maxOutputChannels"] 173 | 174 | def close(self): 175 | if self.stream is not None: 176 | self.stream.stop_stream() 177 | self.stream.close() 178 | self.p.terminate() 179 | 180 | def unload(self): 181 | if self.loaded: 182 | self.stop() 183 | 184 | self.wave.close() 185 | 186 | self.wave = None 187 | self.thread = None 188 | 189 | self.loaded = False 190 | 191 | def play(self): 192 | self.stop_thread = False 193 | self.position = 0 194 | self.chunks_played = 0 195 | self.active = True 196 | 197 | self.wave.rewind() 198 | self.thread = Thread(target=self._threaded_play, daemon=True) 199 | 200 | self.thread.start() 201 | 202 | def _threaded_play(self): 203 | CHUNK_SIZE = 128 # increasing this will reduce get_pos precision 204 | 205 | while not self.stop_thread: 206 | if self.paused: 207 | time.sleep(0.01) 208 | else: 209 | data = self.wave.readframes(CHUNK_SIZE) 210 | if data == b"": 211 | break 212 | 213 | audio = np.frombuffer(data, dtype=np.int16) 214 | 215 | if self.volume == 0.0 or self.muted: 216 | audio = np.zeros_like(audio) 217 | else: 218 | db = 20 * math.log10(self.volume) 219 | audio = (audio * 10 ** (db / 20)).astype(np.int16) # noqa: E226, E501 220 | 221 | self._buffer = audio 222 | 223 | self.stream.write(audio.tobytes()) 224 | 225 | self.chunks_played += CHUNK_SIZE 226 | self.position = self.chunks_played / float(self.wave.getframerate()) 227 | 228 | self.active = False 229 | 230 | def set_volume(self, vol): 231 | self.volume = min(1.0, max(0.0, vol)) 232 | 233 | def get_volume(self): 234 | return self.volume 235 | 236 | def get_pos(self): 237 | return self.position 238 | 239 | def stop(self): 240 | if self.loaded: 241 | self.stop_thread = True 242 | self.thread.join() 243 | self.position = 0 244 | self.chunks_played = 0 245 | 246 | def pause(self): 247 | self.paused = True 248 | 249 | def unpause(self): 250 | self.paused = False 251 | 252 | def mute(self): 253 | self.muted = True 254 | 255 | def unmute(self): 256 | self.muted = False 257 | -------------------------------------------------------------------------------- /tests/test_youtube.py: -------------------------------------------------------------------------------- 1 | # test resources: https://github.com/anrayliu/pyvidplayer2-test-resources 2 | # use pip install pyvidplayer2[all] to install all dependencies 3 | 4 | 5 | import time 6 | import unittest 7 | import unittest.mock 8 | import yt_dlp 9 | import random 10 | from test_subtitles import SUBS 11 | from test_video import while_loop, timed_loop, check_same_frames 12 | from pyvidplayer2 import * 13 | 14 | 15 | def get_youtube_urls(max_results=5): 16 | ydl_opts = { 17 | "quiet": True, 18 | "extract_flat": True, 19 | } 20 | 21 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: 22 | info = ydl.extract_info("https://www.youtube.com/feed/trending", download=False) 23 | 24 | if "entries" in info: 25 | return [entry["url"] for entry in info["entries"] if "view_count" in entry][:max_results] 26 | else: 27 | raise RuntimeError("Could not extract youtube urls.") 28 | 29 | 30 | YOUTUBE_PATH = "https://www.youtube.com/watch?v=K8PoK3533es&t=3s" 31 | 32 | 33 | class TestYoutubeVideo(unittest.TestCase): 34 | # tests that each video is opened properly 35 | def test_metadata(self): 36 | v = Video(YOUTUBE_PATH, youtube=True) 37 | self.assertTrue(v.path.startswith("https")) 38 | self.assertTrue(v._audio_path.startswith("https")) 39 | self.assertEqual(v.name, "The EASIEST Way to Play Videos in Pygame/Python!") 40 | self.assertEqual(v.ext, ".webm") 41 | self.assertEqual(v.duration, 69.36666666666666) 42 | self.assertEqual(v.frame_count, 2081) 43 | self.assertEqual(v.frame_rate, 30.0) 44 | self.assertEqual(v.original_size, (1280, 720)) 45 | self.assertEqual(v.current_size, (1280, 720)) 46 | self.assertEqual(v.aspect_ratio, 1.7777777777777777) 47 | self.assertEqual(type(v._vid).__name__, "CVReader") 48 | v.close() 49 | time.sleep(0.1) 50 | 51 | # tests youtube max_res parameter 52 | def test_max_resolution(self): 53 | for res in (144, 360, 720, 1080): 54 | v = Video(YOUTUBE_PATH, youtube=True, max_res=res) 55 | self.assertEqual(v.current_size[1], res) 56 | v.close() 57 | time.sleep(0.1) # prevents spamming youtube 58 | 59 | # test opens 5 long youtube videos 60 | def test_youtube(self): 61 | urls = ["https://www.youtube.com/watch?v=rfscVS0vtbw", 62 | "https://www.youtube.com/watch?v=PkZNo7MFNFg&t=1115s", 63 | "https://www.youtube.com/watch?v=HXV3zeQKqGY", 64 | "https://www.youtube.com/watch?v=KJgsSFOSQv0&t=1270s", 65 | "https://www.youtube.com/watch?v=vLnPwxZdW4Y"] 66 | for url in urls: 67 | v = Video(url, youtube=True, max_res=360) 68 | self.assertTrue(v._audio_path.startswith("https")) 69 | self.assertTrue(v.path.startswith("https")) 70 | while_loop(lambda: v.get_pos() < 1, v.update, 10) 71 | v.close() 72 | time.sleep(0.1) 73 | 74 | # test that youtube chunk settings are checked 75 | def test_youtube_settings(self): 76 | v = Video(YOUTUBE_PATH, youtube=True, chunk_size=30, max_threads=3, max_chunks=3) 77 | self.assertEqual(v.chunk_size, 60) 78 | self.assertEqual(v.max_threads, 1) 79 | self.assertEqual(v.max_chunks, 3) 80 | v.close() 81 | time.sleep(0.1) 82 | 83 | # tests opening a youtube video with bad paths 84 | def test_open_youtube(self): 85 | with self.assertRaises(YTDLPError) as context: 86 | Video("resources/trailer1.mp4", youtube=True) 87 | self.assertEqual(str(context.exception), 88 | "yt-dlp could not open video. Please ensure the URL is a valid Youtube video.") 89 | time.sleep(0.1) 90 | 91 | with self.assertRaises(YTDLPError) as context: 92 | Video(YOUTUBE_PATH, youtube=True, max_res=0) 93 | self.assertEqual(str(context.exception), "Could not find requested resolution.") 94 | time.sleep(0.1) 95 | 96 | with self.assertRaises(YTDLPError) as context: 97 | Video("https://www.youtube.com/watch?v=thisvideodoesnotexistauwdhoiawdhoiawhdoih", youtube=True) 98 | self.assertEqual(str(context.exception), 99 | "yt-dlp could not open video. Please ensure the URL is a valid Youtube video.") 100 | time.sleep(0.1) 101 | 102 | # tests that youtube videos do not hang when close is called 103 | def test_hanging(self): 104 | v = Video(YOUTUBE_PATH, youtube=True, max_threads=10, max_chunks=10) 105 | t = time.time() 106 | v.close() 107 | self.assertLess(time.time() - t, 0.1) 108 | time.sleep(0.1) 109 | 110 | # tests that youtube videos can be played in reverse 111 | def test_reverse(self): 112 | v = Video(YOUTUBE_PATH, reverse=True, youtube=True) 113 | for i, frame in enumerate(v): 114 | self.assertTrue(check_same_frames(frame, v._preloaded_frames[v.frame_count - i - 1])) 115 | v.close() 116 | time.sleep(0.1) 117 | 118 | # tests for errors for unsupported youtube links 119 | def test_bad_youtube_links(self): 120 | for url in ( 121 | "https://www.youtube.com/@joewoobie1155", "https://www.youtube.com/channel/UCY3Rgenpuy4cY79eGk6DmuA", 122 | "https://www.youtube.com/", "https://www.youtube.com/shorts"): 123 | with self.assertRaises(YTDLPError): 124 | Video(url, youtube=True).close() 125 | time.sleep(0.1) 126 | 127 | # tests that nothing crashes when selecting different languages with Youtube 128 | def test_youtube_language_tracks(self): 129 | for lang in (None, "en-US", "fr-FR", "es-US", "it", "pt-BR", "de-DE", "badcode"): 130 | v = Video("https://www.youtube.com/watch?v=v4H2fTgHGuc", youtube=True, pref_lang=lang) 131 | timed_loop(3, v.update) 132 | v.close() 133 | time.sleep(0.1) 134 | 135 | # tests appropriate error messages when opening subtitles 136 | def test_open_subtitles(self): 137 | with self.assertRaises(SubtitleError) as context: 138 | Subtitles("https://www.youtube.com/watch?v=HurjfO_TDlQ", youtube=True) 139 | self.assertEqual(str(context.exception), "Could not find subtitles in the specified language.") 140 | time.sleep(0.1) 141 | 142 | s = Subtitles("https://www.youtube.com/watch?v=HurjfO_TDlQ", youtube=True, pref_lang="en-US") 143 | self.assertFalse(s._auto_cap) 144 | time.sleep(0.1) 145 | 146 | s = Subtitles("https://www.youtube.com/watch?v=HurjfO_TDlQ", youtube=True, pref_lang="zh-Hant-en-US") 147 | self.assertTrue(s._auto_cap) 148 | time.sleep(0.1) 149 | 150 | with self.assertRaises(SubtitleError) as context: 151 | Subtitles("https://www.youtube.com/watch?v=HurjfO_TDlQ", youtube=True, pref_lang="badcode") 152 | self.assertEqual(str(context.exception), "Could not find subtitles in the specified language.") 153 | time.sleep(0.1) 154 | 155 | with self.assertRaises(SubtitleError) as context: 156 | Subtitles(YOUTUBE_PATH, youtube=True, pref_lang="badcode") 157 | self.assertEqual(str(context.exception), "Could not find subtitles in the specified language.") 158 | time.sleep(0.1) 159 | 160 | # ffprobe can read extracted subtitle file 161 | Subtitles("https://www.youtube.com/watch?v=HurjfO_TDlQ", youtube=True, track_index=0, pref_lang="en-US") 162 | time.sleep(0.1) 163 | 164 | for url in ( 165 | "https://www.youtube.com/@joewoobie1155", "https://www.youtube.com/channel/UCY3Rgenpuy4cY79eGk6DmuA", 166 | "https://www.youtube.com/"): 167 | with self.assertRaises(SubtitleError) as context: 168 | Subtitles(url, youtube=True) 169 | self.assertEqual(str(context.exception), "Could not find subtitles in the specified language.") 170 | time.sleep(0.1) 171 | 172 | with self.assertRaises(yt_dlp.utils.DownloadError): 173 | Subtitles("https://www.youtube.com/shorts", youtube=True) 174 | time.sleep(0.1) 175 | 176 | # tests __str__ 177 | def test_str_magic_method(self): 178 | v = Video(YOUTUBE_PATH, youtube=True) 179 | self.assertEqual("", str(v)) 180 | v.close() 181 | time.sleep(0.1) 182 | 183 | # tests that subtitles are properly read and displayed 184 | def test_subtitles(self): 185 | # running video in x5 to speed up test 186 | v = Video("https://www.youtube.com/watch?v=HurjfO_TDlQ", 187 | subs=Subtitles("https://www.youtube.com/watch?v=HurjfO_TDlQ", youtube=True, pref_lang="en-US"), 188 | speed=5, youtube=True) 189 | 190 | def check_subs(): 191 | if v.update(): 192 | timestamp = v._update_time 193 | # skip when frame has not been rendered yet 194 | if timestamp == 0: 195 | return 196 | 197 | in_interval = False 198 | for start, end, text in SUBS: 199 | if start <= timestamp <= end: 200 | in_interval = True 201 | self.assertEqual(check_same_frames(pygame.surfarray.array3d(v.frame_surf), 202 | pygame.surfarray.array3d(v._create_frame( 203 | v.frame_data))), v.subs_hidden) 204 | 205 | # check the correct subtitle was generated 206 | if not v.subs_hidden: 207 | self.assertTrue(check_same_frames(pygame.surfarray.array3d(v.subs[0]._to_surf(text)), 208 | pygame.surfarray.array3d(v.subs[0].surf))) 209 | 210 | break 211 | 212 | if not in_interval: 213 | self.assertTrue( 214 | check_same_frames(pygame.surfarray.array3d(v.frame_surf), 215 | pygame.surfarray.array3d(v._create_frame( 216 | v.frame_data)))) 217 | 218 | self.assertFalse(v.subs_hidden) 219 | 220 | def randomized_test(): 221 | rand = random.randint(1, 3) 222 | if v.subs_hidden: 223 | v.show_subs() 224 | self.assertFalse(v.subs_hidden) 225 | else: 226 | v.hide_subs() 227 | self.assertTrue(v.subs_hidden) 228 | timed_loop(rand, check_subs) 229 | 230 | # test playback while turning on and off subs 231 | while_loop(lambda: v.active, randomized_test, 120) 232 | v.close() 233 | time.sleep(0.1) 234 | 235 | # tests forcing reader to be ffmepg 236 | def test_force_ffmpeg(self): 237 | v = Video(YOUTUBE_PATH, youtube=True) 238 | timed_loop(2, v.update) 239 | v._force_ffmpeg_reader() 240 | self.assertEqual(type(v._vid).__name__, "FFMPEGReader") 241 | self.assertEqual(v._vid.frame, v.frame) 242 | timed_loop(2, v.update) 243 | v.close() 244 | 245 | # tests forced and auto selection of readers for youtube 246 | def test_youtube_readers(self): 247 | v = Video(YOUTUBE_PATH, youtube=True) 248 | self.assertEqual(type(v._vid).__name__, "CVReader") 249 | 250 | # using _get_best_reader instead of creating Video objects 251 | # to reduce network spam 252 | 253 | # test for exceptions here 254 | # youtube = True, as_bytes = False, reader = READER_AUTO 255 | v._get_best_reader(True, False, READER_AUTO) 256 | v._get_best_reader(True, False, READER_OPENCV) 257 | 258 | with self.assertRaises(ValueError): 259 | v._get_best_reader(True, False, READER_FFMPEG) 260 | with self.assertRaises(ValueError): 261 | v._get_best_reader(True, False, READER_IMAGEIO) 262 | with self.assertRaises(ValueError): 263 | v._get_best_reader(True, False, READER_IMAGEIO) 264 | 265 | with unittest.mock.patch("pyvidplayer2.video.CV", 0): 266 | with self.assertRaises(ValueError) as context: 267 | Video(YOUTUBE_PATH, youtube=True) 268 | self.assertEqual(str(context.exception), 269 | "Only READER_OPENCV is supported for Youtube videos.") 270 | 271 | v.close() 272 | 273 | 274 | if __name__ == "__main__": 275 | unittest.main() 276 | -------------------------------------------------------------------------------- /pyvidplayer2/video_player.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | import math 3 | from typing import Tuple, Union, List 4 | from . import Video 5 | from .error import * 6 | from .video_pygame import VideoPygame 7 | 8 | 9 | class VideoPlayer: 10 | """ 11 | Refer to "https://github.com/anrayliu/pyvidplayer2/blob/main/documentation.md" for detailed documentation. 12 | """ 13 | 14 | def __init__(self, video: Video, rect: Tuple[int, int, int, int], interactable: bool = False, loop: bool = False, 15 | preview_thumbnails: int = 0, font_size: int = 10): 16 | self.video = video 17 | if isinstance(self.video, VideoPygame): 18 | if self.video.closed: 19 | raise VideoStreamError("Provided video is closed.") 20 | self.video._buffer_first_chunk = loop 21 | else: 22 | raise ValueError("Must be a VideoPygame object.") 23 | 24 | self.frame_rect = pygame.Rect(rect) 25 | self.interactable = interactable 26 | self.loop = loop 27 | self.preview_thumbnails = min(max(preview_thumbnails, 0), self.video.frame_count) 28 | self._show_intervals = self.preview_thumbnails != 0 29 | 30 | self.vid_rect = pygame.Rect(0, 0, 0, 0) 31 | self._progress_back = pygame.Rect(0, 0, 0, 0) 32 | self._progress_bar = pygame.Rect(0, 0, 0, 0) 33 | self._smooth_bar = 0 # used for making the progress bar look smooth when seeking 34 | self._font = pygame.font.SysFont("arial", font_size) 35 | 36 | self._buffer_rect = pygame.Rect(0, 0, 0, 0) 37 | self._buffer_angle = 0 38 | 39 | self._zoomed = False 40 | 41 | self._transform(self.frame_rect) 42 | 43 | self._show_ui = False 44 | self.queue_ = [] 45 | 46 | self._clock = pygame.time.Clock() 47 | 48 | self._seek_pos = 0 49 | self._seek_time = 0 50 | self._show_seek = False 51 | 52 | self._fade_timer = 0 53 | 54 | if self._show_intervals: 55 | self._interval = self.video.duration / self.preview_thumbnails 56 | self._interval_frames = [] 57 | self._get_interval_frames() 58 | 59 | self.closed = False 60 | 61 | def __str__(self): 62 | return f"" 63 | 64 | def __len__(self): 65 | return len(self.queue_) + 1 66 | 67 | def __enter__(self): 68 | return self 69 | 70 | def __exit__(self, type_, value, traceback): 71 | self.close() 72 | 73 | def _close_queue(self): 74 | for video in self.queue_: 75 | try: 76 | video.close() 77 | except AttributeError: 78 | pass 79 | 80 | def _get_interval_frames(self): 81 | size = (int(70 * self.video.aspect_ratio), 70) 82 | 83 | self._interval_frames.clear() 84 | frame = self.video._vid.frame 85 | 86 | for i in range(self.preview_thumbnails): 87 | if self.video._preloaded: 88 | data = self.video._preloaded_frames[int(i * self.video.frame_rate * self._interval)] 89 | else: 90 | self.video._vid.seek(int(i * self.video.frame_rate * self._interval)) 91 | data = self.video._vid.read()[1] 92 | 93 | self._interval_frames.append( 94 | pygame.image.frombuffer(self.video._resize_frame(data, size, "fast_bilinear", True).tobytes(), size, 95 | self.video._vid._colour_format)) 96 | 97 | # add last readable frame 98 | 99 | if self.video._preloaded: 100 | self._interval_frames.append(pygame.image.frombuffer( 101 | self.video._resize_frame(self.video._preloaded_frames[-1], size, "fast_bilinear", True).tobytes(), size, 102 | self.video._vid._colour_format)) 103 | else: 104 | i = 1 105 | while True: 106 | self.video._vid.seek(self.video.frame_count - i) 107 | try: 108 | self._interval_frames.append(pygame.image.frombuffer( 109 | self.video._resize_frame(self.video._vid.read()[1], size, "fast_bilinear", True).tobytes(), 110 | size, self.video._vid._colour_format)) 111 | except: 112 | i += 1 113 | else: 114 | break 115 | 116 | self.video._vid.seek(frame) 117 | 118 | def _get_closest_frame(self, time): 119 | return self._interval_frames[round(time / self._interval)] 120 | 121 | def _transform(self, rect): 122 | self.frame_rect = rect 123 | self.zoom_out() 124 | 125 | self._progress_back = pygame.Rect(self.frame_rect.x + 10, self.frame_rect.bottom - 25, self.frame_rect.w - 20, 126 | 15) 127 | self._progress_bar = self._progress_back.copy() 128 | 129 | self._buffer_rect = pygame.Rect(0, 0, 200, 200) 130 | self._buffer_rect.center = self.frame_rect.center 131 | 132 | def _move_angle(self, pos, angle, distance): 133 | return pos[0] + math.cos(angle) * distance, pos[1] + math.sin(angle) * distance 134 | 135 | def _convert_seconds(self, time): 136 | return self.video._convert_seconds(time).split(".")[0] 137 | 138 | # takes a rect and an aspect ratio, returns the largest rect of the aspect ratio that can be fit inside 139 | 140 | def _best_fit(self, rect, r): 141 | s = rect.size 142 | 143 | w = s[0] 144 | h = int(w / r) 145 | y = int(s[1] / 2 - h / 2) 146 | x = 0 147 | if h > s[1]: 148 | h = s[1] 149 | w = int(h * r) 150 | x = int(s[0] / 2 - w / 2) 151 | y = 0 152 | 153 | return pygame.Rect(rect.x + x, rect.y + y, w, h) 154 | 155 | # called after a video is finished playing 156 | def _handle_on_end(self): 157 | if self.queue_: 158 | if self.loop: 159 | self.queue(self.video) 160 | else: 161 | self.video.close() 162 | input_ = self.queue_.pop(0) 163 | if isinstance(input_, Video): 164 | self.video = input_ 165 | self.video.play() 166 | else: 167 | self.video = Video(input_) 168 | self._transform(self.frame_rect) 169 | elif self.loop: 170 | self.video.restart() 171 | 172 | def zoom_to_fill(self) -> None: 173 | s = max(abs(self.frame_rect.w - self.vid_rect.w), abs(self.frame_rect.h - self.vid_rect.h)) 174 | self.vid_rect.inflate_ip(s, s) 175 | self.vid_rect.center = self.frame_rect.center # adjusts for 1.0 rounding imprecisions 176 | self.video.resize(self.vid_rect.size) 177 | self._zoomed = True 178 | 179 | def zoom_out(self) -> None: 180 | self.vid_rect = self._best_fit(self.frame_rect, self.video.aspect_ratio) 181 | self.vid_rect.center = self.frame_rect.center # adjusts for 1.0 rounding imprecisions 182 | self.video.resize(self.vid_rect.size) 183 | self._zoomed = False 184 | 185 | def toggle_zoom(self) -> None: 186 | if self._zoomed: 187 | self.zoom_out() 188 | else: 189 | self.zoom_to_fill() 190 | 191 | def queue(self, input_: Union[str, Video]) -> None: 192 | if type(input_) != str and not isinstance(input_, Video): 193 | raise ValueError("Can only queue video paths or video objects.") 194 | 195 | self.queue_.append(input_) 196 | 197 | # update once to trigger audio loading 198 | try: 199 | input_.stop() 200 | input_._update() 201 | except AttributeError: 202 | pass 203 | 204 | def enqueue(self, input_: Union[str, Video]) -> None: 205 | self.queue(input_) 206 | 207 | def resize(self, size: Tuple[int, int]) -> None: 208 | self.frame_rect.size = size 209 | self._transform(self.frame_rect) 210 | 211 | def move(self, pos: Tuple[int, int], relative: bool = False) -> None: 212 | if relative: 213 | self.frame_rect.move_ip(*pos) 214 | else: 215 | self.frame_rect.topleft = pos 216 | self._transform(self.frame_rect) 217 | 218 | def update(self, events: List[pygame.event.Event] = None, show_ui: bool = None, fps: int = 0) -> bool: 219 | dt = self._clock.tick(fps) 220 | 221 | self.video.update() 222 | 223 | if not self.video.active: 224 | self._handle_on_end() 225 | 226 | if self.interactable: 227 | 228 | mouse = pygame.mouse.get_pos() 229 | click = False 230 | for event in events: 231 | if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: 232 | click = True 233 | 234 | self._show_ui = self.frame_rect.collidepoint(mouse) if show_ui is None else show_ui 235 | 236 | if self._show_ui: 237 | self._progress_bar.w = self._progress_back.w * (self.video.get_pos() / self.video.duration) 238 | self._smooth_bar += (self._progress_bar.w - self._smooth_bar) * (dt / 100) 239 | self._show_seek = self._progress_back.collidepoint(mouse) 240 | 241 | if self._show_seek: 242 | t = (self._progress_back.w - (self._progress_back.right - mouse[0])) * ( 243 | self.video.duration / self._progress_back.w) 244 | 245 | self._seek_pos = self._progress_back.w * (round(t, 1) / self.video.duration) + self._progress_back.x 246 | self._seek_time = t 247 | 248 | if click: 249 | self.video.seek(t, relative=False) 250 | self.video.play() 251 | self._clock.tick() # resets delta time 252 | 253 | elif click: 254 | self.video.toggle_pause() 255 | 256 | self._buffer_angle += dt / 10 257 | 258 | return self._show_ui 259 | 260 | def draw(self, win: pygame.Surface) -> None: 261 | pygame.draw.rect(win, "black", self.frame_rect) 262 | buffer = self.video.frame_surf 263 | if buffer is not None: 264 | if self._zoomed: 265 | win.blit(buffer, self.frame_rect.topleft, ( 266 | self.frame_rect.x - self.vid_rect.x, self.frame_rect.y - self.vid_rect.y, *self.frame_rect.size)) 267 | else: 268 | win.blit(buffer, self.vid_rect.topleft) 269 | 270 | if self._show_ui: 271 | pygame.draw.line(win, (50, 50, 50), (self._progress_back.x, self._progress_back.centery), 272 | (self._progress_back.right, self._progress_back.centery), 5) 273 | if self._smooth_bar > 1: 274 | pygame.draw.line(win, "white", (self._progress_bar.x, self._progress_bar.centery), 275 | (self._progress_bar.x + self._smooth_bar, self._progress_bar.centery), 5) 276 | 277 | f = self._font.render(self.video.name, True, "white") 278 | win.blit(f, (self.frame_rect.x + 10, self.frame_rect.y + 10)) 279 | 280 | f = self._font.render(self._convert_seconds(self.video.get_pos()), True, "white") 281 | win.blit(f, (self.frame_rect.x + 10, self._progress_bar.top - f.get_height() - 10)) 282 | 283 | if self._show_seek: 284 | pygame.draw.line(win, "white", (self._seek_pos, self._progress_back.top), 285 | (self._seek_pos, self._progress_back.bottom), 2) 286 | 287 | f = self._font.render(self._convert_seconds(self._seek_time), True, "white") 288 | win.blit(f, (self._seek_pos - f.get_width() // 2, self._progress_back.y - 10 - f.get_height())) 289 | 290 | if self._show_intervals: 291 | surf = self._get_closest_frame(self._seek_time) 292 | x = self._seek_pos - surf.get_width() // 2 293 | x = min(max(x, self.frame_rect.x), self.frame_rect.right - surf.get_width()) 294 | pygame.draw.rect(win, (0, 0, 0), ( 295 | x - 2, self._progress_back.y - 80 - f.get_height() - 2, surf.get_width() + 4, 296 | surf.get_height() + 4), 2) 297 | win.blit(surf, (x, self._progress_back.y - 80 - f.get_height())) 298 | 299 | if self.interactable: 300 | if self.video.buffering: 301 | for i in range(6): 302 | a = math.radians(self._buffer_angle + i * 60) 303 | pygame.draw.line(win, "white", self._move_angle(self.frame_rect.center, a, 10), 304 | self._move_angle(self.frame_rect.center, a, 30)) 305 | elif self.video.paused: 306 | pygame.draw.rect(win, "white", (self.frame_rect.centerx - 15, self.frame_rect.centery - 20, 10, 40)) 307 | pygame.draw.rect(win, "white", (self.frame_rect.centerx + 5, self.frame_rect.centery - 20, 10, 40)) 308 | 309 | def close(self) -> None: 310 | self.video.close() 311 | self._close_queue() 312 | self.closed = True 313 | 314 | def skip(self) -> None: 315 | if self.queue_: 316 | self.video.stop() if self.loop else self.video.close() 317 | self._handle_on_end() 318 | 319 | def get_next(self) -> Union[str, Video]: 320 | return self.queue_[0] if self.queue_ else None 321 | 322 | def clear_queue(self) -> None: 323 | self._close_queue() 324 | self.queue_.clear() 325 | 326 | def get_video(self) -> Video: 327 | return self.video 328 | 329 | def get_queue(self) -> List[Union[str, Video]]: 330 | return self.queue_ 331 | 332 | def preview(self, max_fps: int = 60): 333 | win = pygame.display.set_mode(self.frame_rect.size, pygame.RESIZABLE) 334 | pygame.display.set_caption(f"videoplayer - {self.video.name}") 335 | self.video.play() 336 | stop = False 337 | while self.video.active and not stop: 338 | events = pygame.event.get() 339 | for event in events: 340 | if event.type == pygame.QUIT: 341 | self.video.stop() 342 | stop = True 343 | elif event.type == pygame.WINDOWRESIZED: 344 | self.resize(win.get_size()) 345 | elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE: 346 | self.toggle_zoom() 347 | self.update(events, True, max_fps) 348 | self.draw(win) 349 | pygame.display.update() 350 | pygame.display.quit() 351 | self.close() 352 | -------------------------------------------------------------------------------- /tests/test_video_player.py: -------------------------------------------------------------------------------- 1 | # test resources: https://github.com/anrayliu/pyvidplayer2-test-resources 2 | # use pip install pyvidplayer2[all] to install all dependencies 3 | 4 | 5 | import random 6 | import time 7 | from threading import Thread 8 | from test_video import VIDEO_PATH, while_loop, timed_loop, check_same_frames 9 | import unittest 10 | from pyvidplayer2 import * 11 | 12 | 13 | class TestVideoPlayer(unittest.TestCase): 14 | # tests that a video can be played entirely in a video player 15 | def test_full_player(self): 16 | v = Video("resources/clip.mp4") 17 | vp = VideoPlayer(v, (0, 0, *v.original_size)) 18 | while_loop(lambda: v.active, vp.update, 10) 19 | vp.close() 20 | self.assertTrue(vp.closed) 21 | 22 | # tests zoom out and zoom to fill 23 | def test_zoom(self): 24 | for i in range(100): 25 | pos = (random.randint(0, 2000), random.randint(0, 2000)) 26 | size = (random.randint(100, 2000), random.randint(100, 2000)) 27 | vp = VideoPlayer(Video(VIDEO_PATH), (*pos, *size)) 28 | 29 | original_vid_rect = vp.vid_rect.copy() 30 | original_frame_rect = vp.frame_rect.copy() 31 | 32 | self.assertTrue(vp.vid_rect.w == vp.frame_rect.w or vp.vid_rect.h == vp.frame_rect.h) 33 | self.assertEqual(vp.vid_rect.center, vp.frame_rect.center) 34 | self.assertFalse(vp._zoomed) 35 | vp.zoom_to_fill() 36 | self.assertGreaterEqual(vp.vid_rect.w, vp.frame_rect.w) 37 | self.assertGreaterEqual(vp.vid_rect.h, vp.frame_rect.h) 38 | self.assertEqual(vp.vid_rect.center, vp.frame_rect.center) 39 | self.assertTrue(vp._zoomed) 40 | vp.toggle_zoom() 41 | self.assertFalse(vp._zoomed) 42 | vp.toggle_zoom() 43 | self.assertTrue(vp._zoomed) 44 | vp.zoom_to_fill() 45 | vp.zoom_out() 46 | vp.zoom_out() 47 | 48 | self.assertEqual(vp.vid_rect, original_vid_rect) 49 | self.assertEqual(vp.frame_rect, original_frame_rect) 50 | 51 | vp.close() 52 | 53 | # tests default video player 54 | def test_open_video_player(self): 55 | v = Video(VIDEO_PATH) 56 | vp = VideoPlayer(v, (0, 0, *v.original_size)) 57 | self.assertIs(vp.video, v) 58 | self.assertIs(vp.get_video(), v) 59 | self.assertEqual(vp.vid_rect, pygame.Rect(0, 0, v.original_size[0], v.original_size[1])) 60 | self.assertEqual(vp.frame_rect, pygame.Rect(0, 0, v.original_size[0], v.original_size[1])) 61 | self.assertFalse(vp.interactable) 62 | self.assertFalse(vp.loop) 63 | self.assertEqual(vp.preview_thumbnails, 0) 64 | self.assertEqual(vp._font.get_height(), 12) # point size 10 should result in 12 height 65 | vp.close() 66 | 67 | # tests queue system 68 | def test_queue(self): 69 | original_video = Video("resources/clip.mp4") 70 | 71 | vp = VideoPlayer(original_video, (0, 0, *original_video.original_size)) 72 | self.assertEqual(len(vp.queue_), 0) 73 | self.assertIs(vp.get_queue(), vp.queue_) 74 | 75 | self.assertIs(vp.get_next(), None) 76 | 77 | v1 = Video("resources/trailer1.mp4") 78 | v2 = Video("resources/ocean.mkv") 79 | v3 = Video("resources/medic.mov") 80 | v4 = Video("resources/birds.avi") 81 | 82 | # v1 is not loaded when it is created 83 | self.assertTrue(v1.active) 84 | self.assertEqual(len(v1._chunks), 0) 85 | 86 | vp.queue(v1) 87 | 88 | # v1 is now loading after queueing happens 89 | self.assertFalse(v1.active) 90 | self.assertEqual(len(v1._chunks), 1) 91 | self.assertEqual(v1._chunks_len(v1._chunks), 0) 92 | 93 | vp.queue(v2) 94 | vp.queue(v3) 95 | vp.queue(v4) 96 | 97 | self.assertEqual(len(vp.queue_), 4) 98 | self.assertEqual(len(vp), 5) 99 | 100 | self.assertIs(vp.get_next(), v1) 101 | 102 | # play first clip in its entirety 103 | timed_loop(8, vp.update) 104 | 105 | self.assertIs(vp.video, v1) 106 | self.assertEqual(len(vp.queue_), 3) 107 | self.assertEqual(len(vp), 4) 108 | self.assertTrue(original_video.closed) 109 | 110 | vp.skip() 111 | 112 | self.assertIs(vp.video, v2) 113 | self.assertEqual(len(vp.queue_), 2) 114 | self.assertTrue(v1.closed) 115 | 116 | vp.skip() # should be on v3 after skip 117 | vp.skip() # should be on v4 after skip 118 | 119 | self.assertIs(vp.video, v4) 120 | self.assertEqual(len(vp), 1) 121 | self.assertIs(vp.get_next(), None) 122 | 123 | # shouldn't do anything 124 | for i in range(10): 125 | vp.skip() 126 | 127 | self.assertTrue(v4.active) 128 | 129 | vp.close() 130 | self.assertTrue(vp.video.closed) 131 | self.assertEqual(len(vp), 1) 132 | self.assertEqual(len(vp.queue_), 0) 133 | for v in (v1, v2, v3, v4): 134 | self.assertTrue(v.closed) 135 | 136 | # test enqueue, a queue alias 137 | def test_enqueue(self): 138 | original_video = Video("resources/clip.mp4") 139 | 140 | vp = VideoPlayer(original_video, (0, 0, *original_video.original_size)) 141 | v1 = Video("resources/trailer1.mp4") 142 | vp.enqueue(v1) 143 | self.assertEqual(len(vp.queue_), 1) 144 | self.assertIs(vp.get_next(), v1) 145 | 146 | vp.close() 147 | 148 | # tests video player with context manager 149 | def test_context_manager(self): 150 | with VideoPlayer(Video(VIDEO_PATH), (0, 0, 1280, 720)) as vp: 151 | self.assertFalse(vp.closed) 152 | self.assertFalse(vp.get_video().closed) 153 | self.assertTrue(vp.closed) 154 | self.assertTrue(vp.get_video().closed) 155 | 156 | # tests queue system with loop 157 | def test_queue_loop(self): 158 | original_video = Video("resources/trailer1.mp4") 159 | v1 = Video("resources/trailer2.mp4") 160 | v2 = Video("resources/clip.mp4") 161 | 162 | vp = VideoPlayer(original_video, (0, 0, *original_video.original_size), loop=True) 163 | vp.queue(v1) 164 | vp.queue(v2) 165 | 166 | vp.skip() 167 | vp.skip() 168 | 169 | self.assertIs(vp.get_video(), v2) 170 | self.assertFalse(v1.active) 171 | self.assertFalse(original_video.active) 172 | self.assertEqual(vp.queue_, [original_video, v1]) 173 | 174 | # play first clip in its entirety 175 | timed_loop(8, vp.update) 176 | 177 | self.assertIs(vp.video, original_video) 178 | self.assertEqual(len(vp.queue_), 2) 179 | self.assertEqual(len(vp), 3) 180 | 181 | # queue an incorrect argument 182 | with self.assertRaises(ValueError): 183 | vp.queue(1) 184 | 185 | # manually wipe queue 186 | vp.clear_queue() 187 | for v in (v1, v2): 188 | self.assertTrue(v.closed) 189 | self.assertEqual(len(vp.queue_), 0) 190 | 191 | vp.queue("bad path") 192 | with self.assertRaises(FileNotFoundError): 193 | vp.skip() 194 | 195 | original_video.stop() 196 | vp.update() # should trigger _handle_on_end 197 | self.assertEqual(original_video.get_pos(), 0) 198 | self.assertTrue(original_video.active) 199 | 200 | vp.close() 201 | 202 | # tests the move method video player 203 | def test_move_video_player(self): 204 | v = Video(VIDEO_PATH) 205 | vp = VideoPlayer(v, (0, 0, *v.original_size)) 206 | 207 | vid_pos = vp.vid_rect.topleft 208 | vid_size = vp.vid_rect.size 209 | 210 | vp.move((10, 10)) 211 | self.assertEqual(vp.frame_rect.topleft, (10, 10)) 212 | 213 | # ensure default setting is not relative 214 | vp.move((10, 10)) 215 | self.assertEqual(vp.frame_rect.topleft, (10, 10)) 216 | 217 | vp.move((10, 10), relative=True) 218 | self.assertEqual(vp.frame_rect.topleft, (20, 20)) 219 | 220 | # ensures that vid rect was properly changed 221 | self.assertNotEqual(vp.vid_rect.topleft, vid_pos) 222 | self.assertEqual(vp.vid_rect.size, vid_size) 223 | 224 | vp.close() 225 | 226 | # tests queueing with video paths instead of objects 227 | def test_queue_str_path(self): 228 | original_video = Video(VIDEO_PATH) 229 | vp = VideoPlayer(original_video, (0, 0, *original_video.original_size), loop=True) 230 | 231 | vp.queue("resources/trailer2.mp4") 232 | vp.queue("resources/clip.mp4") 233 | 234 | self.assertEqual(vp.queue_, ["resources/trailer2.mp4", "resources/clip.mp4"]) 235 | 236 | vp.skip() 237 | vp.skip() 238 | 239 | self.assertEqual(vp.queue_[0].name, "trailer1") 240 | self.assertEqual(vp.queue_[1].name, "trailer2") 241 | 242 | vp.clear_queue() 243 | vp.queue("badpath") 244 | self.assertRaises(FileNotFoundError, vp.skip) 245 | vp.close() 246 | 247 | # tests that each preview thumbnail is read 248 | def test_preview_thumbnails(self): 249 | original_video = Video("resources/clip.mp4") 250 | 251 | # test that preview thumbnail loading does not change vid frame pointer 252 | self.assertEqual(original_video._vid._vidcap.get(cv2.CAP_PROP_POS_FRAMES), 0) 253 | vp = VideoPlayer(original_video, (0, 0, *original_video.original_size), preview_thumbnails=30) 254 | self.assertEqual(original_video._vid._vidcap.get(cv2.CAP_PROP_POS_FRAMES), 0) 255 | 256 | self.assertEqual(len(vp._interval_frames), 31) 257 | self.assertEqual(vp.video._vid.frame, 0) 258 | 259 | viewed_thumbnails = [] 260 | for i in range(int(original_video.duration * 10)): 261 | thumbnail = vp._get_closest_frame(i * 0.1) 262 | if not thumbnail in viewed_thumbnails: 263 | viewed_thumbnails.append(thumbnail) 264 | 265 | # ensures that when preloaded, the preview thumbnails are taken straight from the preloaded frames 266 | original_video._preload_frames() 267 | t = Thread( 268 | target=lambda: VideoPlayer(original_video, (0, 0, *original_video.original_size), preview_thumbnails=300)) 269 | t.start() 270 | time.sleep(10) 271 | self.assertFalse(t.is_alive()) 272 | 273 | # checks that loaded preview thumbnails from both methods produce the same frames 274 | vp2 = VideoPlayer(original_video, (0, 0, *original_video.original_size), preview_thumbnails=30) 275 | for f1, f2 in zip(vp._interval_frames, vp2._interval_frames): 276 | self.assertTrue(check_same_frames(pygame.surfarray.array3d(f1), pygame.surfarray.array3d(f2))) 277 | 278 | self.assertEqual(original_video._vid._vidcap.get(cv2.CAP_PROP_POS_FRAMES), 0) 279 | 280 | # tests the _best_fit method for videoplayer 281 | def test_best_fit(self): 282 | vp = VideoPlayer(Video(VIDEO_PATH), (0, 0, 1280, 720)) 283 | 284 | # Test case 1: Rectangle with exact aspect ratio 285 | rect = pygame.Rect(0, 0, 1920, 1080) 286 | aspect_ratio = 16 / 9 287 | expected = pygame.Rect(0, 0, 1920, 1080) 288 | self.assertEqual(vp._best_fit(rect, aspect_ratio), expected) 289 | 290 | # Test case 2: Width limiting, maintaining aspect ratio 291 | rect = pygame.Rect(0, 0, 1920, 1080) 292 | aspect_ratio = 4 / 3 293 | expected = pygame.Rect(240, 0, 1440, 1080) # Centered horizontally 294 | self.assertEqual(vp._best_fit(rect, aspect_ratio), expected) 295 | 296 | # Test case 3: Height limiting, maintaining aspect ratio 297 | rect = pygame.Rect(0, 0, 1080, 1920) 298 | aspect_ratio = 16 / 9 299 | expected = pygame.Rect(0, 656, 1080, 607) # Centered vertically 300 | self.assertEqual(vp._best_fit(rect, aspect_ratio), expected) 301 | 302 | # Test case 4: Square rectangle with wide aspect ratio 303 | rect = pygame.Rect(0, 0, 1000, 1000) 304 | aspect_ratio = 16 / 9 305 | expected = pygame.Rect(0, 219, 1000, 562) # Centered vertically 306 | self.assertEqual(vp._best_fit(rect, aspect_ratio), expected) 307 | 308 | # Test case 5: Square rectangle with tall aspect ratio 309 | rect = pygame.Rect(0, 0, 1000, 1000) 310 | aspect_ratio = 9 / 16 311 | expected = pygame.Rect(219, 0, 562, 1000) # Centered horizontally 312 | self.assertEqual(vp._best_fit(rect, aspect_ratio), expected) 313 | 314 | # Test case 6: Rectangle fully inside another with the same aspect ratio 315 | rect = pygame.Rect(100, 100, 1920, 1080) 316 | aspect_ratio = 16 / 9 317 | expected = pygame.Rect(100, 100, 1920, 1080) 318 | self.assertEqual(vp._best_fit(rect, aspect_ratio), expected) 319 | 320 | # Test case 7: Aspect ratio = 1 (square fit) 321 | rect = pygame.Rect(0, 0, 1920, 1080) 322 | aspect_ratio = 1 323 | expected = pygame.Rect(420, 0, 1080, 1080) # Fit to height 324 | self.assertEqual(vp._best_fit(rect, aspect_ratio), expected) 325 | 326 | # Test case 8: Extremely wide aspect ratio 327 | rect = pygame.Rect(0, 0, 1920, 1080) 328 | aspect_ratio = 32 / 9 329 | expected = pygame.Rect(0, 270, 1920, 540) # Fit to width, centered vertically 330 | self.assertEqual(vp._best_fit(rect, aspect_ratio), expected) 331 | 332 | # Test case 9: Extremely tall aspect ratio 333 | rect = pygame.Rect(0, 0, 1920, 1080) 334 | aspect_ratio = 9 / 32 335 | expected = pygame.Rect(808, 0, 303, 1080) # Fit to height, centered horizontally 336 | self.assertEqual(vp._best_fit(rect, aspect_ratio), expected) 337 | 338 | # Test case 10: Zero-size rectangle (edge case) 339 | rect = pygame.Rect(0, 0, 0, 0) 340 | aspect_ratio = 16 / 9 341 | expected = pygame.Rect(0, 0, 0, 0) # No space to fit 342 | self.assertEqual(vp._best_fit(rect, aspect_ratio), expected) 343 | 344 | vp.close() 345 | 346 | # tests _convert_seconds for videoplayer 347 | def test_video_player_convert_seconds(self): 348 | vp = VideoPlayer(Video(VIDEO_PATH), (0, 0, 1280, 720)) 349 | 350 | # Whole Hours 351 | self.assertEqual(vp._convert_seconds(3600), "1:0:0") 352 | self.assertEqual(vp._convert_seconds(7200), "2:0:0") 353 | 354 | # Hours and Minutes 355 | self.assertEqual(vp._convert_seconds(3660), "1:1:0") 356 | self.assertEqual(vp._convert_seconds(7325), "2:2:5") 357 | 358 | # Minutes and Seconds 359 | self.assertEqual(vp._convert_seconds(65), "0:1:5") 360 | self.assertEqual(vp._convert_seconds(125), "0:2:5") 361 | 362 | # Seconds Only 363 | self.assertEqual(vp._convert_seconds(5), "0:0:5") 364 | self.assertEqual(vp._convert_seconds(59), "0:0:59") 365 | 366 | # Fractional Seconds 367 | self.assertEqual(vp._convert_seconds(5.3), "0:0:5") 368 | self.assertEqual(vp._convert_seconds(125.6), "0:2:5") 369 | self.assertEqual(vp._convert_seconds(7325.9), "2:2:5") 370 | 371 | # Zero Seconds 372 | self.assertEqual(vp._convert_seconds(0), "0:0:0") 373 | 374 | # Large Number 375 | self.assertEqual(vp._convert_seconds(86400), "24:0:0") 376 | self.assertEqual(vp._convert_seconds(90061.5), "25:1:1") 377 | 378 | # Negative Seconds 379 | self.assertEqual(vp._convert_seconds(-5), "0:0:5") 380 | self.assertEqual(vp._convert_seconds(-3665), "1:1:5") 381 | 382 | self.assertEqual(vp._convert_seconds(4.98), "0:0:4") 383 | self.assertEqual(vp._convert_seconds(4.98881), "0:0:4") 384 | self.assertEqual(vp._convert_seconds(12.1280937198881), "0:0:12") 385 | 386 | vp.close() 387 | 388 | # tests different arguments for videoplayers to check for errors 389 | def test_bad_player_path(self): 390 | with self.assertRaises(ValueError) as context: 391 | VideoPlayer("badpath", (0, 0, 100, 100)) 392 | 393 | with self.assertRaises(ValueError) as context: 394 | VideoPlayer(VideoTkinter(VIDEO_PATH), (0, 0, 100, 100)) 395 | 396 | v = Video(VIDEO_PATH) 397 | v.close() 398 | with self.assertRaises(VideoStreamError) as context: 399 | VideoPlayer(v, (0, 0, *v.original_size)) 400 | self.assertEqual(str(context.exception), "Provided video is closed.") 401 | 402 | # tests __str__ 403 | def test_str_magic_method(self): 404 | vp = VideoPlayer(Video(VIDEO_PATH), (0, 0, 1280, 720)) 405 | self.assertEqual("", str(vp)) 406 | vp.close() 407 | 408 | 409 | if __name__ == "__main__": 410 | unittest.main() 411 | -------------------------------------------------------------------------------- /documentation.md: -------------------------------------------------------------------------------- 1 | # Video(path, chunk_size=10, max_threads=1, max_chunks=1, subs=None, post_process=PostProcessing.none, interp="linear", use_pygame_audio=False, reverse=False, no_audio=False, speed=1, youtube=False, max_res=720, as_bytes=False, audio_track=0, vfr=False, pref_lang="en", audio_index=None, reader=pyvidplayer2.READER_AUTO, cuda_device=-1) 2 | 3 | Main object used to play videos. Videos can be read from disk, memory or streamed from Youtube. The object uses FFmpeg 4 | to extract chunks of audio from videos and then feeds it into a Pyaudio stream. It uses OpenCV to display the 5 | appropriate video frames. Videos can only be played simultaneously if they're using Pyaudio (see `use_pygame_audio` 6 | below). Pygame or Pygame CE are the only graphics libraries to support subtitles. `ytdlp` is required to stream videos 7 | from Youtube. Decord is required to play videos from memory. This particular object uses Pygame for graphics, but see 8 | bottom for other supported libraries. Actual class name is `VideoPygame`. 9 | 10 | ## Parameters 11 | 12 | - `path: str | bytes` - Path to video file. Supports almost all file containers such as mkv, mp4, mov, avi, 3gp, etc. 13 | Can also provide the video in bytes (see `as_bytes` below). If streaming from Youtube (see `youtube` below), provide 14 | the URL here. 15 | - `chunk_size: float` - How much audio is extracted in each chunk, in seconds. Increasing this value will slow the 16 | initial loading of video, but may be necessary to prevent stuttering. Recommended to keep over 60 if streaming from 17 | Youtube (see `youtube` below). 18 | - `max_threads: int` - Maximum number of chunks that can be extracted at any given time. Do not change if streaming from 19 | Youtube (see `youtube` below). 20 | - `max_chunks: int` - Maximum number of chunks allowed to be extracted and reserved. Do not change if streaming from 21 | Youtube (see `youtube` below). 22 | - `subs: pyvidplayer2.Subtitles` - Pass a `Subtitles` object here for the video to display subtitles. 23 | - `post_process: function(numpy.ndarray) -> numpy.ndarray` - Post processing function to be applied whenever a frame is 24 | rendered. This is PostProcessing.none by default, which means no alterations are taking place. Post-processing 25 | functions should accept a NumpPy image (see `frame_data` below) and return the processed image. 26 | - `interp: str | int` - Interpolation technique used when resizing frames. Accepts `"nearest"`, `"linear"`, `"cubic"`, 27 | `"lanczos4"` and `"area"`. Nearest is the fastest technique but produces the worst results. Lanczos4 produces the best 28 | results but is so much more intensive that it's usually not worth it. Area is a technique that produces the best 29 | results when downscaling. This parameter can also accept OpenCV constants as in `cv2.INTER_LINEAR`. Resizing will use 30 | opencv when available but can fall back on ffmpeg if needed. 31 | - `use_pygame_audio: bool` - Specifies whether to use Pyaudio or Pygame to play audio. Pyaudio is almost always the best 32 | option, so this is mostly obsolete. Using Pygame audio will not allow videos to be played in parallel. 33 | - `reverse: bool` - Specifies whether to play the video in reverse. Warning: Doing so will load every video frame into 34 | memory, so videos longer than a few minutes can temporarily brick your computer. Subtitles are currently unaffected by 35 | reverse playback. 36 | - `no_audio: bool` - Specifies whether the given video has no audio tracks. Setting this to `True` can also be used to 37 | disable all existing audio tracks. 38 | - `speed: float | int` - Float from 0.5 to 10.0 that multiplies the playback speed. Note that if for example, `speed=2`, 39 | the video will play twice as fast. However, every single video frame will still be processed. Therefore, the frame 40 | rate of your program must be at least twice that of the video's frame rate to prevent dropped frames. So for example, 41 | for a 24 fps video, the video will have to be updated (see `draw` below) at least, but ideally more than 48 times a 42 | second to achieve true x2 speed. 43 | - `youtube: bool` - Specifies whether to stream a Youtube video. Path must be a valid Youtube video URL. Youtube shorts 44 | and livestreams are not supported. The python packages `yt_dlp` and `opencv-python` are required for this feature. 45 | They can be installed through pip. Setting this to `True` will force `chunk_size` to be at least 60 and `max_threads` 46 | to be 1. 47 | - `max_res: int` - Only used when streaming Youtube videos. Sets the highest possible resolution when choosing video 48 | quality. 4320p is the highest Youtube supports. Note that actual video quality is not guaranteed to match `max_res`. 49 | - `as_bytes: bool` - Specifies whether `path` is a video in byte form. The python package `decord` is required for this 50 | feature. It can be installed through pip. 51 | - `audio_track: int` - Selects which audio track to use. 0 will play the first, 1 will play the second, and so on. 52 | - `vfr: bool` - Used to play variable frame rate videos properly. If `False`, a constant frame rate will be assumed. If 53 | `True`, presentation timestamps will be extracted for each frame (see `timestamps` below). This still works for 54 | constant frame rate videos, but extracting the timestamps will mean a longer initial load. 55 | - `pref_lang: str` - Only used when streaming Youtube videos. Used to select a language track if video has multiple. 56 | This must be a Google language code; refer to the examples directory. 57 | - `audio_index: int` - Used to specify which audio output device to use if using PyAudio. Can be specific to each video, 58 | and is automatically calculated if argument is not provided. To get a list of devices and their indices, use libraries 59 | like `sounddevice` (see `audio_devices_demo.py` in examples directory). Use the MME host APIs. If using Pygame instead 60 | of PyAudio, setting output device can be done in the mixer init settings, independent of pyvidplayer2. 61 | - `reader: int` - Specifies which video reading backend to use. Can be `pyvidplayer2.READER_AUTO` (choose best backend 62 | automatically), `pyvidplayer2.READER_OPENCV`, `pyvidplayer2.READER_DECORD`, `pyvidplayer2.READER_IMAGEIO`, and 63 | `pyvidplayer2.READER_FFMPEG`. Note that their respective packages must be installed to use. Also, the colour format 64 | varies between readers. `READER_OPENCV` and `READER_FFMPEG` use BGR while `READER_IMAGEIO` and `READER_DECORD` use 65 | RGB (see `colour_format` below). This is a simply a fundamental difference in the native libraries. 66 | - `cuda_device: int` - Specifies which Nvidia GPU to use for hardware acceleration. First GPU device is `0`, second is `1`, 67 | etc. Default is `-1`, which disables hardware acceleration. Note: this may not result in significant performance gains 68 | because all the currently supported graphics libraries must convert video frames with CPU for software rendering. However, 69 | in certain situations, such as video seeking where the bottleneck is video decoding instead of rendering, this can 70 | increase performance. AMD GPU support to come in the future. 71 | 72 | ## Attributes 73 | 74 | - `path: str | bytes` - Same as given argument. 75 | - `name: str` - Name of file without the directory and extension. Will be an empty string if video is given in byte 76 | form (see `as_bytes` above). 77 | - `ext: str` - Type of video (mp4, mkv, mov, etc). Will be `"webm"` if streaming from Youtube (see `youtube` above). 78 | Will be an empty string if video is given in byte form (see `as_bytes` above). 79 | - `frame: int` - Frame index to be rendered next. Starts at 0, which means the 1st frame has not been rendered yet. If 80 | `frame=49`, it means the 49th frame has already been rendered, and the 50th frame will be rendered next. 81 | - `frame_rate: float` - Float that indicates how many frames are in one second. 82 | - `max_fr: float` - Only used if `vfr = True`. Gives the maximum frame rate throughout the video. 83 | - `min_fr: float` - Only used if `vfr = True`. Gives the minimum frame rate throughout the video. 84 | - `avg_fr: float` - Only used if `vfr = True`. Gives the average frame rate of all the extracted presentation 85 | timestamps. 86 | - `timestamps: [float]` - List of presentation timestamps for each frame. 87 | - `frame_count: int` - How many total frames there are. May not be accurate if the video was improperly encoded. For a 88 | more accurate (but slower) frame count, use `_get_real_frame_count()`. 89 | - `frame_delay: float` - Time between frames in order to maintain frame rate in fractions of a second). 90 | - `duration: float` - Length of video in decimal seconds. 91 | - `original_size: (int, int)` - Tuple containing the width and height of each original frame. Unaffected by resizing. 92 | - `current_size: (int, int)` - Tuple containing the width and height of each frame being rendered. Affected by resizing. 93 | - `aspect_ratio: float` - Width divided by height of original size. 94 | - `audio_channels: int` - Number of audio channels in current audio track. May change when other tracks are set with 95 | `set_audio_track`. 96 | - `chunk_size: float` - Same as given argument. May be overridden if `youtube` is `True` (see `youtube` above). 97 | - `max_chunks: int` - Same as given argument. 98 | - `max_threads: int` - Same as given argument. May be overridden if `youtube` is `True` (see `youtube` above). 99 | - `frame_data: numpy.ndarray` - Current video frame as a NumPy `ndarray`. May be in a variety of colour formats (see 100 | `colour_format` below). 101 | - `frame_surf: pygame.Surface` - Current video frame as a Pygame `Surface`. Will be rendered in RGB. This may also 102 | change into other objects depending on the specific graphics library. 103 | - `active: bool` - Whether the video is currently playing. This is unaffected by pausing and resuming. Only turns false 104 | when `stop()` is called or video ends. 105 | - `buffering: bool` - Whether the video is waiting for audio to extract. 106 | - `paused: bool` - Video can be both paused and active at the same time. 107 | - `volume: float` - Float from 0.0 to 1.0. 0.0 means 0% volume and 1.0 means 100% volume. 108 | - `muted: bool` - Will not play audio if muted, but does not affect volume. Video can be both muted and at 1.0 volume at 109 | the same time. 110 | - `speed: float | int` - Same as given argument. 111 | - `subs: pyvidplayer2.Subtitles` - Same as given argument. 112 | - `post_func: callable(numpy.ndarray) -> numpy.ndarray` - Same as given argument. Can be changed with `set_post_func`. 113 | - `interp: int` - Same as given argument. Can be changed with `set_interp`. Will be converted to an integer if given a 114 | string. For example, if `"linear"` is given during initialization, this will be converted to cv2.INTER_LINEAR. 115 | - `use_pygame_audio: bool` - Same as given argument. May be automatically set to default sound backend. 116 | - `reverse: bool` - Same as given argument. 117 | - `no_audio: bool` - Same as given argument. May change if no audio is automatically detected. 118 | - `youtube: bool` - Same as given argument. 119 | - `max_res: int` - Same as given argument. 120 | - `as_bytes: bool` - Same as given argument. May change if bytes are automatically detected. 121 | - `audio_track: int` - Same as given argument. 122 | - `vfr: bool` - Same as given argument. 123 | - `pref_lang: str` - Same as given argument. 124 | - `audio_index: int` - Same as given argument. 125 | - `subs_hidden: bool` - `True` if subs are currently disabled and `False` otherwise. 126 | - `closed: bool` - `True` after `close()` is called. Attempting to use video object after closing it may lead to 127 | unexpected behaviour. 128 | - `colour_format: str` - Whatever colour format the current backend is reading in. OpenCV and FFmpeg use BGR, while 129 | Decord and ImageIO use RGB. 130 | - `cuda_device: int` - Same as given argument. 131 | 132 | ## Methods 133 | 134 | - `play() -> None` - Sets `active` to `True`. 135 | - `stop() -> None` - Resets video and sets `active` to `False`. 136 | - `resize(size: (int, int)) -> None` - Sets the new frame size for video. Also resizes current `frame_data` and 137 | `frame_surf`. 138 | - `change_resolution(height: int) -> None` - Given a height, the video will scale its dimensions while maintaining 139 | aspect ratio. Will scale width to an even number. 140 | - `close() -> None` - Releases resources. Always recommended to call when done. Attempting to use video object after 141 | closing it may lead to unexpected behaviour. 142 | - `restart() -> None` - Rewinds video to the beginning. Does not change `video.active`, and does not refresh current 143 | frame information. 144 | - `get_speed() -> float | int` - Returns `video.speed`. Only exists due to backwards compatibility. 145 | - `set_volume(volume: float) -> None` - Adjusts the volume of the video, from 0.0 (min) to 1.0 (max). 146 | - `get_volume() -> float` - Returns `video.volume`. Only exists due to backwards compatibility. 147 | - `get_paused() -> bool` - Returns `video.paused`. Only exists due to backwards compatibility. 148 | - `toggle_pause() -> None` - Pauses if the video is playing, and resumes if the video is paused. 149 | - `pause() -> None` - Pauses the video. 150 | - `resume() -> None` - Resumes the video. 151 | - `set_audio_track(index: int)` - Sets the audio track used (see `audio_track` above). This will re-probe the video for 152 | number of audio channels. 153 | - `toggle_mute() -> None` - Mutes if the video is unmuted, and unmutes if the video is muted. 154 | - `mute() -> None` - Mutes video. Doesn't affect volume. 155 | - `unmute() -> None` - Unmutes video. Doesn't affect volume. 156 | - `set_interp(interp: str | int) -> None` - Changes the interpolation technique that OpenCV uses. Works the same as the 157 | `interp` parameter (see `interp` above). Does nothing if OpenCV is not installed. 158 | - `set_post_func(func: callable(numpy.ndarray) -> numpy.ndarray) -> None` - Changes the post processing function. Works 159 | the same as the `post_func` parameter (see `post_func` above). 160 | - `get_pos(): float` - Returns the current video timestamp/position in decimal seconds. 161 | - `seek(time: float | int, relative: bool = True) -> None` - Changes the current position in the video. If relative is 162 | `True`, the given time will be added or subtracted to the current time. Otherwise, the current position will be set to 163 | the given time exactly. Time must be given in seconds, with no precision limit. Note that 164 | frames and audio within the video will not yet be updated after calling seek. Does not refresh `frame_data` or 165 | `frame_surf`. To do so, call `buffer_current()`. If the given value is larger than the video duration, the video will 166 | seek to the last frame. Calling `next(video)` will read the last frame. 167 | - `seek_frame(index: int, relative: bool = False) -> None` - Same as `seek` but seeks to a specific frame instead of a 168 | timestamp. For example, index 0 will seek to the first frame, index 1 will seek to the second frame, and so on. If 169 | `frame=0`, then the first frame will be rendered next. If the given index is larger than the total frames, the video 170 | will seek to the last frame. 171 | Does not refresh `frame_data` or `frame_surf`. 172 | - `update() -> bool` - Allows video to perform required operations. `draw` already calls this method, so it's usually 173 | not used. Returns `True` if a new frame is ready to be displayed. 174 | - `draw(surf: pygame.Surface, pos: (int, int), force_draw: bool = True) -> bool` - Draws the current video frame onto 175 | the given surface, at the given position. If `force_draw` is `True`, a surface will be drawn every time this is 176 | called. Otherwise, only new frames will be drawn. This reduces CPU usage but will cause flickering if anything is 177 | drawn under or above the video. This method also returns whether a frame was drawn. 178 | - `preview(show_fps: bool = False, max_fps: int = 60) -> None` - Opens a window and plays the video. This method will 179 | hang until the video finishes. `max_fps` enforces how many times a second the video is updated. If `show_fps` is 180 | `True`, a counter will be displayed showing the actual number of new frames being rendered every second. 181 | - `show_subs() -> None` - Enables subtitles. 182 | - `hide_subs() -> None` - Disables subtitles. 183 | - `set_subs(subs: Subtitles | [Subtitles]) -> None` - Set the subtitles to use. Works the same as providing subtitles 184 | through the initialization parameter. 185 | - `probe() -> None` - Uses FFprobe to find information about the video. When using cv2 to read videos, information such 186 | as frame count and frame rate are read through the file headers, which is sometimes incorrect. For more accuracy, call 187 | this method to start a probe and update video metadata attributes. 188 | - `get_metadata() -> dict` - Outputs a dictionary with attributes about the file metadata, including frame_count, 189 | frame_rate, etc. Can be combined with `pprint` to quickly see a general overview of a video file. 190 | - `buffer_current() -> bool` - Whenever `frame_surf` or `frame_data` are `None`, use this method to populate them. This 191 | is useful because seeking does not update `frame_data` or `frame_surf`. Keep in mind that `video.frame` represents the 192 | frame 193 | that WILL be rendered. Therefore, if `video.frame == 0`, that means the first frame has yet to be rendered, and 194 | `buffer_current` will not work. Returns `True` or `False` depending on if data was successfully buffered. 195 | 196 | ## Supported Graphics Libraries 197 | 198 | - Pygame or Pygame CE (`Video`) <- default and best supported 199 | - Tkinter (`VideoTkinter`) 200 | - Pyglet (`VideoPyglet`) 201 | - PySide6 (`VideoPySide`) 202 | - PyQT6 (`VideoPyQT`) 203 | - RayLib (`VideoRayLib`) 204 | - WxPython (`VideoWx`) 205 | 206 | To use other libraries instead of Pygame, use their respective video object. Each preview method will use their 207 | respective graphics API to create a window and draw frames. See the examples folder for details. Note that `Subtitles`, 208 | `Webcam`, and `VideoPlayer` only work with Pygame installed. Preview methods for other graphics libraries also do not 209 | accept any arguments. 210 | 211 | ## As a Generator 212 | 213 | Video objects can be iterated through as a generator, returning each subsequent frame. Frames will be given in reverse 214 | if video is reversed, and post processing and resizing will still take place. Subtitles will not be rendered. After 215 | iterating through frames, `play()` will resume the video from where the last frame left off. Returned frames will be in 216 | BGR format. 217 | 218 | ``` 219 | for frame in Video("example.mp4"): 220 | print(frame) 221 | ``` 222 | 223 | ## In a Context Manager 224 | 225 | Video objects can also be opened using context managers which will automatically call `close()` (see `close()` above) 226 | when out of use. 227 | 228 | ``` 229 | with Video("example.mp4") as vid: 230 | vid.preview() 231 | ``` 232 | 233 | # VideoPlayer(video, rect, interactable=False, loop=False, preview_thumbnails=0, font_size=10) 234 | 235 | VideoPlayers are GUI containers for videos. They are useful for scaling a video to fit an area or looping videos. Only 236 | supported for Pygame. 237 | 238 | ## Parameters 239 | 240 | - `video: pyvidplayer2.VideoPygame` - Video object to play. 241 | - `rect: (int, int, int, int)` - An x, y, width, and height of the VideoPlayer. The top left corner will be the x, y 242 | coordinate. 243 | - `interactable: bool` - Enables the GUI. 244 | - `loop: bool` - Specifies whether the contained video will restart after it finishes. If the queue is not empty, the 245 | entire queue will loop, not just the current video. 246 | - `preview_thumbnails: int` - Number of preview thumbnails loaded and saved in memory. When seeking, a preview window 247 | will show the closest loaded frame. The higher this number is, the more frames are loaded, increasing the preview 248 | accuracy but also increasing initial load time and memory usage. Because of this, this value is defaulted to 0, which 249 | turns seek previewing off. 250 | - `font_size: int` - Sets font size for GUI elements. 251 | 252 | ## Attributes 253 | 254 | - `video: pyvidplayer2.VideoPygame` - Same as given argument. 255 | - `frame_rect: (int, int, int, int)` - Same as given argument. 256 | - `vid_rect: (int, int, int, int)` - Location and dimensions (x, y, width, height) of the video fitted into `frame_rect` 257 | while maintaining aspect ratio. Black bars will appear in any unused space. 258 | - `interactable: bool` - Same as given argument. 259 | - `loop: bool` - Same as given argument. 260 | - `queue_: list[pyvidplayer2.VideoPygame | str]` - Videos to play after the current one finishes. 261 | - `preview_thumbnails: int` - Same as given argument. 262 | - `closed: bool` - True after `close()` is called. 263 | 264 | ## Methods 265 | 266 | - `zoom_to_fill() -> None` - Zooms in the video so that `frame_rect` is entirely filled in while maintaining aspect 267 | ratio. 268 | - `zoom_out() -> None` - Reverts `zoom_to_fill()`. 269 | - `toggle_zoom() -> None` - Switches between zoomed in and zoomed out. 270 | - `queue(input: pyvidplayer2.VideoPygame | str) -> None` - Accepts a path to a video or a Video object and adds it to 271 | the queue. Passing a path will not load the video until it becomes the active video. Passing a Video object will cause 272 | it to silently load its first audio chunk, so changing videos will be as seamless as possible. 273 | - `enqueue(input: pyvidplayer2.VideoPygame | str) -> None` - Same exact method as `queue`, but with a more 274 | conventionally correct name. I'm keeping `queue` only for backwards compatibility. 275 | - `get_queue(): list[pyvidplayer2.VideoPygame]` - Returns list of queued video objects. 276 | - `resize(size: (int, int)) -> None` - Resizes the video player. The contained video will automatically re-adjust to fit 277 | the player. 278 | - `move(pos: (int, int), relative: bool = False) -> None` - Moves the VideoPlayer. If `relative` is `True`, the given 279 | coordinates will be added onto the current coordinates. Otherwise, the current coordinates will be set to the given 280 | coordinates. 281 | - `update(events: list[pygame.event.Event], show_ui: bool = None, fps: int = 0) -> bool` - Allows the VideoPlayer to 282 | make calculations. It must be given the returns of `pygame.event.get()`. The GUI automatically shows up when your 283 | mouse hovers over the video player, so setting `show_ui` to `False` can be used to override that. The `fps` parameter 284 | can enforce be used to enforce a frame rate to your app. This method also returns whether the UI was shown. 285 | - `draw(surface: pygame.Surface) -> None` - Draws the VideoPlayer onto the given Pygame surface. 286 | - `close() -> None` - Releases resources. Always recommended to call when done. 287 | - `skip() -> None` - Moves onto the next video in the queue. 288 | - `get_video() -> pyvidplayer2.VideoPygame` - Returns currently playing video. 289 | - `preview(max_fps: int = 60)` - Similar to `Video.preview()`. Gives a quick and easy demo of the class. 290 | 291 | # Subtitles(path, colour="white", highlight=(0, 0, 0, 128), font=None, encoding="utf-8", offset=50, delay=0, youtube=False, pref_lang="en", track_index=None) 292 | 293 | Object used for handling subtitles. Only supported for Pygame. 294 | 295 | ## Parameters 296 | 297 | - `path: str` - Path to subtitle file. This can be any file pysubs2 can read including .srt, .ass, .vtt, and others. Can 298 | also be a youtube url if `youtube = True`. Can also be a video that contains subtitle tracks. 299 | - `colour: str | (int, int, int)` - Colour of text as an RGB value or a string recognized by Pygame. 300 | - `highlight: str | (int, int, int, int)` - Background colour of text. Accepts RGBA, so it can be made completely 301 | transparent. 302 | - `font: pygame.font.Font | pygame.font.SysFont` - Pygame `Font` or `SysFont` object used to render surfaces. This 303 | includes the size of the text. 304 | - `encoding: str` - Encoding used to open subtitle files. 305 | - `offset: float` - The higher this number is, the closer the subtitle is to the top of the screen. 306 | - `delay: float` - Delays all subtitles by this many seconds. 307 | - `youtube: bool` - Set this to true and put a youtube video url into path to grab subtitles. 308 | - `pref_lang: str` - Which language file to grab if `youtube = True`. If no subtitle file exists for this language, 309 | automatic captions are used, which are also automatically translated into the preferred language. However, it's 310 | important to use the correct language code set by Google, otherwise the subtitles will not be found. 311 | For example, usually setting `en` will get English subtitles. However, the video might be in `en-US` instead, so this 312 | is an important differentiation. Confirm which one your video has in Youtube first. 313 | - `track_index: int` - If path is given as a video with subtitle tracks, use this to specify which subtitle to load. 0 314 | selects the first, 1 selects the second, etfc. 315 | 316 | ## Attributes 317 | 318 | - `path: str` - Same as given argument. 319 | - `encoding: str` - Same as given argument. 320 | - `start: float` - Starting timestamp of current subtitle. 321 | - `end: float` - Ending timestamp of current subtitle. 322 | - `text: str` - Current subtitle text. 323 | - `surf: pygame.Surface` - Current text in a Pygame `Surface`. 324 | - `colour: str | (int, int, int)` - Same as given argument. 325 | - `highlight: str | (int, int, int, int)` - Same as given argument. 326 | - `font: pygame.font.Font | pygame.font.SysFont` - Same as given argument. 327 | - `offset: float` - Same as given argument. 328 | - `delay: float` - Same as given argument. 329 | - `youtube: bool` - Same as given argument. 330 | - `pref_lang: str` - Same as given argument. 331 | - `buffer: str` - Entire subtitle file loaded into memory if downloaded. 332 | - `track_index: int` - Same as given argument. 333 | 334 | ## Methods 335 | 336 | - `set_font(font: pygame.font.Font | pygame.font.SysFont) -> None` - Same as `font` parameter (see `font` above). 337 | - `get_font() -> pygame.font.Font | pygame.font.SysFont` 338 | 339 | # Webcam(post_process=PostProcessing.none, interp="linear", fps=30, cam_id=0, capture_size=(0, 0)) 340 | 341 | Object used for displaying a webcam feed. Only supported for Pygame. 342 | 343 | ## Parameters 344 | 345 | - `post_process: callable(numpy.ndarray) -> numpy.ndarray` - Post processing function that is applied whenever a frame 346 | is rendered. This is PostProcessing.none by default, which means no alterations are taking place. Post processing 347 | functions should accept a NumpPy image (see `frame_data` below) and return the processed image. 348 | - `interp: str | int` - Interpolation technique used by OpenCV when resizing frames. Accepts `"nearest"`, `"linear"`, 349 | `"cubic"`, `"lanczos4"` and `"area"`. Nearest is the fastest technique but produces the worst results. Lanczos4 350 | produces the best results but is so much more intensive that it's usually not worth it. Area is a technique that 351 | produces the best results when downscaling. This parameter can also accept OpenCV constants as in `cv2.INTER_LINEAR`. 352 | Resizing will use opencv when available but can fall back on ffmpeg if needed. 353 | - `fps: int` - Maximum number of frames captured from the webcam per second. 354 | - `cam_id: int` - Specifies which webcam to use if there are more than one. 0 means the first, 1 means the second, and 355 | so on. 356 | - `capture_size: int` - Specifies the webcam resolution. If nothing is set, a default is used. 357 | - 358 | 359 | ## Attributes 360 | 361 | - `post_process: callable(numpy.ndarray) -> numpy.ndarray` - Same as given argument. 362 | - `interp: int` - Same as given argument. 363 | - `fps: int` - Same as given argument. 364 | - `original_size: (int, int)` - Size of raw frames captured by the webcam. Can be set with `resize_capture`. 365 | - `current_size: (int, int)` - Size of frames after resampling. Can be set with `resize`. 366 | - `aspect_ratio: float` - Width divided by height of original size. 367 | - `active: bool` - Whether the webcam is currently playing. 368 | - `frame_data: numpy.ndarray` - Current video frame as a NumPy `ndarray`. Will be in BGR format. 369 | - `frame_surf: pygame.Surface` - Current video frame as a Pygame `Surface`. 370 | - `cam_id: int` - Same as given argument. 371 | - `closed: bool` - True after `close()` is called. 372 | 373 | ## Methods 374 | 375 | - `play() -> None` 376 | - `stop() -> None` 377 | - `resize(size: (int, int)) -> None` - Simply sets dimensions that captured frames will be resized to. 378 | - `resize_capture(size: (int, int)) -> bool` - Changes the resolution at which frames are captured from the webcam. 379 | Returns `True` if a resolution was found that matched the given size exactly. Otherwise, `False` will be returned and 380 | the closest matching resolution will be used. 381 | - `change_resolution(height: int) -> None` - Given a height, the video will scale its width while maintaining aspect 382 | ratio. Will scale width to an even number. 383 | - `set_interp(interp: str | int) -> None` - Changes the interpolation technique that OpenCV uses. Works the same as the 384 | `interp` parameter (see `interp` above). Does nothing if OpenCV is not installed. 385 | - `set_post_func(func: callable(numpy.ndarray) -> numpy.ndarray) -> None` - Changes the post processing function. Works 386 | the same as the `post_func` parameter (see `post_func` above). 387 | - `close() -> None` - Releases resources. Always recommended to call when done. 388 | - `get_pos() -> float` - Returns how long the webcam has been active. Is not reset if webcam is stopped. 389 | - `update() -> bool` - Allows webcam to perform required operations. `draw` already calls this method, so it's usually 390 | not used. Returns `True` if a new frame is ready to be displayed. 391 | - `draw(surf: pygame.Surface, pos: (int, int), force_draw: bool = True) -> bool` - Draws the current video frame onto 392 | the given surface, at the given position. If `force_draw` is `True`, a surface will be drawn every time this is 393 | called. Otherwise, only new frames will be drawn. This reduces CPU usage but will cause flickering if anything is 394 | drawn under or above the video. This method also returns whether a frame was drawn. 395 | - `preview() -> None` - Opens a window and plays the webcam. This method will hang until the window is closed. Videos 396 | are played at whatever fps the webcam object is set to. 397 | 398 | # PostProcessing 399 | 400 | Used to apply various filters to video playback. Mostly for fun. Works across all graphics libraries. Requires OpenCV. 401 | 402 | - `none` - Default. Nothing happens. 403 | - `blur` - Slightly blurs frames. 404 | - `sharpen` - An okay-looking sharpen. Looks pretty bad for small resolutions. 405 | - `greyscale` - Removes colour from frame. 406 | - `noise` - Adds a static-like filter. Very resource intensive. 407 | - `letterbox` - Adds black bars above and below the frame to look more cinematic. 408 | - `cel_shading` - Thickens borders for a comic book style filter. 409 | - `fliplr` - Flips the video across y axis. 410 | - `flipup` - Flips the video across x axis. 411 | - `rotate90` - Rotates the video by 90 degrees. 412 | - `rotate270` - Essentially just rotate90 but in the other direction. 413 | 414 | # Errors 415 | 416 | - `Pyvidplayer2Error` - Base error for pyvidplayer2 related exceptions. 417 | - `AudioDeviceError(Pyvidplayer2Error)` - Thrown for exceptions related to PyAudio output devices. 418 | - `SubtitleError(Pyvidplayer2Error)` - Thrown for exceptions related to subtitles. 419 | - `VideoStreamError(Pyvidplayer2Error)` - Thrown for exceptions related to general video probing and playback. 420 | - `AudioStreamError(Pyvidplayer2Error)` - Thrown for exceptions related to audio tracks. 421 | - `FFmpegNotFoundError(Pyvidplayer2Error)` - Thrown when FFmpeg is missing. 422 | - `OpenCVError(Pyvidplayer2Error)` - Thrown for exceptions related to OpenCV processes. 423 | - `YTDLPError(Pyvidplayer2Error)` - Thrown for exceptions related to YTDLP processes. 424 | - `WebcamNotFoundError(Pyvidplayer2Error)` - Thrown when there are no webcams to activate. 425 | 426 | # Misc 427 | 428 | ``` 429 | print(pyvidplayer2.get_version_info()) 430 | ``` 431 | 432 | Returns a dictionary with the version of pyvidplayer2, FFmpeg, and Pygame. Version can also be accessed directly 433 | with `pyvidplayer2.__version__` or `pyvidplayer2.VERSION`. --------------------------------------------------------------------------------