psychopy 2025.1.0__py3-none-any.whl → 2025.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of psychopy might be problematic. Click here for more details.
- psychopy/VERSION +1 -1
- psychopy/alerts/alertsCatalogue/4810.yaml +19 -0
- psychopy/alerts/alertsCatalogue/alertCategories.yaml +4 -0
- psychopy/alerts/alertsCatalogue/alertmsg.py +15 -1
- psychopy/alerts/alertsCatalogue/generateAlertmsg.py +2 -2
- psychopy/app/Resources/classic/add_many.png +0 -0
- psychopy/app/Resources/classic/add_many@2x.png +0 -0
- psychopy/app/Resources/classic/devices.png +0 -0
- psychopy/app/Resources/classic/devices@2x.png +0 -0
- psychopy/app/Resources/classic/photometer.png +0 -0
- psychopy/app/Resources/classic/photometer@2x.png +0 -0
- psychopy/app/Resources/dark/add_many.png +0 -0
- psychopy/app/Resources/dark/add_many@2x.png +0 -0
- psychopy/app/Resources/dark/devices.png +0 -0
- psychopy/app/Resources/dark/devices@2x.png +0 -0
- psychopy/app/Resources/dark/photometer.png +0 -0
- psychopy/app/Resources/dark/photometer@2x.png +0 -0
- psychopy/app/Resources/light/add_many.png +0 -0
- psychopy/app/Resources/light/add_many@2x.png +0 -0
- psychopy/app/Resources/light/devices.png +0 -0
- psychopy/app/Resources/light/devices@2x.png +0 -0
- psychopy/app/Resources/light/photometer.png +0 -0
- psychopy/app/Resources/light/photometer@2x.png +0 -0
- psychopy/app/_psychopyApp.py +35 -13
- psychopy/app/builder/builder.py +88 -35
- psychopy/app/builder/dialogs/__init__.py +69 -220
- psychopy/app/builder/dialogs/dlgsCode.py +29 -8
- psychopy/app/builder/dialogs/paramCtrls.py +1468 -904
- psychopy/app/builder/validators.py +25 -17
- psychopy/app/coder/coder.py +12 -1
- psychopy/app/coder/repl.py +5 -2
- psychopy/app/colorpicker/__init__.py +1 -1
- psychopy/app/deviceManager/__init__.py +1 -0
- psychopy/app/deviceManager/addDialog.py +218 -0
- psychopy/app/deviceManager/dialog.py +185 -0
- psychopy/app/deviceManager/panel.py +191 -0
- psychopy/app/deviceManager/utils.py +60 -0
- psychopy/app/idle.py +7 -0
- psychopy/app/locale/ar_001/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ar_001/LC_MESSAGE/messages.po +12695 -10592
- psychopy/app/locale/cs_CZ/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/cs_CZ/LC_MESSAGE/messages.po +10199 -24
- psychopy/app/locale/da_DK/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/da_DK/LC_MESSAGE/messages.po +10199 -24
- psychopy/app/locale/de_DE/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/de_DE/LC_MESSAGE/messages.po +11221 -9712
- psychopy/app/locale/el_GR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/el_GR/LC_MESSAGE/messages.po +10200 -25
- psychopy/app/locale/en_NZ/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/en_NZ/LC_MESSAGE/messages.po +10200 -25
- psychopy/app/locale/en_US/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/en_US/LC_MESSAGE/messages.po +10195 -18
- psychopy/app/locale/es_CO/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/es_CO/LC_MESSAGE/messages.po +11917 -9101
- psychopy/app/locale/es_ES/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/es_ES/LC_MESSAGE/messages.po +11924 -9103
- psychopy/app/locale/es_US/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/es_US/LC_MESSAGE/messages.po +11917 -9101
- psychopy/app/locale/et_EE/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/et_EE/LC_MESSAGE/messages.po +11084 -9569
- psychopy/app/locale/fa_IR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/fa_IR/LC_MESSAGE/messages.po +11590 -5806
- psychopy/app/locale/fi_FI/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/fi_FI/LC_MESSAGE/messages.po +10199 -24
- psychopy/app/locale/fr_FR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/fr_FR/LC_MESSAGE/messages.po +11091 -9577
- psychopy/app/locale/he_IL/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/he_IL/LC_MESSAGE/messages.po +11072 -9549
- psychopy/app/locale/hi_IN/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/hi_IN/LC_MESSAGE/messages.po +11071 -9559
- psychopy/app/locale/hu_HU/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/hu_HU/LC_MESSAGE/messages.po +10200 -25
- psychopy/app/locale/it_IT/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/it_IT/LC_MESSAGE/messages.po +11072 -9560
- psychopy/app/locale/ja_JP/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ja_JP/LC_MESSAGE/messages.po +1485 -1137
- psychopy/app/locale/ko_KR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ko_KR/LC_MESSAGE/messages.po +10199 -24
- psychopy/app/locale/ms_MY/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ms_MY/LC_MESSAGE/messages.po +11463 -8757
- psychopy/app/locale/nl_NL/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/nl_NL/LC_MESSAGE/messages.po +10200 -25
- psychopy/app/locale/nn_NO/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/nn_NO/LC_MESSAGE/messages.po +10200 -25
- psychopy/app/locale/pl_PL/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/pl_PL/LC_MESSAGE/messages.po +10200 -25
- psychopy/app/locale/pt_PT/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/pt_PT/LC_MESSAGE/messages.po +11288 -9434
- psychopy/app/locale/ro_RO/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ro_RO/LC_MESSAGE/messages.po +10200 -25
- psychopy/app/locale/ru_RU/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ru_RU/LC_MESSAGE/messages.po +10199 -24
- psychopy/app/locale/sv_SE/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/sv_SE/LC_MESSAGE/messages.po +11441 -8747
- psychopy/app/locale/tr_TR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/tr_TR/LC_MESSAGE/messages.po +11069 -9545
- psychopy/app/locale/zh_CN/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/zh_CN/LC_MESSAGE/messages.po +12085 -8268
- psychopy/app/locale/zh_TW/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/zh_TW/LC_MESSAGE/messages.po +11929 -8022
- psychopy/app/plugin_manager/dialog.py +12 -3
- psychopy/app/plugin_manager/packageIndex.py +303 -0
- psychopy/app/plugin_manager/packages.py +203 -63
- psychopy/app/plugin_manager/plugins.py +120 -240
- psychopy/app/preferencesDlg.py +6 -1
- psychopy/app/psychopyApp.py +16 -4
- psychopy/app/runner/runner.py +10 -2
- psychopy/app/runner/scriptProcess.py +8 -3
- psychopy/app/stdout/stdOutRich.py +11 -4
- psychopy/app/themes/icons.py +3 -0
- psychopy/app/utils.py +61 -0
- psychopy/colors.py +10 -5
- psychopy/data/experiment.py +133 -23
- psychopy/data/routine.py +12 -0
- psychopy/data/staircase.py +42 -20
- psychopy/data/trial.py +20 -12
- psychopy/data/utils.py +43 -3
- psychopy/demos/builder/Experiments/dragAndDrop/drag_and_drop.psyexp +22 -5
- psychopy/demos/builder/Experiments/dragAndDrop/stimuli/solutions.xlsx +0 -0
- psychopy/demos/builder/Experiments/stroopVoice/stroopVoice.psyexp +2 -12
- psychopy/demos/builder/Feature Demos/buttonBox/buttonBoxDemo.psyexp +3 -8
- psychopy/demos/builder/Feature Demos/movies/movie.psyexp +220 -0
- psychopy/demos/builder/Feature Demos/movies/readme.md +3 -0
- psychopy/demos/builder/Feature Demos/visualValidator/visualValidator.psyexp +1 -2
- psychopy/demos/builder/Hardware/camera/camera.psyexp +3 -16
- psychopy/demos/builder/Hardware/microphone/microphone.psyexp +3 -16
- psychopy/demos/coder/hardware/hdf5_extract.py +133 -0
- psychopy/event.py +20 -15
- psychopy/experiment/_experiment.py +86 -10
- psychopy/experiment/components/__init__.py +3 -10
- psychopy/experiment/components/_base.py +9 -20
- psychopy/experiment/components/button/__init__.py +1 -1
- psychopy/experiment/components/buttonBox/__init__.py +50 -54
- psychopy/experiment/components/camera/__init__.py +137 -359
- psychopy/experiment/components/keyboard/__init__.py +17 -24
- psychopy/experiment/components/microphone/__init__.py +61 -110
- psychopy/experiment/components/movie/__init__.py +2 -3
- psychopy/experiment/components/serialOut/__init__.py +192 -93
- psychopy/experiment/components/settings/__init__.py +45 -27
- psychopy/experiment/components/sound/__init__.py +82 -73
- psychopy/experiment/components/soundsensor/__init__.py +43 -80
- psychopy/experiment/devices.py +303 -0
- psychopy/experiment/exports.py +20 -18
- psychopy/experiment/flow.py +7 -0
- psychopy/experiment/loops.py +47 -29
- psychopy/experiment/monitor.py +74 -0
- psychopy/experiment/params.py +48 -10
- psychopy/experiment/plugins.py +28 -108
- psychopy/experiment/py2js_transpiler.py +1 -1
- psychopy/experiment/routines/__init__.py +1 -1
- psychopy/experiment/routines/_base.py +59 -24
- psychopy/experiment/routines/audioValidator/__init__.py +19 -155
- psychopy/experiment/routines/visualValidator/__init__.py +25 -25
- psychopy/hardware/__init__.py +20 -57
- psychopy/hardware/button.py +15 -2
- psychopy/hardware/camera/__init__.py +2237 -1394
- psychopy/hardware/joystick/__init__.py +1 -1
- psychopy/hardware/keyboard.py +5 -8
- psychopy/hardware/listener.py +4 -1
- psychopy/hardware/manager.py +75 -35
- psychopy/hardware/microphone.py +53 -7
- psychopy/hardware/monitor.py +144 -0
- psychopy/hardware/photometer/__init__.py +156 -117
- psychopy/hardware/serialdevice.py +16 -2
- psychopy/hardware/soundsensor.py +4 -1
- psychopy/iohub/devices/deviceConfigValidation.py +2 -1
- psychopy/iohub/devices/eyetracker/hw/gazepoint/__init__.py +2 -2
- psychopy/iohub/devices/eyetracker/hw/gazepoint/gp3/__init__.py +1 -0
- psychopy/iohub/devices/eyetracker/hw/gazepoint/gp3/eyetracker.py +10 -0
- psychopy/iohub/devices/keyboard/darwin.py +8 -5
- psychopy/iohub/util/__init__.py +7 -8
- psychopy/localization/generateTranslationTemplate.py +208 -116
- psychopy/localization/messages.pot +4305 -3502
- psychopy/monitors/MonitorCenter.py +174 -74
- psychopy/plugins/__init__.py +6 -4
- psychopy/preferences/devices.py +80 -0
- psychopy/preferences/generateHints.py +2 -1
- psychopy/preferences/preferences.py +35 -11
- psychopy/scripts/psychopy-pkgutil.py +969 -0
- psychopy/scripts/psyexpCompile.py +1 -1
- psychopy/session.py +34 -38
- psychopy/sound/__init__.py +6 -260
- psychopy/sound/audioclip.py +164 -0
- psychopy/sound/backend_ptb.py +8 -0
- psychopy/sound/backend_pygame.py +10 -0
- psychopy/sound/backend_pysound.py +9 -0
- psychopy/sound/backends/__init__.py +0 -0
- psychopy/sound/microphone.py +3 -0
- psychopy/sound/sound.py +58 -0
- psychopy/tests/data/correctScript/python/correctNoiseStimComponent.py +1 -1
- psychopy/tests/data/duplicateHeaders.csv +2 -0
- psychopy/tests/test_app/test_builder/test_BuilderFrame.py +22 -7
- psychopy/tests/test_app/test_builder/test_CompileFromBuilder.py +0 -2
- psychopy/tests/test_data/test_utils.py +5 -1
- psychopy/tests/test_experiment/test_components/test_ButtonBoxComponent.py +22 -2
- psychopy/tests/test_hardware/test_ports.py +0 -12
- psychopy/tests/test_tools/test_stringtools.py +1 -1
- psychopy/tools/attributetools.py +12 -5
- psychopy/tools/fontmanager.py +17 -14
- psychopy/tools/gltools.py +4 -2
- psychopy/tools/movietools.py +43 -2
- psychopy/tools/stringtools.py +33 -8
- psychopy/tools/versionchooser.py +1 -1
- psychopy/validation/audio.py +5 -1
- psychopy/validation/visual.py +5 -1
- psychopy/visual/basevisual.py +8 -7
- psychopy/visual/circle.py +2 -2
- psychopy/visual/helpers.py +3 -1
- psychopy/visual/image.py +29 -109
- psychopy/visual/movies/__init__.py +1800 -313
- psychopy/visual/polygon.py +4 -0
- psychopy/visual/shape.py +2 -2
- psychopy/visual/window.py +35 -12
- psychopy/voicekey/__init__.py +41 -669
- psychopy/voicekey/labjack_vks.py +7 -48
- psychopy/voicekey/parallel_vks.py +7 -42
- psychopy/voicekey/vk_tools.py +114 -263
- {psychopy-2025.1.0.dist-info → psychopy-2025.2.1.dist-info}/METADATA +20 -13
- {psychopy-2025.1.0.dist-info → psychopy-2025.2.1.dist-info}/RECORD +222 -190
- {psychopy-2025.1.0.dist-info → psychopy-2025.2.1.dist-info}/WHEEL +1 -1
- psychopy/visual/movies/players/__init__.py +0 -62
- psychopy/visual/movies/players/ffpyplayer_player.py +0 -1401
- psychopy/voicekey/demo_vks.py +0 -12
- psychopy/voicekey/signal.py +0 -42
- {psychopy-2025.1.0.dist-info → psychopy-2025.2.1.dist-info}/entry_points.txt +0 -0
- {psychopy-2025.1.0.dist-info → psychopy-2025.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -15,40 +15,1076 @@ import ctypes
|
|
|
15
15
|
import os.path
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
from psychopy import layout, prefs
|
|
19
22
|
from psychopy.tools.filetools import pathToString, defaultStim
|
|
20
23
|
from psychopy.visual.basevisual import (
|
|
21
24
|
BaseVisualStim, DraggingMixin, ContainerMixin, ColorMixin
|
|
22
25
|
)
|
|
23
|
-
from psychopy.constants import
|
|
26
|
+
from psychopy.constants import (
|
|
27
|
+
FINISHED, NOT_STARTED, PAUSED, PLAYING, STOPPED, SEEKING)
|
|
28
|
+
from psychopy import core
|
|
29
|
+
|
|
30
|
+
from .metadata import MovieMetadata, NULL_MOVIE_METADATA
|
|
31
|
+
from .frame import MovieFrame, NULL_MOVIE_FRAME_INFO
|
|
32
|
+
|
|
33
|
+
from psychopy import logging
|
|
34
|
+
import numpy as np
|
|
35
|
+
import pyglet
|
|
36
|
+
pyglet.options['debug_gl'] = False
|
|
37
|
+
GL = pyglet.gl
|
|
38
|
+
|
|
39
|
+
# threshold to stop reporting dropped frames
|
|
40
|
+
reportNDroppedFrames = 10
|
|
41
|
+
defaultTimeout = 5.0 # seconds
|
|
42
|
+
|
|
43
|
+
# constants for use with ffpyplayer
|
|
44
|
+
FFPYPLAYER_STATUS_EOF = 'eof'
|
|
45
|
+
FFPYPLAYER_STATUS_PAUSED = 'paused'
|
|
46
|
+
|
|
47
|
+
PREFERRED_VIDEO_LIB = 'ffpyplayer'
|
|
48
|
+
|
|
49
|
+
# Keep track of movie readers here. This is used to close all movie readers
|
|
50
|
+
# when the main thread exits. We identify movie readers by hashing the filename
|
|
51
|
+
# they are presently reading from.
|
|
52
|
+
|
|
53
|
+
_openMovieReaders = set()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------------------------
|
|
57
|
+
# Classes
|
|
58
|
+
#
|
|
59
|
+
|
|
60
|
+
class MoviePlaybackError(Exception):
|
|
61
|
+
"""Exception raised when there is an error during movie playback."""
|
|
62
|
+
def __init__(self, message):
|
|
63
|
+
super().__init__(message)
|
|
64
|
+
self.message = message
|
|
65
|
+
|
|
66
|
+
def __str__(self):
|
|
67
|
+
return f"MoviePlaybackError: {self.message}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class MovieFileNotFoundError(MoviePlaybackError):
|
|
71
|
+
"""Exception raised when a movie file is not found."""
|
|
72
|
+
def __init__(self, filename):
|
|
73
|
+
super().__init__(f"Movie file not found: {filename}")
|
|
74
|
+
self.filename = filename
|
|
75
|
+
|
|
76
|
+
def __str__(self):
|
|
77
|
+
return f"MovieFileNotFoundError: {self.filename} does not exist."
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class MovieFileFormatError(MoviePlaybackError):
|
|
81
|
+
"""Exception raised when a movie file format is not supported."""
|
|
82
|
+
def __init__(self, filename):
|
|
83
|
+
super().__init__(f"Movie file format not supported: {filename}")
|
|
84
|
+
self.filename = filename
|
|
85
|
+
|
|
86
|
+
def __str__(self):
|
|
87
|
+
return f"MovieFileFormatError: {self.filename} is not a supported movie format."
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class MovieAudioError(MoviePlaybackError):
|
|
91
|
+
"""Exception raised when there is an error with movie audio playback."""
|
|
92
|
+
def __init__(self, message):
|
|
93
|
+
super().__init__(message)
|
|
94
|
+
self.message = message
|
|
95
|
+
|
|
96
|
+
def __str__(self):
|
|
97
|
+
return f"MovieAudioError: {self.message}"
|
|
98
|
+
|
|
99
|
+
# ------------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
class MovieMetadata:
|
|
102
|
+
"""Class for storing metadata about a movie file.
|
|
103
|
+
|
|
104
|
+
This class is used to store metadata about a movie file. This includes
|
|
105
|
+
information about the video and audio tracks in the movie. Metadata is
|
|
106
|
+
extracted from the movie file when the movie reader is opened.
|
|
107
|
+
|
|
108
|
+
This class is not intended to be used directly by users. It is used
|
|
109
|
+
internally by the `MovieFileReader` class to store metadata about the movie
|
|
110
|
+
file being read.
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
filename : str
|
|
115
|
+
The name (or path) of the movie file to extract metadata from.
|
|
116
|
+
size : tuple
|
|
117
|
+
The size of the movie in pixels (width, height).
|
|
118
|
+
frameRate : float
|
|
119
|
+
The frame rate of the movie in frames per second.
|
|
120
|
+
duration : float
|
|
121
|
+
The duration of the movie in seconds.
|
|
122
|
+
colorFormat : str
|
|
123
|
+
The color format of the movie (e.g. 'rgb24', etc.).
|
|
124
|
+
audioTrack : AudioMetadata or None
|
|
125
|
+
The audio track metadata.
|
|
126
|
+
|
|
127
|
+
"""
|
|
128
|
+
__slots__ = (
|
|
129
|
+
'_filename', '_size', '_frameRate', '_duration', '_frameInterval',
|
|
130
|
+
'_colorFormat', '_audioTrack')
|
|
131
|
+
|
|
132
|
+
def __init__(self, filename, size, frameRate, duration, colorFormat,
|
|
133
|
+
audioTrack=None):
|
|
134
|
+
self._filename = filename
|
|
135
|
+
self._size = size
|
|
136
|
+
self._frameRate = frameRate
|
|
137
|
+
self._duration = duration
|
|
138
|
+
self._frameInterval = 1.0 / self._frameRate
|
|
139
|
+
|
|
140
|
+
if isinstance(colorFormat, bytes):
|
|
141
|
+
colorFormat = colorFormat.decode('utf-8')
|
|
142
|
+
self._colorFormat = colorFormat
|
|
143
|
+
|
|
144
|
+
# audio track metadata
|
|
145
|
+
self._audioTrack = audioTrack
|
|
146
|
+
|
|
147
|
+
def __repr__(self):
|
|
148
|
+
return (
|
|
149
|
+
f"MovieMetadata(filename={self.filename}, "
|
|
150
|
+
f"size={self.size}, "
|
|
151
|
+
f"frameRate={self.frameRate}, "
|
|
152
|
+
f"duration={self.duration})")
|
|
153
|
+
|
|
154
|
+
def __str__(self):
|
|
155
|
+
return (
|
|
156
|
+
f"MovieMetadata(filename={self.filename}, "
|
|
157
|
+
f"size={self.size}, "
|
|
158
|
+
f"frameRate={self.frameRate}, "
|
|
159
|
+
f"duration={self.duration})")
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def filename(self):
|
|
163
|
+
"""The name (path) of the movie file (`str`).
|
|
164
|
+
|
|
165
|
+
"""
|
|
166
|
+
return self._filename
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def size(self):
|
|
170
|
+
"""The size of the movie in pixels (`tuple`).
|
|
171
|
+
|
|
172
|
+
"""
|
|
173
|
+
return self._size
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def frameRate(self):
|
|
177
|
+
"""The frame rate of the movie in frames per second (`float`).
|
|
178
|
+
|
|
179
|
+
"""
|
|
180
|
+
return self._frameRate
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def frameInterval(self):
|
|
184
|
+
"""The interval between frames in the movie in seconds (`float`).
|
|
185
|
+
|
|
186
|
+
"""
|
|
187
|
+
return self._frameInterval
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def duration(self):
|
|
191
|
+
"""The duration of the movie in seconds (`float`).
|
|
192
|
+
|
|
193
|
+
"""
|
|
194
|
+
return self._duration
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def colorFormat(self):
|
|
198
|
+
"""The color format of the movie (`str`).
|
|
199
|
+
|
|
200
|
+
"""
|
|
201
|
+
return self._colorFormat
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def audioTrack(self):
|
|
205
|
+
"""The audio track metadata (`AudioMetadata` or `None`).
|
|
206
|
+
|
|
207
|
+
"""
|
|
208
|
+
return self._audioTrack
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class MovieFileReader:
|
|
212
|
+
"""Read movie frames from file.
|
|
213
|
+
|
|
214
|
+
This class manages reading movie frames from a file or stream. The method
|
|
215
|
+
used to read the movie frames is determined by the `decoderLib` parameter.
|
|
216
|
+
|
|
217
|
+
Parameters
|
|
218
|
+
----------
|
|
219
|
+
filename : str
|
|
220
|
+
The name (or path) of the file to read the movie from.
|
|
221
|
+
decoderLib : str
|
|
222
|
+
The library to use to handle decoding the movie. The default is
|
|
223
|
+
'ffpyplayer'.
|
|
224
|
+
decoderOpts : dict or None
|
|
225
|
+
A dictionary of options to pass to the decoder. These option can be used
|
|
226
|
+
to control the quality of the movie, for example. The options depend on
|
|
227
|
+
the `decoderLib` in use. If `None`, the reader will use the default
|
|
228
|
+
options for the backend.
|
|
229
|
+
|
|
230
|
+
Notes
|
|
231
|
+
-----
|
|
232
|
+
* If `decoderLib='ffpyplayer'`, audio playback is handled externally by
|
|
233
|
+
SDL2. This means that audio playback is not synchronized with frame
|
|
234
|
+
presentation in PsychoPy. However, playback will not begin until the audio
|
|
235
|
+
track starts playing.
|
|
236
|
+
* Do not access private attributes or methods of this class directly since
|
|
237
|
+
doing so is not thread-safe. Use the public methods provided by this class
|
|
238
|
+
to interact with the movie reader.
|
|
239
|
+
|
|
240
|
+
"""
|
|
241
|
+
def __init__(self,
|
|
242
|
+
filename,
|
|
243
|
+
decoderLib='ffpyplayer',
|
|
244
|
+
decoderOpts=None):
|
|
245
|
+
|
|
246
|
+
self._filename = filename
|
|
247
|
+
self._decoderLib = decoderLib
|
|
248
|
+
self._decoderOpts = {} if decoderOpts is None else decoderOpts
|
|
249
|
+
|
|
250
|
+
# thread for the reader
|
|
251
|
+
self._player = None # player interface object
|
|
252
|
+
|
|
253
|
+
# movie information
|
|
254
|
+
self._metadata = None # metadata object
|
|
255
|
+
|
|
256
|
+
# store decoded video segmenets in memory
|
|
257
|
+
self._frameStore = []
|
|
258
|
+
|
|
259
|
+
# callbacks for video events
|
|
260
|
+
self._streamEOFCallback = None
|
|
261
|
+
|
|
262
|
+
# video segment format
|
|
263
|
+
# [{'video': videoFrame, 'audio': audioFrame, 'pts': pts}, ...]
|
|
264
|
+
|
|
265
|
+
def __hash__(self):
|
|
266
|
+
"""Use the absolute file path as the hash value since we only allow one
|
|
267
|
+
instance per file.
|
|
268
|
+
"""
|
|
269
|
+
return hash(os.path.abspath(self._filename))
|
|
270
|
+
|
|
271
|
+
def _clearFrameQueue(self):
|
|
272
|
+
"""Clear the frame queue in a thread-safe way.
|
|
273
|
+
"""
|
|
274
|
+
with self._frameQueue.mutex:
|
|
275
|
+
self._frameQueue.queue.clear()
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def decoderLib(self):
|
|
279
|
+
"""The library used to decode the movie (`str`).
|
|
280
|
+
|
|
281
|
+
"""
|
|
282
|
+
return self._decoderLib
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def frameSize(self):
|
|
286
|
+
"""The frame size of the movie in pixels (`tuple`).
|
|
287
|
+
|
|
288
|
+
This is only valid after calling `open()`. If not, the value is
|
|
289
|
+
`(-1, -1)`.
|
|
290
|
+
|
|
291
|
+
"""
|
|
292
|
+
return self._srcFrameSize
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def frameInterval(self):
|
|
296
|
+
"""The interval between frames in the movie in seconds (`float`).
|
|
297
|
+
|
|
298
|
+
This is only valid after calling `open()`. If not, the value is `-1`.
|
|
299
|
+
|
|
300
|
+
"""
|
|
301
|
+
return self._frameInterval
|
|
302
|
+
|
|
303
|
+
@property
|
|
304
|
+
def frameRate(self):
|
|
305
|
+
"""The frame rate of the movie in frames per second (`float`).
|
|
306
|
+
|
|
307
|
+
This is only valid after calling `open()`. If not, the value is `-1`.
|
|
308
|
+
|
|
309
|
+
"""
|
|
310
|
+
return self._frameRate
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def duration(self):
|
|
314
|
+
"""The duration of the movie in seconds (`float`).
|
|
315
|
+
|
|
316
|
+
This is only valid after calling `open()`. If not, the value is `-1`.
|
|
317
|
+
|
|
318
|
+
"""
|
|
319
|
+
return self._duration
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def volume(self):
|
|
323
|
+
"""The volume level of the movie player (`float`).
|
|
324
|
+
|
|
325
|
+
This is only valid after calling `open()`. If not, the value is `0.0`.
|
|
326
|
+
|
|
327
|
+
"""
|
|
328
|
+
if self._decoderLib == 'ffpyplayer':
|
|
329
|
+
return self._getVolumeFFPyPlayer()
|
|
330
|
+
else:
|
|
331
|
+
raise NotImplementedError(
|
|
332
|
+
'Volume control is not implemented for this decoder library.')
|
|
333
|
+
|
|
334
|
+
@volume.setter
|
|
335
|
+
def volume(self, value):
|
|
336
|
+
"""Set the volume level of the movie player (`float`).
|
|
337
|
+
|
|
338
|
+
This is only valid after calling `open()`. If not, the value is `0.0`.
|
|
339
|
+
|
|
340
|
+
"""
|
|
341
|
+
if self._decoderLib == 'ffpyplayer':
|
|
342
|
+
self._setVolumeFFPyPlayer(value)
|
|
343
|
+
else:
|
|
344
|
+
raise NotImplementedError(
|
|
345
|
+
'Volume control is not implemented for this decoder library.')
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def filename(self):
|
|
349
|
+
"""The name (path) of the movie file (`str`).
|
|
350
|
+
|
|
351
|
+
This cannot be changed after the reader has been opened.
|
|
352
|
+
|
|
353
|
+
"""
|
|
354
|
+
return self._filename
|
|
355
|
+
|
|
356
|
+
def load(self, filename):
|
|
357
|
+
"""Load a movie file.
|
|
358
|
+
|
|
359
|
+
This is an alias for `setMovie()` to synchronize naming with other video
|
|
360
|
+
classes around PsychoPy.
|
|
361
|
+
|
|
362
|
+
Parameters
|
|
363
|
+
----------
|
|
364
|
+
filename : str
|
|
365
|
+
The name (path) of the file to read the movie from.
|
|
366
|
+
|
|
367
|
+
"""
|
|
368
|
+
self.setMovie(filename)
|
|
369
|
+
|
|
370
|
+
def setMovie(self, filename):
|
|
371
|
+
"""Set the movie file to read from and open it.
|
|
372
|
+
|
|
373
|
+
If there is a movie file currently open, it will be closed before
|
|
374
|
+
opening the new movie file. Playback will be reset to the beginning of
|
|
375
|
+
the movie.
|
|
376
|
+
|
|
377
|
+
Parameters
|
|
378
|
+
----------
|
|
379
|
+
filename : str
|
|
380
|
+
The name (path) of the file to read the movie from.
|
|
381
|
+
|
|
382
|
+
"""
|
|
383
|
+
if self.isOpen:
|
|
384
|
+
self.close()
|
|
385
|
+
|
|
386
|
+
# check if the file exists and is readable
|
|
387
|
+
if not os.path.isfile(filename):
|
|
388
|
+
raise IOError('Movie file does not exist: {}'.format(filename))
|
|
389
|
+
|
|
390
|
+
self._filename = filename
|
|
391
|
+
|
|
392
|
+
self.open()
|
|
393
|
+
|
|
394
|
+
def getMetadata(self):
|
|
395
|
+
"""Get metadata about the movie file.
|
|
396
|
+
|
|
397
|
+
This function returns a `MovieMetadata` object containing metadata
|
|
398
|
+
about the movie file. This includes information about the video and audio
|
|
399
|
+
tracks in the movie. Metadata is extracted from the movie file when the
|
|
400
|
+
movie reader is opened.
|
|
401
|
+
|
|
402
|
+
Returns
|
|
403
|
+
-------
|
|
404
|
+
MovieMetadata
|
|
405
|
+
Movie metadata object. If no movie is loaded, return a
|
|
406
|
+
`NULL_MOVIE_METADATA` object instead of `None`. At a minimum,
|
|
407
|
+
ensure that fields `duration`, `size`, and `frameRate` are
|
|
408
|
+
populated if a valid movie is loaded.
|
|
409
|
+
|
|
410
|
+
"""
|
|
411
|
+
if self._metadata is None:
|
|
412
|
+
return NULL_MOVIE_METADATA
|
|
413
|
+
# raise ValueError('Movie metadata not available. Movie not open.')
|
|
414
|
+
|
|
415
|
+
return self._metadata
|
|
416
|
+
|
|
417
|
+
# --------------------------------------------------------------------------
|
|
418
|
+
# Backend-specific reader interface methods
|
|
419
|
+
#
|
|
420
|
+
# These methods are used to interface with the backend specified by the
|
|
421
|
+
# `decoderLib` parameter. The methods are not intended to be used directly
|
|
422
|
+
# by users. In the future, these will likely be moved into separate classes
|
|
423
|
+
# for each backend. Methods are suffixed with the backend name and are
|
|
424
|
+
# selected based on the `decoderLib` parameter inside public methods which
|
|
425
|
+
# relate to them (e.g. `open()` will call `_openFFPyPlayer()` if the backend
|
|
426
|
+
# is `ffpyplayer`).
|
|
427
|
+
#
|
|
428
|
+
|
|
429
|
+
# --------------------------------------------------------------------------
|
|
430
|
+
# FFPyPlayer specific methods
|
|
431
|
+
#
|
|
432
|
+
|
|
433
|
+
def _openFFPyPlayer(self):
|
|
434
|
+
"""Open a movie reader using FFPyPlayer.
|
|
435
|
+
|
|
436
|
+
This function opens the movie file and extracts metadata about the movie
|
|
437
|
+
file. Metadata will be accessible via the `getMetadata()` method.
|
|
438
|
+
|
|
439
|
+
"""
|
|
440
|
+
# import in the class too avoid hard dependency on ffpyplayer
|
|
441
|
+
try:
|
|
442
|
+
from ffpyplayer.player import MediaPlayer
|
|
443
|
+
except ImportError:
|
|
444
|
+
raise ImportError(
|
|
445
|
+
'The `ffpyplayer` library is required to read movie files with '
|
|
446
|
+
'`decoderLib=ffpyplayer`.')
|
|
447
|
+
|
|
448
|
+
logging.info("Opening movie file: {}".format(self._filename))
|
|
449
|
+
|
|
450
|
+
# Using sync to audio since it allows us to poll the player for frames
|
|
451
|
+
# any number of frames and allows the audio to be played at the correct
|
|
452
|
+
# rate if using the SDL2 interface
|
|
453
|
+
syncMode = 'audio'
|
|
454
|
+
|
|
455
|
+
# default options
|
|
456
|
+
defaultFFOpts = {
|
|
457
|
+
'paused': True,
|
|
458
|
+
'sync': syncMode, # always use audio sync
|
|
459
|
+
'an': False,
|
|
460
|
+
'volume': 0.0, # mute
|
|
461
|
+
'loop': 1, # number of replays (0=infinite, 1=once, 2=twice, etc.)
|
|
462
|
+
'infbuf': True
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
# merge user settings with defaults, user settings take precedence
|
|
466
|
+
defaultFFOpts.update(self._decoderOpts)
|
|
467
|
+
self._decoderOpts = defaultFFOpts
|
|
468
|
+
|
|
469
|
+
# create media player interface
|
|
470
|
+
self._player = MediaPlayer(
|
|
471
|
+
self._filename,
|
|
472
|
+
ff_opts=self._decoderOpts)
|
|
473
|
+
|
|
474
|
+
self._player.set_mute(True) # mute the player first
|
|
475
|
+
self._player.set_pause(False)
|
|
476
|
+
|
|
477
|
+
# Get metadata and 'warm-up' the player to ensure it is responsive
|
|
478
|
+
# before we start decoding frames.
|
|
479
|
+
|
|
480
|
+
# wait for valid metadata to be available
|
|
481
|
+
logging.debug("Waiting for movie metadata...")
|
|
482
|
+
startTime = time.time()
|
|
483
|
+
while time.time() - startTime < defaultTimeout: # 5 second timeout
|
|
484
|
+
movieMetadata = self._player.get_metadata()
|
|
485
|
+
# keep calling until we get a valid frame size
|
|
486
|
+
if movieMetadata['src_vid_size'] != (0, 0):
|
|
487
|
+
break
|
|
488
|
+
else:
|
|
489
|
+
raise RuntimeError(
|
|
490
|
+
'FFPyPlayer failed to extract metadata from the movie. Check '
|
|
491
|
+
'the movie file and decoder options.')
|
|
492
|
+
|
|
493
|
+
# warmup, takes a while before the video starts playing
|
|
494
|
+
startTime = time.time()
|
|
495
|
+
while time.time() - startTime < defaultTimeout: # 5 second timeout
|
|
496
|
+
frame, _ = self._player.get_frame()
|
|
497
|
+
if frame != None:
|
|
498
|
+
break
|
|
499
|
+
else:
|
|
500
|
+
raise RuntimeError(
|
|
501
|
+
'FFPyPlayer failed to start decoding the movie. Check the '
|
|
502
|
+
'movie file and decoder options.')
|
|
503
|
+
|
|
504
|
+
# go back to first frame
|
|
505
|
+
self._player.set_pause(True) # pause the player again
|
|
506
|
+
self._player.set_mute(False) # unmute the player
|
|
507
|
+
|
|
508
|
+
# seek to the beginning of the movie
|
|
509
|
+
self._player.seek(0.0, relative=False)
|
|
510
|
+
|
|
511
|
+
# compute frame rate and interval
|
|
512
|
+
numer, denom = movieMetadata['frame_rate']
|
|
513
|
+
frameRate = numer / denom
|
|
514
|
+
self._frameInterval = 1.0 / frameRate
|
|
515
|
+
|
|
516
|
+
# populate the metadata object with the movie metadata we got
|
|
517
|
+
self._metadata = MovieMetadata(
|
|
518
|
+
self._filename,
|
|
519
|
+
movieMetadata['src_vid_size'],
|
|
520
|
+
frameRate,
|
|
521
|
+
movieMetadata['duration'],
|
|
522
|
+
movieMetadata['src_pix_fmt'])
|
|
523
|
+
|
|
524
|
+
logging.debug("Movie metadata: {}".format(movieMetadata))
|
|
525
|
+
|
|
526
|
+
def _seekFFPyPlayer(self, reqPTS):
|
|
527
|
+
"""FFPyPlayer specific seek routine.
|
|
528
|
+
|
|
529
|
+
This is called by `seek()` when the `ffpyplayer` backend is in use.
|
|
530
|
+
Video decoding will be paused after calling this function.
|
|
531
|
+
|
|
532
|
+
Parameters
|
|
533
|
+
----------
|
|
534
|
+
reqPTS : float
|
|
535
|
+
The presentation timestamp (PTS) to seek to in seconds.
|
|
536
|
+
|
|
537
|
+
Returns
|
|
538
|
+
-------
|
|
539
|
+
float
|
|
540
|
+
The presentation timestamp (PTS) of the frame we landed on in
|
|
541
|
+
seconds.
|
|
542
|
+
|
|
543
|
+
"""
|
|
544
|
+
reqPTS = min(max(0.0, reqPTS), self._metadata.duration)
|
|
545
|
+
|
|
546
|
+
if self._player is None:
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
# clear the frame store
|
|
550
|
+
self._cleanUpFrameStore()
|
|
551
|
+
|
|
552
|
+
# seek to the desired PTS
|
|
553
|
+
self._player.seek(
|
|
554
|
+
reqPTS,
|
|
555
|
+
relative=False,
|
|
556
|
+
seek_by_bytes=False,
|
|
557
|
+
accurate=True)
|
|
558
|
+
|
|
559
|
+
return self._player.get_pts()
|
|
560
|
+
|
|
561
|
+
def _convertFrameToRGBFFPyPlayer(self, frame):
|
|
562
|
+
"""Convert a frame to RGB format.
|
|
563
|
+
|
|
564
|
+
This function converts a frame to RGB format. The frame is returned as
|
|
565
|
+
a Numpy array. The resulting array will be in the correct format to
|
|
566
|
+
upload to OpenGL as a texture.
|
|
567
|
+
|
|
568
|
+
Parameters
|
|
569
|
+
----------
|
|
570
|
+
frame : FFPyPlayer frame
|
|
571
|
+
The frame to convert.
|
|
572
|
+
|
|
573
|
+
Returns
|
|
574
|
+
-------
|
|
575
|
+
numpy.ndarray
|
|
576
|
+
The converted frame in RGB format.
|
|
577
|
+
|
|
578
|
+
"""
|
|
579
|
+
from ffpyplayer.pic import SWScale
|
|
580
|
+
|
|
581
|
+
if frame.get_pixel_format() == 'rgb24': # already converted
|
|
582
|
+
return frame
|
|
583
|
+
|
|
584
|
+
rgbImg = SWScale(
|
|
585
|
+
self._metadata.size[0], self._metadata.size[1], # width, height
|
|
586
|
+
frame.get_pixel_format(),
|
|
587
|
+
ofmt='rgb24').scale(frame)
|
|
588
|
+
|
|
589
|
+
return rgbImg
|
|
590
|
+
|
|
591
|
+
def _bufferFramesFFPyPlayer(self, start=0.0, end=None, units='seconds'):
|
|
592
|
+
"""Buffer frames from the movie file using FFPyPlayer.
|
|
593
|
+
|
|
594
|
+
Parameters
|
|
595
|
+
----------
|
|
596
|
+
start : float
|
|
597
|
+
The start time in seconds to buffer frames from.
|
|
598
|
+
end : float or int
|
|
599
|
+
The end time in seconds to buffer frames to. If `None`, the end
|
|
600
|
+
time is set to the duration of the movie. If `int`, the end time is
|
|
601
|
+
interpreted as a frame index.
|
|
602
|
+
units : str
|
|
603
|
+
The units to use for the start and end times. This can be 'seconds'
|
|
604
|
+
or 'frames'. If 'frames', the start and end times are interpreted as
|
|
605
|
+
frame indices.
|
|
606
|
+
|
|
607
|
+
"""
|
|
608
|
+
if self._player is None:
|
|
609
|
+
return
|
|
610
|
+
|
|
611
|
+
# check if we have a valid start time
|
|
612
|
+
if start < 0.0:
|
|
613
|
+
raise ValueError('Start time must be greater than or equal to 0.0.')
|
|
614
|
+
|
|
615
|
+
# check if we have a valid end time
|
|
616
|
+
if end is None:
|
|
617
|
+
end = self._metadata.duration
|
|
618
|
+
elif end < 0.0:
|
|
619
|
+
raise ValueError('End time must be greater than or equal to 0.0.')
|
|
620
|
+
|
|
621
|
+
# convert the start and end times to frame indices
|
|
622
|
+
if units == 'frames':
|
|
623
|
+
start = self._frameIndexToTimestamp(start)
|
|
624
|
+
end = self._frameIndexToTimestamp(end)
|
|
625
|
+
|
|
626
|
+
# seek to the start time
|
|
627
|
+
self._seekFFPyPlayer(start)
|
|
628
|
+
|
|
629
|
+
# buffer frames from the movie file
|
|
630
|
+
while True:
|
|
631
|
+
frame, status = self._player.get_frame()
|
|
632
|
+
|
|
633
|
+
if status == 'eof':
|
|
634
|
+
break
|
|
635
|
+
|
|
636
|
+
if frame is None:
|
|
637
|
+
break
|
|
638
|
+
|
|
639
|
+
img, curPts = frame
|
|
640
|
+
if curPts >= end:
|
|
641
|
+
break
|
|
642
|
+
if curPts >= start:
|
|
643
|
+
# convert the frame to RGB format
|
|
644
|
+
rgbImg = self._convertFrameToRGB(img)
|
|
645
|
+
self._frameStore.append((rgbImg, curPts, status))
|
|
646
|
+
|
|
647
|
+
def _getFrameFFPyPlayer(self, reqPTS=0.0):
|
|
648
|
+
"""Get a frame from the movie file using FFPyPlayer.
|
|
649
|
+
|
|
650
|
+
This method gets the desired frame from the movie file. If it has not
|
|
651
|
+
been decoded yet, this function will ensure the frame is decoded and
|
|
652
|
+
made available.
|
|
653
|
+
|
|
654
|
+
Parameters
|
|
655
|
+
----------
|
|
656
|
+
reqPTS : float
|
|
657
|
+
The presentation timestamp (PTS) of the frame to get in seconds.
|
|
658
|
+
This hints the reader to which frame to decode and return.
|
|
659
|
+
|
|
660
|
+
Returns
|
|
661
|
+
-------
|
|
662
|
+
tuple
|
|
663
|
+
Video data (`ndarray`), presentation timestamp (PTS), and status.
|
|
664
|
+
The status value may be backend specific.
|
|
665
|
+
|
|
666
|
+
"""
|
|
667
|
+
# check if we have a player object, return None if not
|
|
668
|
+
if self._player is None:
|
|
669
|
+
return None
|
|
670
|
+
# raise ValueError('Movie reader is not open. Cannot grab frame.')
|
|
671
|
+
|
|
672
|
+
# normalzie the PTS to be between 0 and the duration of the movie
|
|
673
|
+
reqPTS = min(max(0.0, reqPTS),
|
|
674
|
+
self._metadata.duration + self._metadata.frameInterval)
|
|
675
|
+
|
|
676
|
+
# check if we have the frame in the store
|
|
677
|
+
frame = self._getFrameFromStore(reqPTS)
|
|
678
|
+
if frame is not None:
|
|
679
|
+
return frame
|
|
680
|
+
|
|
681
|
+
while 1: # keep getting frames until we reach the desired PTS
|
|
682
|
+
frame, status = self._player.get_frame()
|
|
683
|
+
|
|
684
|
+
if status == 'eof':
|
|
685
|
+
if self._streamEOFCallback is not None:
|
|
686
|
+
self._streamEOFCallback()
|
|
687
|
+
self._cleanUpFrameStore()
|
|
688
|
+
break
|
|
689
|
+
elif status == 'paused':
|
|
690
|
+
break
|
|
691
|
+
|
|
692
|
+
if frame is None:
|
|
693
|
+
break
|
|
694
|
+
|
|
695
|
+
img, curPts = frame # extract frame information
|
|
696
|
+
|
|
697
|
+
# if we have gotten the frame we are looking for, return it
|
|
698
|
+
if curPts + self._metadata.frameInterval >= reqPTS:
|
|
699
|
+
self._frameStore.append(
|
|
700
|
+
(self._convertFrameToRGBFFPyPlayer(img), curPts, status))
|
|
701
|
+
break
|
|
702
|
+
|
|
703
|
+
toReturn = self._getFrameFromStore(reqPTS)
|
|
704
|
+
|
|
705
|
+
self._cleanUpFrameStore(reqPTS) # clean up the frame store
|
|
706
|
+
|
|
707
|
+
return toReturn
|
|
708
|
+
|
|
709
|
+
# --------------------------------------------------------------------------
|
|
710
|
+
# File I/O methods
|
|
711
|
+
#
|
|
712
|
+
|
|
713
|
+
def open(self):
|
|
714
|
+
"""Open the movie file for reading.
|
|
715
|
+
|
|
716
|
+
Calling this will open the movie file and extract metadata to determine
|
|
717
|
+
the frame rate, size, and duration of the movie.
|
|
718
|
+
|
|
719
|
+
"""
|
|
720
|
+
logging.debug("Using decoder library: {}".format(self._decoderLib))
|
|
721
|
+
if self._decoderLib == 'ffpyplayer':
|
|
722
|
+
self._openFFPyPlayer()
|
|
723
|
+
elif self._decoderLib == 'opencv':
|
|
724
|
+
self._openOpenCV()
|
|
725
|
+
else:
|
|
726
|
+
raise ValueError(
|
|
727
|
+
'Unknown decoder library: {}'.format(self._decoderLib))
|
|
728
|
+
|
|
729
|
+
# register the reader with the global list of open movie readers
|
|
730
|
+
if self in _openMovieReaders:
|
|
731
|
+
raise RuntimeError(
|
|
732
|
+
'Movie reader already open for file: {}'.format(self._filename))
|
|
733
|
+
|
|
734
|
+
self._playbackStatus = NOT_STARTED # reset playback status
|
|
735
|
+
|
|
736
|
+
_openMovieReaders.add(self)
|
|
737
|
+
|
|
738
|
+
@property
|
|
739
|
+
def isOpen(self):
|
|
740
|
+
"""Whether the movie file is open (`bool`).
|
|
741
|
+
|
|
742
|
+
If `True`, the movie file is open and frames can be read from it. If
|
|
743
|
+
`False`, the movie file is closed and no more frames can be read from
|
|
744
|
+
it.
|
|
745
|
+
|
|
746
|
+
"""
|
|
747
|
+
return self in _openMovieReaders
|
|
748
|
+
|
|
749
|
+
def close(self):
|
|
750
|
+
"""Close the movie file or stream.
|
|
751
|
+
|
|
752
|
+
This will unload the movie file and free any resources associated with
|
|
753
|
+
it.
|
|
754
|
+
|
|
755
|
+
"""
|
|
756
|
+
self._freePlayer() # free the player
|
|
757
|
+
|
|
758
|
+
# clear frames from store
|
|
759
|
+
self._cleanUpFrameStore()
|
|
760
|
+
|
|
761
|
+
self._metadata = None # clear metadata
|
|
762
|
+
|
|
763
|
+
# remove the reader from the global list of open movie readers
|
|
764
|
+
if self in _openMovieReaders:
|
|
765
|
+
_openMovieReaders.remove(self)
|
|
766
|
+
|
|
767
|
+
def _freePlayer(self):
|
|
768
|
+
"""Clean up the player.
|
|
769
|
+
|
|
770
|
+
This function closes the player and clears the player object. Do not
|
|
771
|
+
call this method directly while the player is still in use.
|
|
772
|
+
|
|
773
|
+
"""
|
|
774
|
+
if self._player is None:
|
|
775
|
+
return
|
|
776
|
+
|
|
777
|
+
if self._decoderLib == 'ffpyplayer':
|
|
778
|
+
self._player.set_mute(True) # mute the player
|
|
779
|
+
self._player.set_pause(True) # pause the player
|
|
780
|
+
self._player.close_player()
|
|
781
|
+
|
|
782
|
+
self._player = None
|
|
783
|
+
|
|
784
|
+
def _cleanUpFrameStore(self, keepAfterPTS=None):
|
|
785
|
+
"""Clean up the frame store.
|
|
786
|
+
|
|
787
|
+
This function is called when the movie reader is closed. It clears the
|
|
788
|
+
frame queue and the video segment buffer.
|
|
789
|
+
|
|
790
|
+
Parameters
|
|
791
|
+
----------
|
|
792
|
+
keepAfterPTS : float
|
|
793
|
+
The presentation timestamp (PTS) to keep in the frame store. All
|
|
794
|
+
frames before this PTS will be removed from the frame store. If
|
|
795
|
+
`None`, all frames will be removed from the frame store.
|
|
796
|
+
|
|
797
|
+
"""
|
|
798
|
+
if keepAfterPTS is None:
|
|
799
|
+
self._frameStore.clear()
|
|
800
|
+
return
|
|
801
|
+
|
|
802
|
+
for i, frame in enumerate(self._frameStore):
|
|
803
|
+
if frame[1] >= keepAfterPTS - self._metadata.frameInterval:
|
|
804
|
+
self._frameStore = self._frameStore[i:]
|
|
805
|
+
break
|
|
806
|
+
|
|
807
|
+
def _getFrameFromStore(self, reqPTS):
|
|
808
|
+
"""Get a frame from the store.
|
|
809
|
+
|
|
810
|
+
This function gets a frame from the store. The frame is returned as
|
|
811
|
+
a Numpy array. The resulting array will be in the correct format to
|
|
812
|
+
upload to OpenGL as a texture.
|
|
813
|
+
|
|
814
|
+
Parameters
|
|
815
|
+
----------
|
|
816
|
+
reqPTS : float
|
|
817
|
+
The presentation timestamp (PTS) of the frame to get in seconds.
|
|
818
|
+
|
|
819
|
+
Returns
|
|
820
|
+
-------
|
|
821
|
+
numpy.ndarray
|
|
822
|
+
The converted frame in RGB format.
|
|
823
|
+
|
|
824
|
+
"""
|
|
825
|
+
if self._frameStore is None:
|
|
826
|
+
return None
|
|
827
|
+
|
|
828
|
+
for img, pts, status in self._frameStore:
|
|
829
|
+
if pts <= reqPTS < pts + self._metadata.frameInterval:
|
|
830
|
+
return (img, pts, status)
|
|
831
|
+
|
|
832
|
+
return None # no frame found
|
|
833
|
+
|
|
834
|
+
def setStreamEOFCallback(self, callback):
|
|
835
|
+
"""Set a callback function to be called when the end of the movie is
|
|
836
|
+
reached.
|
|
837
|
+
|
|
838
|
+
Parameters
|
|
839
|
+
----------
|
|
840
|
+
callback : callable or None
|
|
841
|
+
The callback function to call when the end of the movie is reached.
|
|
842
|
+
The function should take no arguments. If `None`, no callback
|
|
843
|
+
function will be called.
|
|
844
|
+
|
|
845
|
+
"""
|
|
846
|
+
if callback is None:
|
|
847
|
+
self._streamEOFCallback = None
|
|
848
|
+
return
|
|
849
|
+
|
|
850
|
+
if not callable(callback):
|
|
851
|
+
raise ValueError('Callback must be a callable function.')
|
|
852
|
+
|
|
853
|
+
self._streamEOFCallback = callback
|
|
854
|
+
|
|
855
|
+
def _frameIndexToTimestamp(self, frameIndex):
|
|
856
|
+
"""Convert a frame index to a presentation timestamp (PTS).
|
|
857
|
+
|
|
858
|
+
This function converts a frame index to a presentation timestamp (PTS)
|
|
859
|
+
in seconds. The frame index is the index of the frame in the movie file.
|
|
860
|
+
|
|
861
|
+
Parameters
|
|
862
|
+
----------
|
|
863
|
+
frameIndex : int
|
|
864
|
+
The index of the frame in the movie file.
|
|
865
|
+
|
|
866
|
+
Returns
|
|
867
|
+
-------
|
|
868
|
+
float
|
|
869
|
+
The presentation timestamp (PTS) of the frame in seconds.
|
|
870
|
+
|
|
871
|
+
"""
|
|
872
|
+
return frameIndex * self._metadata.frameInterval
|
|
873
|
+
|
|
874
|
+
def _timestampToFrameIndex(self, pts):
|
|
875
|
+
"""Convert a presentation timestamp (PTS) to a frame index.
|
|
876
|
+
|
|
877
|
+
This function converts a presentation timestamp (PTS) in seconds to a
|
|
878
|
+
frame index. The frame index is the index of the frame in the movie
|
|
879
|
+
file.
|
|
880
|
+
|
|
881
|
+
Parameters
|
|
882
|
+
----------
|
|
883
|
+
pts : float
|
|
884
|
+
The presentation timestamp (PTS) of the frame in seconds.
|
|
885
|
+
|
|
886
|
+
Returns
|
|
887
|
+
-------
|
|
888
|
+
int
|
|
889
|
+
The index of the frame in the movie file.
|
|
890
|
+
|
|
891
|
+
"""
|
|
892
|
+
return int(pts / self._metadata.frameInterval)
|
|
893
|
+
|
|
894
|
+
def _restartFFPyPlayer(self):
|
|
895
|
+
"""Restart the FFPyPlayer decoder.
|
|
896
|
+
|
|
897
|
+
This function restarts the FFPyPlayer decoder. This is useful if the
|
|
898
|
+
decoder has stopped working or if the movie file has changed.
|
|
899
|
+
|
|
900
|
+
"""
|
|
901
|
+
self._seekFFPyPlayer(0.0) # seek to the beginning of the movie
|
|
902
|
+
|
|
903
|
+
def pause(self, state=True):
|
|
904
|
+
"""Pause the movie reader.
|
|
905
|
+
|
|
906
|
+
This function pauses the movie reader. If the movie reader is already
|
|
907
|
+
paused, this function does nothing. If the movie reader is not open,
|
|
908
|
+
this function raises a `ValueError`.
|
|
909
|
+
|
|
910
|
+
Parameters
|
|
911
|
+
----------
|
|
912
|
+
state : bool
|
|
913
|
+
If `True`, the movie reader is paused. If `False`, the movie reader
|
|
914
|
+
is not paused. The default is `True`.
|
|
915
|
+
|
|
916
|
+
"""
|
|
917
|
+
if self._player is None:
|
|
918
|
+
return
|
|
919
|
+
|
|
920
|
+
self._player.set_pause(bool(state))
|
|
921
|
+
|
|
922
|
+
def seek(self, pts):
|
|
923
|
+
"""Seek to a specific presentation timestamp (PTS) in the movie.
|
|
924
|
+
|
|
925
|
+
This function seeks to a specific presentation timestamp (PTS) in the
|
|
926
|
+
movie file. The decoder will begin decoding frames from the specified
|
|
927
|
+
PTS. If the PTS is outside the range of the movie, the decoder will seek
|
|
928
|
+
to the end of the movie.
|
|
929
|
+
|
|
930
|
+
Seeking blocks the main thread until the desired frame is found.
|
|
931
|
+
|
|
932
|
+
Parameters
|
|
933
|
+
----------
|
|
934
|
+
pts : float
|
|
935
|
+
The presentation timestamp (PTS) to seek to in seconds.
|
|
936
|
+
|
|
937
|
+
"""
|
|
938
|
+
if self._decoderLib == 'ffpyplayer':
|
|
939
|
+
self._seekFFPyPlayer(pts)
|
|
940
|
+
elif self._decoderLib == 'opencv': # rough in support for opencv
|
|
941
|
+
raise NotImplementedError(
|
|
942
|
+
'The `opencv` library is not supported for movie reading.')
|
|
943
|
+
else:
|
|
944
|
+
raise ValueError(
|
|
945
|
+
'Unknown decoder library: {}'.format(self._decoderLib))
|
|
946
|
+
|
|
947
|
+
def mute(self, state=True):
|
|
948
|
+
"""Mute the movie reader.
|
|
949
|
+
|
|
950
|
+
This function mutes the movie reader. If the movie reader is already
|
|
951
|
+
muted, this function does nothing. If the movie reader is not open,
|
|
952
|
+
this function raises a `ValueError`.
|
|
953
|
+
|
|
954
|
+
Parameters
|
|
955
|
+
----------
|
|
956
|
+
state : bool
|
|
957
|
+
If `True`, the movie reader is muted. If `False`, the movie reader
|
|
958
|
+
is not muted. The default is `True`.
|
|
959
|
+
|
|
960
|
+
"""
|
|
961
|
+
if self._player is None:
|
|
962
|
+
return
|
|
963
|
+
|
|
964
|
+
self._player.set_mute(bool(state))
|
|
965
|
+
|
|
966
|
+
@property
|
|
967
|
+
def memoryUsed(self):
|
|
968
|
+
"""Get the amount of memory used for cache.
|
|
969
|
+
|
|
970
|
+
Returns
|
|
971
|
+
-------
|
|
972
|
+
int
|
|
973
|
+
The amount of memory used by the movie reader in bytes.
|
|
974
|
+
|
|
975
|
+
"""
|
|
976
|
+
# sum of bytes used by video segments
|
|
977
|
+
totalFramesDecoded = len(self._frameStore)
|
|
978
|
+
pixelSize = 3 if 'rgb' in self._srcPixelFormat else 4
|
|
979
|
+
pixelCount = self._srcFrameSize[0] * self._srcFrameSize[1]
|
|
980
|
+
|
|
981
|
+
return totalFramesDecoded * pixelCount * pixelSize
|
|
982
|
+
|
|
983
|
+
def getFrame(self, pts=0.0):
|
|
984
|
+
"""Get a frame from the movie file at the specified presentation
|
|
985
|
+
timestamp.
|
|
986
|
+
|
|
987
|
+
Parameters
|
|
988
|
+
----------
|
|
989
|
+
pts : float or None
|
|
990
|
+
The presentation timestamp (PTS) of the frame to get in seconds.
|
|
991
|
+
Timestamps can be as precise as six decimal places.
|
|
992
|
+
dropFrame : bool
|
|
993
|
+
If `True`, the frame is dropped if it is not available, and the
|
|
994
|
+
most recent frame will be returned immediately. If `False`, the
|
|
995
|
+
function will block until the desired frame is returned.
|
|
996
|
+
|
|
997
|
+
Returns
|
|
998
|
+
-------
|
|
999
|
+
tuple
|
|
1000
|
+
Video data.
|
|
24
1001
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
1002
|
+
"""
|
|
1003
|
+
if self._decoderLib == 'ffpyplayer':
|
|
1004
|
+
return self._getFrameFFPyPlayer(pts)
|
|
1005
|
+
|
|
1006
|
+
def getSubtitle(self):
|
|
1007
|
+
"""Get the subtitle from the movie file.
|
|
28
1008
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
GL = pyglet.gl
|
|
1009
|
+
This function returns the subtitle from the movie file. The subtitle is
|
|
1010
|
+
returned as a string. If no subtitle is available, this function returns
|
|
1011
|
+
`None`.
|
|
33
1012
|
|
|
34
|
-
|
|
35
|
-
|
|
1013
|
+
Returns
|
|
1014
|
+
-------
|
|
1015
|
+
str or None
|
|
1016
|
+
The subtitle from the movie file. If no subtitle is available, this
|
|
1017
|
+
function returns `None`.
|
|
36
1018
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
1019
|
+
"""
|
|
1020
|
+
if self._player is None:
|
|
1021
|
+
return ''
|
|
1022
|
+
#raise ValueError('Movie reader is not open. Cannot get subtitle.')
|
|
40
1023
|
|
|
41
|
-
|
|
1024
|
+
return ''
|
|
42
1025
|
|
|
1026
|
+
def _getVolumeFFPyPlayer(self):
|
|
1027
|
+
"""Get the volume of the movie player using the ffpyplayer library.
|
|
43
1028
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
1029
|
+
Returns
|
|
1030
|
+
-------
|
|
1031
|
+
float
|
|
1032
|
+
The volume level of the movie player, between 0.0 (mute) and 1.0 (full volume).
|
|
1033
|
+
"""
|
|
1034
|
+
if self._player is None:
|
|
1035
|
+
return 0.0
|
|
1036
|
+
|
|
1037
|
+
return self._player.get_volume()
|
|
1038
|
+
|
|
1039
|
+
def _setVolumeFFPyPlayer(self, volume):
|
|
1040
|
+
"""Set the volume of the movie player using the ffpyplayer library.
|
|
1041
|
+
|
|
1042
|
+
Parameters
|
|
1043
|
+
----------
|
|
1044
|
+
volume : float
|
|
1045
|
+
The volume level to set, between 0.0 (mute) and 1.0 (full volume).
|
|
1046
|
+
|
|
1047
|
+
"""
|
|
1048
|
+
if self._player is None:
|
|
1049
|
+
return
|
|
1050
|
+
|
|
1051
|
+
self._player.set_volume(volume)
|
|
1052
|
+
|
|
1053
|
+
def setVolume(self, volume):
|
|
1054
|
+
"""Set the volume of the movie player.
|
|
1055
|
+
|
|
1056
|
+
Parameters
|
|
1057
|
+
----------
|
|
1058
|
+
volume : float
|
|
1059
|
+
The volume level to set, between 0.0 (mute) and 1.0 (full volume).
|
|
1060
|
+
|
|
1061
|
+
"""
|
|
1062
|
+
if self._player is None:
|
|
1063
|
+
return
|
|
1064
|
+
|
|
1065
|
+
volume = min(1.0, max(0.0, float(volume)))
|
|
1066
|
+
|
|
1067
|
+
logging.debug("Setting movie volume to: {}".format(volume))
|
|
1068
|
+
|
|
1069
|
+
if self._decoderLib == 'ffpyplayer':
|
|
1070
|
+
self._setVolumeFFPyPlayer(volume)
|
|
1071
|
+
else:
|
|
1072
|
+
raise NotImplementedError(
|
|
1073
|
+
'Volume control is not implemented for this decoder library.')
|
|
1074
|
+
|
|
1075
|
+
def __del__(self):
|
|
1076
|
+
"""Close the movie file when the object is deleted.
|
|
1077
|
+
"""
|
|
1078
|
+
self.close()
|
|
47
1079
|
|
|
48
1080
|
|
|
49
1081
|
class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
50
1082
|
"""Class for presenting movie clips as stimuli.
|
|
51
1083
|
|
|
1084
|
+
This class is used to present movie clips loaded from file as stimuli in
|
|
1085
|
+
PsychoPy. Movies will play at the their native frame rate regardless of the
|
|
1086
|
+
refresh rate of the display.
|
|
1087
|
+
|
|
52
1088
|
Parameters
|
|
53
1089
|
----------
|
|
54
1090
|
win : :class:`~psychopy.visual.Window`
|
|
@@ -60,6 +1096,11 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
60
1096
|
Library to use for video decoding. By default, the 'preferred' library
|
|
61
1097
|
by PsychoPy developers is used. Default is `'ffpyplayer'`. An alert is
|
|
62
1098
|
raised if you are not using the preferred player.
|
|
1099
|
+
audioLib : str or None
|
|
1100
|
+
Library to use for audio decoding. If `movieLib` is `'ffpyplayer'`
|
|
1101
|
+
then this must be `'sdl2'` for audio playback. If `None`, the
|
|
1102
|
+
default audio library for the `movieLib` will be used (this will be
|
|
1103
|
+
`'sdl2'` for `movieLib='ffpyplayer'`).
|
|
63
1104
|
units : str
|
|
64
1105
|
Units to use when sizing the video frame on the window, affects how
|
|
65
1106
|
`size` is interpreted.
|
|
@@ -81,11 +1122,19 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
81
1122
|
autoStart : bool
|
|
82
1123
|
Automatically begin playback of the video when `flip()` is called.
|
|
83
1124
|
|
|
1125
|
+
Notes
|
|
1126
|
+
-----
|
|
1127
|
+
* Precise audio and visual syncronization is not guaranteed when using
|
|
1128
|
+
the `ffpyplayer` library for video playback. If you require precise
|
|
1129
|
+
synchronization, consider extracting the audio from the movie file and
|
|
1130
|
+
playing it separately using the `sound.Sound` class instead.
|
|
1131
|
+
|
|
84
1132
|
"""
|
|
85
1133
|
def __init__(self,
|
|
86
1134
|
win,
|
|
87
1135
|
filename="",
|
|
88
1136
|
movieLib=u'ffpyplayer',
|
|
1137
|
+
audioLib=None,
|
|
89
1138
|
units='pix',
|
|
90
1139
|
size=None,
|
|
91
1140
|
pos=(0.0, 0.0),
|
|
@@ -107,12 +1156,6 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
107
1156
|
interpolate=True,
|
|
108
1157
|
autoStart=True):
|
|
109
1158
|
|
|
110
|
-
# # check if we have the VLC lib
|
|
111
|
-
# if not haveFFPyPlayer:
|
|
112
|
-
# raise ImportError(
|
|
113
|
-
# 'Cannot import package `ffpyplayer`, therefore `FFMovieStim` '
|
|
114
|
-
# 'cannot be used this session.')
|
|
115
|
-
|
|
116
1159
|
# what local vars are defined (these are the init params) for use
|
|
117
1160
|
self._initParams = dir()
|
|
118
1161
|
self._initParams.remove('self')
|
|
@@ -134,13 +1177,52 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
134
1177
|
self.opacity = opacity
|
|
135
1178
|
|
|
136
1179
|
# playback stuff
|
|
1180
|
+
self._movieLib = movieLib
|
|
1181
|
+
self._decoderOpts = {}
|
|
1182
|
+
self._player = None # player interface object
|
|
137
1183
|
self._filename = pathToString(filename)
|
|
138
1184
|
self._volume = volume
|
|
139
1185
|
self._noAudio = noAudio # cannot be changed
|
|
140
|
-
self.
|
|
1186
|
+
self._loop = loop
|
|
1187
|
+
self._loopCount = 0 # number of times the movie has looped
|
|
141
1188
|
self._recentFrame = None
|
|
142
1189
|
self._autoStart = autoStart
|
|
143
1190
|
self._isLoaded = False
|
|
1191
|
+
self._pts = 0.0
|
|
1192
|
+
self._movieTime = 0.0 # current movie position in seconds
|
|
1193
|
+
self._lastFrameAbsTime = -1.0 # absolute time of the last frame
|
|
1194
|
+
|
|
1195
|
+
# internal status flags for keeping track of the playback state
|
|
1196
|
+
self._playbackStatus = NOT_STARTED
|
|
1197
|
+
self._wasPaused = False # was the movie paused?
|
|
1198
|
+
|
|
1199
|
+
# audio stuff
|
|
1200
|
+
if audioLib is None and self._movieLib == 'ffpyplayer':
|
|
1201
|
+
self._audioLib = 'sdl2'
|
|
1202
|
+
self._noAudio = False # use SDL2 for audio playback
|
|
1203
|
+
else:
|
|
1204
|
+
self._audioLib = audioLib
|
|
1205
|
+
self._noAudio = True # no audio if using a different library
|
|
1206
|
+
|
|
1207
|
+
# warn the user if they are using the SDL2 audio library that precise
|
|
1208
|
+
# A/V sync is not supported
|
|
1209
|
+
if self._audioLib == 'sdl2':
|
|
1210
|
+
logging.warning(
|
|
1211
|
+
'Using `sdl2` for audio playback via `ffpyplayer`. This is not '
|
|
1212
|
+
'recommended for applications requiring precise audio-visual '
|
|
1213
|
+
'synchronization.')
|
|
1214
|
+
else:
|
|
1215
|
+
raise MovieAudioError(
|
|
1216
|
+
"Movie audio playback is only supported with the 'sdl2' library "
|
|
1217
|
+
"at this time.")
|
|
1218
|
+
|
|
1219
|
+
# audio playback configuration
|
|
1220
|
+
self._audioConfig = {}
|
|
1221
|
+
self._audioTempFile = None # audio extracted from the movie
|
|
1222
|
+
self._audioSamples = [] # audio samples from the movie
|
|
1223
|
+
self._audioReader = None # audio reader object
|
|
1224
|
+
self._audioSampleRate = 44100 # audio sample rate
|
|
1225
|
+
self._audioChannels = 2 # number of audio channels
|
|
144
1226
|
|
|
145
1227
|
# OpenGL data
|
|
146
1228
|
self.interpolate = interpolate
|
|
@@ -149,16 +1231,43 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
149
1231
|
self._pixbuffId = GL.GLuint(0)
|
|
150
1232
|
self._textureId = GL.GLuint(0)
|
|
151
1233
|
|
|
152
|
-
# get the player interface for the desired `movieLib` and instance it
|
|
153
|
-
self._player = getMoviePlayer(movieLib)(self)
|
|
154
|
-
|
|
155
1234
|
# load a file if provided, otherwise the user must call `setMovie()`
|
|
156
1235
|
self._filename = pathToString(filename)
|
|
157
1236
|
if self._filename: # load a movie if provided
|
|
158
1237
|
self.loadMovie(self._filename)
|
|
159
1238
|
|
|
160
1239
|
self.autoLog = autoLog
|
|
161
|
-
|
|
1240
|
+
|
|
1241
|
+
@property
|
|
1242
|
+
def size(self):
|
|
1243
|
+
return BaseVisualStim.size.fget(self)
|
|
1244
|
+
|
|
1245
|
+
@size.setter
|
|
1246
|
+
def size(self, value):
|
|
1247
|
+
# store requested size
|
|
1248
|
+
self._requestedSize = value
|
|
1249
|
+
# if player isn't initialsied yet, do no more
|
|
1250
|
+
if not self._hasPlayer:
|
|
1251
|
+
return
|
|
1252
|
+
# duplicate if necessary
|
|
1253
|
+
if isinstance(value, (float, int)):
|
|
1254
|
+
value = [value, value]
|
|
1255
|
+
# make sure value is a list so we can assign indices
|
|
1256
|
+
if isinstance(value, tuple):
|
|
1257
|
+
value = [val for val in value]
|
|
1258
|
+
# handle aspect ratio
|
|
1259
|
+
if value[0] is None and value[1] is None:
|
|
1260
|
+
# if both values are none, use original size
|
|
1261
|
+
value = layout.Size(self.frameSize, units="pix", win=self.win)
|
|
1262
|
+
elif value[0] is None:
|
|
1263
|
+
# if width is None, use height and maintain aspect ratio
|
|
1264
|
+
value[0] = (self.frameSize[0] / self.frameSize[1]) * value[1]
|
|
1265
|
+
elif value[1] is None:
|
|
1266
|
+
# if height is None, use width and maintain aspect ratio
|
|
1267
|
+
value[1] = (self.frameSize[1] / self.frameSize[0]) * value[0]
|
|
1268
|
+
# set as normal
|
|
1269
|
+
BaseVisualStim.size.fset(self, value)
|
|
1270
|
+
|
|
162
1271
|
@property
|
|
163
1272
|
def filename(self):
|
|
164
1273
|
"""File name for the loaded video (`str`)."""
|
|
@@ -187,6 +1296,36 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
187
1296
|
"""Frame rate of the movie in Hertz (`float`).
|
|
188
1297
|
"""
|
|
189
1298
|
return self._player.metadata.frameRate
|
|
1299
|
+
|
|
1300
|
+
@property
|
|
1301
|
+
def loop(self):
|
|
1302
|
+
"""Whether the movie will loop when it reaches the end (`bool`).
|
|
1303
|
+
|
|
1304
|
+
If `True`, the movie will start over from the beginning when it reaches
|
|
1305
|
+
the end. If `False`, the movie will stop at the end.
|
|
1306
|
+
|
|
1307
|
+
"""
|
|
1308
|
+
return self._loop
|
|
1309
|
+
|
|
1310
|
+
@loop.setter
|
|
1311
|
+
def loop(self, value):
|
|
1312
|
+
"""Set whether the movie will loop when it reaches the end.
|
|
1313
|
+
|
|
1314
|
+
Parameters
|
|
1315
|
+
----------
|
|
1316
|
+
value : bool
|
|
1317
|
+
If `True`, the movie will loop when it reaches the end. If `False`,
|
|
1318
|
+
the movie will stop at the end.
|
|
1319
|
+
|
|
1320
|
+
"""
|
|
1321
|
+
self._loop = bool(value)
|
|
1322
|
+
|
|
1323
|
+
@property
|
|
1324
|
+
def loopCount(self):
|
|
1325
|
+
"""Number of times the movie has looped (`int`).
|
|
1326
|
+
|
|
1327
|
+
"""
|
|
1328
|
+
return self._player.loopCount if self._hasPlayer else 0
|
|
190
1329
|
|
|
191
1330
|
@property
|
|
192
1331
|
def _hasPlayer(self):
|
|
@@ -194,15 +1333,23 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
194
1333
|
"""
|
|
195
1334
|
# use this property to check if the player instance is started in
|
|
196
1335
|
# methods which require it
|
|
197
|
-
return self._player is not None
|
|
1336
|
+
return hasattr(self, "_player") and self._player is not None
|
|
1337
|
+
|
|
1338
|
+
# --------------------------------------------------------------------------
|
|
1339
|
+
# Movie file handlers
|
|
1340
|
+
#
|
|
1341
|
+
|
|
1342
|
+
def _setFileName(self, filename):
|
|
1343
|
+
"""Set the file name of the movie.
|
|
198
1344
|
|
|
199
|
-
|
|
200
|
-
|
|
1345
|
+
This function sets the file name of the movie. The file name is used
|
|
1346
|
+
to load the movie from disk. If the file name is not set, the movie
|
|
1347
|
+
will not be loaded.
|
|
201
1348
|
|
|
202
1349
|
Parameters
|
|
203
1350
|
----------
|
|
204
1351
|
filename : str
|
|
205
|
-
|
|
1352
|
+
The file name of the movie.
|
|
206
1353
|
|
|
207
1354
|
"""
|
|
208
1355
|
# If given `default.mp4`, sub in full path
|
|
@@ -213,23 +1360,169 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
213
1360
|
|
|
214
1361
|
# check if the file has can be loaded
|
|
215
1362
|
if not os.path.isfile(filename):
|
|
216
|
-
raise
|
|
217
|
-
filename))
|
|
1363
|
+
raise MovieFileNotFoundError(
|
|
1364
|
+
"Cannot open movie file `{}`".format(filename))
|
|
218
1365
|
else:
|
|
219
1366
|
# If given a recording component, use its last clip
|
|
220
1367
|
if hasattr(filename, "lastClip"):
|
|
221
1368
|
filename = filename.lastClip
|
|
222
1369
|
|
|
223
|
-
self._filename = filename
|
|
224
|
-
self._player.load(self._filename)
|
|
1370
|
+
self._filename = os.path.abspath(str(filename))
|
|
225
1371
|
|
|
226
|
-
|
|
1372
|
+
def loadMovie(self, filename):
|
|
1373
|
+
"""Load a movie file from disk.
|
|
1374
|
+
|
|
1375
|
+
Parameters
|
|
1376
|
+
----------
|
|
1377
|
+
filename : str
|
|
1378
|
+
Path to movie file. Must be a format that FFMPEG supports.
|
|
1379
|
+
|
|
1380
|
+
"""
|
|
1381
|
+
# Set the movie file name, this handles normalizing the path and
|
|
1382
|
+
# checking if the file exists.
|
|
1383
|
+
|
|
1384
|
+
self._setFileName(filename)
|
|
1385
|
+
|
|
1386
|
+
# Time opening the movie file
|
|
1387
|
+
|
|
1388
|
+
t0 = time.time() # time it
|
|
1389
|
+
logging.debug(
|
|
1390
|
+
"Opening movie file: {}".format(self._filename))
|
|
1391
|
+
|
|
1392
|
+
# Extact the audio track so we can read samples from it. This needs to
|
|
1393
|
+
# be done before the movie is opened by the player to avoid file access
|
|
1394
|
+
# issues. The audio track is extracted to a temporary file which is
|
|
1395
|
+
# deleted when the movie is closed.
|
|
1396
|
+
|
|
1397
|
+
disableAudio = False
|
|
1398
|
+
if not self._noAudio and self._audioLib not in ('sdl', 'sdl2'):
|
|
1399
|
+
# if using SDL, playback is handled by the ffpyplayer library so we
|
|
1400
|
+
# don't need to extract the audio track or setup the audio stream
|
|
1401
|
+
self._extractAudioTrack()
|
|
1402
|
+
disableAudio = True
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
self._decoderOpts['an'] = disableAudio
|
|
1406
|
+
|
|
1407
|
+
# Setup looping if the user has requested it. This is done by setting the
|
|
1408
|
+
# `loop` option in the decoder options so FFMPEG will loop the movie
|
|
1409
|
+
# automatically when it reaches the end. The loop count is reset to 0.
|
|
1410
|
+
|
|
1411
|
+
self._decoderOpts['loop'] = 0 if self._loop else 1
|
|
1412
|
+
self._loopCount = 0 # reset loop count
|
|
1413
|
+
|
|
1414
|
+
# Create the movie player interface, this is what decodes movie frames
|
|
1415
|
+
# in the background. We disable audio playback since we are using the
|
|
1416
|
+
# our own audio library for playback.
|
|
1417
|
+
|
|
1418
|
+
self._player = MovieFileReader(
|
|
1419
|
+
filename=self._filename,
|
|
1420
|
+
decoderLib=self._movieLib,
|
|
1421
|
+
decoderOpts=self._decoderOpts)
|
|
1422
|
+
|
|
1423
|
+
# Open the player, this will get metadata about the movie and start
|
|
1424
|
+
# decoding frames in the background.
|
|
1425
|
+
|
|
1426
|
+
self._player.open()
|
|
1427
|
+
|
|
1428
|
+
logging.debug(
|
|
1429
|
+
"Movie file opened in {:.2f} seconds".format(
|
|
1430
|
+
time.time() - t0))
|
|
1431
|
+
|
|
1432
|
+
# Setup the OpenGL buffers for the movie frames. The sizes of the
|
|
1433
|
+
# buffers are determined by the size of the movie frames obtained from
|
|
1434
|
+
# the player.
|
|
1435
|
+
|
|
1436
|
+
self._freeTextureBuffers() # free buffers (if any) before creating a new one
|
|
227
1437
|
self._setupTextureBuffers()
|
|
228
1438
|
|
|
1439
|
+
# update size in case frame size has changed
|
|
1440
|
+
self.size = self._requestedSize
|
|
1441
|
+
|
|
1442
|
+
# reset movie state and timekeeping variables
|
|
1443
|
+
self._playbackStatus = NOT_STARTED # reset playback status
|
|
1444
|
+
self._pts = 0.0 # reset presentation timestamp
|
|
1445
|
+
self._movieTime = 0.0 # reset movie time
|
|
229
1446
|
self._isLoaded = True
|
|
230
1447
|
|
|
1448
|
+
# set the volume to previous
|
|
1449
|
+
self.volume = self._volume
|
|
1450
|
+
|
|
1451
|
+
def _setupAudioStream(self):
|
|
1452
|
+
"""Setup the audio stream for the movie.
|
|
1453
|
+
"""
|
|
1454
|
+
# todo - handle setting up the audio library stream
|
|
1455
|
+
if self._noAudio or self._audioLib in ('sdl', 'sdl2'):
|
|
1456
|
+
return
|
|
1457
|
+
|
|
1458
|
+
def _pushAudioSamples(self):
|
|
1459
|
+
"""Push audio samples to the audio buffer.
|
|
1460
|
+
"""
|
|
1461
|
+
# todo - implement this
|
|
1462
|
+
if self._noAudio or self._audioLib in ('sdl', 'sdl2'):
|
|
1463
|
+
return
|
|
1464
|
+
|
|
1465
|
+
def _extractAudioTrack(self):
|
|
1466
|
+
"""Extract the audio track from the movie file.
|
|
1467
|
+
|
|
1468
|
+
This function extracts the audio track from the movie file and writes
|
|
1469
|
+
it to a temporary file. The temporary file is used to play the audio
|
|
1470
|
+
track in sync with the video frames.
|
|
1471
|
+
|
|
1472
|
+
"""
|
|
1473
|
+
t0 = time.time()
|
|
1474
|
+
logging.debug("Extracting audio track from movie file: {}".format(
|
|
1475
|
+
self._filename))
|
|
1476
|
+
|
|
1477
|
+
# Create a temporary file where the audio track will be written to. The
|
|
1478
|
+
# file will be deleted when the movie is closed.
|
|
1479
|
+
self._audioTempFile = tempfile.NamedTemporaryFile(
|
|
1480
|
+
suffix='.wav',
|
|
1481
|
+
delete=False)
|
|
1482
|
+
|
|
1483
|
+
# use moviepy to extract the audio track
|
|
1484
|
+
import moviepy as mp
|
|
1485
|
+
|
|
1486
|
+
videoClip = mp.VideoFileClip(
|
|
1487
|
+
self._filename)
|
|
1488
|
+
audioTrackData = videoClip.audio
|
|
1489
|
+
|
|
1490
|
+
audioTrackData.write_audiofile(
|
|
1491
|
+
self._audioTempFile.name,
|
|
1492
|
+
codec='pcm_s16le',
|
|
1493
|
+
fps=44100,
|
|
1494
|
+
nbytes=2,
|
|
1495
|
+
logger=None)
|
|
1496
|
+
|
|
1497
|
+
videoClip.close()
|
|
1498
|
+
self._audioTempFile.close()
|
|
1499
|
+
|
|
1500
|
+
logging.warning(
|
|
1501
|
+
"Audio track written to temporary file: {} ({} bytes)".format(
|
|
1502
|
+
self._audioTempFile.name,
|
|
1503
|
+
os.path.getsize(self._audioTempFile.name)))
|
|
1504
|
+
|
|
1505
|
+
logging.warning(
|
|
1506
|
+
"Audio track extraction completed in {:.2f} seconds".format(
|
|
1507
|
+
time.time() - t0))
|
|
1508
|
+
|
|
1509
|
+
# use soundfile to read the audio samples from the temporary file
|
|
1510
|
+
import soundfile as sf
|
|
1511
|
+
samples, sr = sf.read(
|
|
1512
|
+
self._audioTempFile.name,
|
|
1513
|
+
dtype='float32',
|
|
1514
|
+
always_2d=True)
|
|
1515
|
+
self._audioSampleRate = sr
|
|
1516
|
+
self._audioSamples = samples
|
|
1517
|
+
|
|
1518
|
+
# compute the size of the audio samples in bytes
|
|
1519
|
+
audioSize = self._audioSamples.nbytes
|
|
1520
|
+
|
|
1521
|
+
logging.debug(
|
|
1522
|
+
"Audio track size: {} bytes".format(audioSize))
|
|
1523
|
+
|
|
231
1524
|
def load(self, filename):
|
|
232
|
-
"""Load a movie file from disk (alias of `
|
|
1525
|
+
"""Load a movie file from disk (alias of `setMovie`).
|
|
233
1526
|
|
|
234
1527
|
Parameters
|
|
235
1528
|
----------
|
|
@@ -237,7 +1530,7 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
237
1530
|
Path to movie file. Must be a format that FFMPEG supports.
|
|
238
1531
|
|
|
239
1532
|
"""
|
|
240
|
-
self.
|
|
1533
|
+
self.setMovie(filename=filename)
|
|
241
1534
|
|
|
242
1535
|
def unload(self, log=True):
|
|
243
1536
|
"""Stop and unload the movie.
|
|
@@ -248,10 +1541,60 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
248
1541
|
Log this event.
|
|
249
1542
|
|
|
250
1543
|
"""
|
|
251
|
-
self.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
1544
|
+
if self._isLoaded:
|
|
1545
|
+
self._player.close()
|
|
1546
|
+
self._freeTextureBuffers() # free buffer before creating a new one
|
|
1547
|
+
self._isLoaded = False
|
|
1548
|
+
|
|
1549
|
+
# --------------------------------------------------------------------------
|
|
1550
|
+
# Time and frame management
|
|
1551
|
+
#
|
|
1552
|
+
|
|
1553
|
+
def _updateMoviePos(self):
|
|
1554
|
+
"""Update the movie position.
|
|
1555
|
+
|
|
1556
|
+
This function updates the movie position. The movie position is the
|
|
1557
|
+
presentation timestamp (PTS) of the current frame. The PTS is updated
|
|
1558
|
+
when the movie is played or paused.
|
|
1559
|
+
|
|
1560
|
+
"""
|
|
1561
|
+
# todo - use 'geFutureFlipTime' to get the time of the next flip to align
|
|
1562
|
+
|
|
1563
|
+
# the movie with the flip time
|
|
1564
|
+
now = core.getTime()
|
|
1565
|
+
# if self._playbackStatus == SEEKING:
|
|
1566
|
+
# self._lastFrameAbsTime = now
|
|
1567
|
+
# # if we are seeking, the movie time is not updated until done
|
|
1568
|
+
# return
|
|
1569
|
+
|
|
1570
|
+
if self._playbackStatus == PLAYING:
|
|
1571
|
+
# check if were at the end of the movie
|
|
1572
|
+
if self._movieTime < self.duration:
|
|
1573
|
+
# determine the current movie time
|
|
1574
|
+
self._movieTime = min(
|
|
1575
|
+
self._movieTime + (now - self._lastFrameAbsTime),
|
|
1576
|
+
self.duration)
|
|
1577
|
+
else:
|
|
1578
|
+
if self._loop:
|
|
1579
|
+
# if looping, reset the movie time to 0
|
|
1580
|
+
self._loopCount += 1 # increment loop count
|
|
1581
|
+
self._movieTime = 0.0
|
|
1582
|
+
else:
|
|
1583
|
+
# if not looping, stop playback
|
|
1584
|
+
self._player.pause(True)
|
|
1585
|
+
self._movieTime = self.duration # set to end of movie
|
|
1586
|
+
self._playbackStatus = FINISHED # indicate movie is done
|
|
1587
|
+
|
|
1588
|
+
elif self._playbackStatus == NOT_STARTED:
|
|
1589
|
+
self._movieTime = 0.0 # reset movie time to 0
|
|
1590
|
+
|
|
1591
|
+
# if paused, the movie time does not advance but we still need to
|
|
1592
|
+
# update the last frame time
|
|
1593
|
+
self._lastFrameAbsTime = now # always updates
|
|
1594
|
+
|
|
1595
|
+
# --------------------------------------------------------------------------
|
|
1596
|
+
# Drawing and rendering
|
|
1597
|
+
#
|
|
255
1598
|
|
|
256
1599
|
@property
|
|
257
1600
|
def frameTexture(self):
|
|
@@ -260,30 +1603,259 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
260
1603
|
`updateVideoFrame` to keep this up to date.
|
|
261
1604
|
|
|
262
1605
|
"""
|
|
263
|
-
return self._textureId
|
|
1606
|
+
return self._textureId
|
|
1607
|
+
|
|
1608
|
+
def updateVideoFrame(self):
|
|
1609
|
+
"""Update the present video frame. The next call to `draw()` will make
|
|
1610
|
+
the retrieved frame appear.
|
|
1611
|
+
|
|
1612
|
+
Returns
|
|
1613
|
+
-------
|
|
1614
|
+
bool
|
|
1615
|
+
If `True`, the video texture has been updated and the frame index is
|
|
1616
|
+
advanced by one. If `False`, the last frame should be kept
|
|
1617
|
+
on-screen.
|
|
1618
|
+
|
|
1619
|
+
"""
|
|
1620
|
+
# get the current movie frame for the video time
|
|
1621
|
+
self._updateMoviePos() # update the movie position
|
|
1622
|
+
|
|
1623
|
+
frameData = self._player.getFrame(self._movieTime)
|
|
1624
|
+
|
|
1625
|
+
if frameData is None: # handle frame not available by showing last frame
|
|
1626
|
+
# if self._playbackStatus == PLAYING: # something went wrong
|
|
1627
|
+
# self._playbackStatus = SEEKING
|
|
1628
|
+
|
|
1629
|
+
return False
|
|
1630
|
+
|
|
1631
|
+
frameImage, pts, _ = frameData
|
|
1632
|
+
|
|
1633
|
+
# check if we are seeking
|
|
1634
|
+
# if self._playbackStatus == SEEKING:
|
|
1635
|
+
# if self._wasPaused:
|
|
1636
|
+
# self._playbackStatus = PAUSED
|
|
1637
|
+
# else:
|
|
1638
|
+
# self._playbackStatus = PLAYING
|
|
1639
|
+
|
|
1640
|
+
if frameImage is not None:
|
|
1641
|
+
# suggested by Alex Forrence (aforren1) originally in PR #6439 to use memoryview
|
|
1642
|
+
videoBuffer = frameImage.to_memoryview()[0].memview
|
|
1643
|
+
videoFrameArray = np.frombuffer(videoBuffer, dtype=np.uint8)
|
|
1644
|
+
self._recentFrame = videoFrameArray # most recent frame
|
|
1645
|
+
else:
|
|
1646
|
+
self._recentFrame = None
|
|
1647
|
+
|
|
1648
|
+
self._pts = pts # store the current PTS of the frame we got
|
|
1649
|
+
|
|
1650
|
+
return True
|
|
1651
|
+
|
|
1652
|
+
def _freeTextureBuffers(self):
|
|
1653
|
+
"""Free texture and pixel buffers. Call this when tearing down this
|
|
1654
|
+
class or if a movie is stopped.
|
|
1655
|
+
|
|
1656
|
+
"""
|
|
1657
|
+
try:
|
|
1658
|
+
# delete buffers and textures if previously created
|
|
1659
|
+
if self._pixbuffId.value > 0:
|
|
1660
|
+
GL.glDeleteBuffers(1, self._pixbuffId)
|
|
1661
|
+
self._pixbuffId = GL.GLuint()
|
|
1662
|
+
|
|
1663
|
+
# delete the old texture if present
|
|
1664
|
+
if self._textureId.value > 0:
|
|
1665
|
+
GL.glDeleteTextures(1, self._textureId)
|
|
1666
|
+
self._textureId = GL.GLuint()
|
|
1667
|
+
|
|
1668
|
+
except Exception: # can happen when unloading or shutting down
|
|
1669
|
+
pass
|
|
1670
|
+
|
|
1671
|
+
def _setupTextureBuffers(self):
|
|
1672
|
+
"""Setup texture buffers which hold frame data. This creates a 2D
|
|
1673
|
+
RGB texture and pixel buffer. The pixel buffer serves as the store for
|
|
1674
|
+
texture color data. Each frame, the pixel buffer memory is mapped and
|
|
1675
|
+
frame data is copied over to the GPU from the decoder.
|
|
1676
|
+
|
|
1677
|
+
This is called every time a video file is loaded. The
|
|
1678
|
+
`_freeTextureBuffers` method is called in this routine prior to creating
|
|
1679
|
+
new buffers, so it's safe to call this right after loading a new movie
|
|
1680
|
+
without having to `_freeTextureBuffers` first.
|
|
1681
|
+
|
|
1682
|
+
"""
|
|
1683
|
+
# get the size of the movie frame and compute the buffer size
|
|
1684
|
+
vidWidth, vidHeight = self._player.getMetadata().size
|
|
1685
|
+
nBufferBytes = vidWidth * vidHeight * 3
|
|
1686
|
+
|
|
1687
|
+
# Create the pixel buffer object which will serve as the texture memory
|
|
1688
|
+
# store. Pixel data will be copied to this buffer each frame.
|
|
1689
|
+
GL.glGenBuffers(1, ctypes.byref(self._pixbuffId))
|
|
1690
|
+
GL.glBindBuffer(GL.GL_PIXEL_UNPACK_BUFFER, self._pixbuffId)
|
|
1691
|
+
GL.glBufferData(
|
|
1692
|
+
GL.GL_PIXEL_UNPACK_BUFFER,
|
|
1693
|
+
nBufferBytes * ctypes.sizeof(GL.GLubyte),
|
|
1694
|
+
None,
|
|
1695
|
+
GL.GL_STREAM_DRAW) # one-way app -> GL
|
|
1696
|
+
GL.glBindBuffer(GL.GL_PIXEL_UNPACK_BUFFER, 0)
|
|
1697
|
+
|
|
1698
|
+
# Create a texture which will hold the data streamed to the pixel
|
|
1699
|
+
# buffer. Only one texture needs to be allocated.
|
|
1700
|
+
GL.glEnable(GL.GL_TEXTURE_2D)
|
|
1701
|
+
GL.glGenTextures(1, ctypes.byref(self._textureId))
|
|
1702
|
+
GL.glBindTexture(GL.GL_TEXTURE_2D, self._textureId)
|
|
1703
|
+
GL.glTexImage2D(
|
|
1704
|
+
GL.GL_TEXTURE_2D,
|
|
1705
|
+
0,
|
|
1706
|
+
GL.GL_RGB8,
|
|
1707
|
+
vidWidth, vidHeight, # frame dims in pixels
|
|
1708
|
+
0,
|
|
1709
|
+
GL.GL_RGB,
|
|
1710
|
+
GL.GL_UNSIGNED_BYTE,
|
|
1711
|
+
None)
|
|
1712
|
+
|
|
1713
|
+
# setup texture filtering
|
|
1714
|
+
if self.interpolate:
|
|
1715
|
+
texFilter = GL.GL_LINEAR
|
|
1716
|
+
else:
|
|
1717
|
+
texFilter = GL.GL_NEAREST
|
|
1718
|
+
|
|
1719
|
+
GL.glTexParameteri(
|
|
1720
|
+
GL.GL_TEXTURE_2D,
|
|
1721
|
+
GL.GL_TEXTURE_MAG_FILTER,
|
|
1722
|
+
texFilter)
|
|
1723
|
+
GL.glTexParameteri(
|
|
1724
|
+
GL.GL_TEXTURE_2D,
|
|
1725
|
+
GL.GL_TEXTURE_MIN_FILTER,
|
|
1726
|
+
texFilter)
|
|
1727
|
+
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP)
|
|
1728
|
+
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP)
|
|
1729
|
+
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
|
|
1730
|
+
GL.glDisable(GL.GL_TEXTURE_2D)
|
|
1731
|
+
|
|
1732
|
+
GL.glFlush() # make sure all buffers are ready
|
|
1733
|
+
|
|
1734
|
+
def _pixelTransfer(self):
|
|
1735
|
+
"""Copy pixel data from video frame to texture.
|
|
1736
|
+
|
|
1737
|
+
This is called when a new frame is available. The pixel data is copied
|
|
1738
|
+
from the video frame to the texture store on the GPU.
|
|
1739
|
+
|
|
1740
|
+
"""
|
|
1741
|
+
# get the size of the movie frame and compute the buffer size
|
|
1742
|
+
vidWidth, vidHeight = self._player.getMetadata().size
|
|
1743
|
+
|
|
1744
|
+
nBufferBytes = vidWidth * vidHeight * 3
|
|
1745
|
+
|
|
1746
|
+
# bind pixel unpack buffer
|
|
1747
|
+
GL.glBindBuffer(GL.GL_PIXEL_UNPACK_BUFFER, self._pixbuffId)
|
|
1748
|
+
|
|
1749
|
+
# Free last storage buffer before mapping and writing new frame
|
|
1750
|
+
# data. This allows the GPU to process the extant buffer in VRAM
|
|
1751
|
+
# uploaded last cycle without being stalled by the CPU accessing it.
|
|
1752
|
+
GL.glBufferData(
|
|
1753
|
+
GL.GL_PIXEL_UNPACK_BUFFER,
|
|
1754
|
+
nBufferBytes * ctypes.sizeof(GL.GLubyte),
|
|
1755
|
+
None,
|
|
1756
|
+
GL.GL_STREAM_DRAW)
|
|
1757
|
+
|
|
1758
|
+
# Map the buffer to client memory, `GL_WRITE_ONLY` to tell the
|
|
1759
|
+
# driver to optimize for a one-way write operation if it can.
|
|
1760
|
+
bufferPtr = GL.glMapBuffer(
|
|
1761
|
+
GL.GL_PIXEL_UNPACK_BUFFER,
|
|
1762
|
+
GL.GL_WRITE_ONLY)
|
|
1763
|
+
|
|
1764
|
+
# copy the frame data to the buffer
|
|
1765
|
+
ctypes.memmove(bufferPtr,
|
|
1766
|
+
self._recentFrame.ctypes.data,
|
|
1767
|
+
nBufferBytes)
|
|
1768
|
+
|
|
1769
|
+
# Very important that we unmap the buffer data after copying, but
|
|
1770
|
+
# keep the buffer bound for setting the texture.
|
|
1771
|
+
GL.glUnmapBuffer(GL.GL_PIXEL_UNPACK_BUFFER)
|
|
1772
|
+
|
|
1773
|
+
# bind the texture in OpenGL
|
|
1774
|
+
GL.glEnable(GL.GL_TEXTURE_2D)
|
|
1775
|
+
GL.glActiveTexture(GL.GL_TEXTURE0)
|
|
1776
|
+
GL.glBindTexture(GL.GL_TEXTURE_2D, self._textureId)
|
|
1777
|
+
|
|
1778
|
+
# copy the PBO to the texture
|
|
1779
|
+
GL.glTexSubImage2D(
|
|
1780
|
+
GL.GL_TEXTURE_2D, 0, 0, 0,
|
|
1781
|
+
vidWidth, vidHeight,
|
|
1782
|
+
GL.GL_RGB,
|
|
1783
|
+
GL.GL_UNSIGNED_BYTE,
|
|
1784
|
+
0) # point to the presently bound buffer
|
|
1785
|
+
|
|
1786
|
+
# update texture filtering only if needed
|
|
1787
|
+
if self._texFilterNeedsUpdate:
|
|
1788
|
+
if self.interpolate:
|
|
1789
|
+
texFilter = GL.GL_LINEAR
|
|
1790
|
+
else:
|
|
1791
|
+
texFilter = GL.GL_NEAREST
|
|
1792
|
+
|
|
1793
|
+
GL.glTexParameteri(
|
|
1794
|
+
GL.GL_TEXTURE_2D,
|
|
1795
|
+
GL.GL_TEXTURE_MAG_FILTER,
|
|
1796
|
+
texFilter)
|
|
1797
|
+
GL.glTexParameteri(
|
|
1798
|
+
GL.GL_TEXTURE_2D,
|
|
1799
|
+
GL.GL_TEXTURE_MIN_FILTER,
|
|
1800
|
+
texFilter)
|
|
1801
|
+
|
|
1802
|
+
self._texFilterNeedsUpdate = False
|
|
1803
|
+
|
|
1804
|
+
# important to unbind the PBO
|
|
1805
|
+
GL.glBindBuffer(GL.GL_PIXEL_UNPACK_BUFFER, 0)
|
|
1806
|
+
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
|
|
1807
|
+
GL.glDisable(GL.GL_TEXTURE_2D)
|
|
1808
|
+
|
|
1809
|
+
def _drawRectangle(self):
|
|
1810
|
+
"""Draw the video frame to the window.
|
|
1811
|
+
|
|
1812
|
+
This is called by the `draw()` method to blit the video to the display
|
|
1813
|
+
window. The dimensions of the video are set by the `size` parameter.
|
|
1814
|
+
|
|
1815
|
+
"""
|
|
1816
|
+
# make sure that textures are on and GL_TEXTURE0 is active
|
|
1817
|
+
GL.glEnable(GL.GL_TEXTURE_2D)
|
|
1818
|
+
GL.glActiveTexture(GL.GL_TEXTURE0)
|
|
264
1819
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
1820
|
+
# sets opacity (1, 1, 1 = RGB placeholder)
|
|
1821
|
+
GL.glColor4f(1, 1, 1, self.opacity)
|
|
1822
|
+
GL.glPushMatrix()
|
|
1823
|
+
self.win.setScale('pix')
|
|
268
1824
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
bool
|
|
272
|
-
If `True`, the video texture has been updated and the frame index is
|
|
273
|
-
advanced by one. If `False`, the last frame should be kept
|
|
274
|
-
on-screen.
|
|
1825
|
+
# move to centre of stimulus and rotate
|
|
1826
|
+
vertsPix = self.verticesPix
|
|
275
1827
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
1828
|
+
array = (GL.GLfloat * 32)(
|
|
1829
|
+
1, 1, # texture coords
|
|
1830
|
+
vertsPix[0, 0], vertsPix[0, 1], 0., # vertex
|
|
1831
|
+
0, 1,
|
|
1832
|
+
vertsPix[1, 0], vertsPix[1, 1], 0.,
|
|
1833
|
+
0, 0,
|
|
1834
|
+
vertsPix[2, 0], vertsPix[2, 1], 0.,
|
|
1835
|
+
1, 0,
|
|
1836
|
+
vertsPix[3, 0], vertsPix[3, 1], 0.,
|
|
1837
|
+
)
|
|
1838
|
+
GL.glPushAttrib(GL.GL_ENABLE_BIT)
|
|
281
1839
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
1840
|
+
GL.glActiveTexture(GL.GL_TEXTURE0)
|
|
1841
|
+
GL.glBindTexture(GL.GL_TEXTURE_2D, self._textureId)
|
|
1842
|
+
GL.glPushClientAttrib(GL.GL_CLIENT_VERTEX_ARRAY_BIT)
|
|
1843
|
+
|
|
1844
|
+
# 2D texture array, 3D vertex array
|
|
1845
|
+
GL.glInterleavedArrays(GL.GL_T2F_V3F, 0, array)
|
|
1846
|
+
GL.glDrawArrays(GL.GL_QUADS, 0, 4)
|
|
1847
|
+
GL.glPopClientAttrib()
|
|
1848
|
+
GL.glPopAttrib()
|
|
1849
|
+
GL.glPopMatrix()
|
|
1850
|
+
|
|
1851
|
+
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
|
|
1852
|
+
GL.glDisable(GL.GL_TEXTURE_2D)
|
|
285
1853
|
|
|
286
|
-
|
|
1854
|
+
def _drawThrobber(self):
|
|
1855
|
+
"""Draw a throbber to indicate that the movie is loading or seeking.
|
|
1856
|
+
"""
|
|
1857
|
+
# todo - implement this
|
|
1858
|
+
pass
|
|
287
1859
|
|
|
288
1860
|
def draw(self, win=None):
|
|
289
1861
|
"""Draw the current frame to a particular window.
|
|
@@ -309,12 +1881,17 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
309
1881
|
|
|
310
1882
|
# handle autoplay
|
|
311
1883
|
if self._autoStart and self.isNotStarted:
|
|
312
|
-
|
|
1884
|
+
self.play()
|
|
313
1885
|
|
|
314
1886
|
# update the video frame and draw it to a quad
|
|
315
|
-
|
|
1887
|
+
if self.updateVideoFrame():
|
|
1888
|
+
self._pixelTransfer()
|
|
1889
|
+
|
|
316
1890
|
self._drawRectangle() # draw the texture to the target window
|
|
317
1891
|
|
|
1892
|
+
# if self._playbackStatus == SEEKING:
|
|
1893
|
+
# self._drawThrobber()
|
|
1894
|
+
|
|
318
1895
|
return True
|
|
319
1896
|
|
|
320
1897
|
# --------------------------------------------------------------------------
|
|
@@ -325,52 +1902,42 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
325
1902
|
def isPlaying(self):
|
|
326
1903
|
"""`True` if the video is presently playing (`bool`).
|
|
327
1904
|
"""
|
|
328
|
-
|
|
329
|
-
# self documenting and prevent the user from touching the status flag
|
|
330
|
-
# attribute directly.
|
|
331
|
-
#
|
|
332
|
-
if self._player is not None:
|
|
333
|
-
return self._player.isPlaying
|
|
334
|
-
|
|
335
|
-
return False
|
|
1905
|
+
return self._playbackStatus == PLAYING
|
|
336
1906
|
|
|
337
1907
|
@property
|
|
338
1908
|
def isNotStarted(self):
|
|
339
1909
|
"""`True` if the video may not have started yet (`bool`). This status is
|
|
340
1910
|
given after a video is loaded and play has yet to be called.
|
|
341
1911
|
"""
|
|
342
|
-
|
|
343
|
-
return self._player.isNotStarted
|
|
344
|
-
|
|
345
|
-
return True
|
|
1912
|
+
return self._playbackStatus == NOT_STARTED
|
|
346
1913
|
|
|
347
1914
|
@property
|
|
348
1915
|
def isStopped(self):
|
|
349
1916
|
"""`True` if the video is stopped (`bool`). It will resume from the
|
|
350
1917
|
beginning if `play()` is called.
|
|
351
1918
|
"""
|
|
352
|
-
|
|
353
|
-
return self._player.isStopped
|
|
354
|
-
|
|
355
|
-
return False
|
|
1919
|
+
return self._playbackStatus == STOPPED
|
|
356
1920
|
|
|
357
1921
|
@property
|
|
358
1922
|
def isPaused(self):
|
|
359
1923
|
"""`True` if the video is presently paused (`bool`).
|
|
360
1924
|
"""
|
|
361
|
-
|
|
362
|
-
return self._player.isPaused
|
|
363
|
-
|
|
364
|
-
return False
|
|
1925
|
+
return self._playbackStatus == PAUSED
|
|
365
1926
|
|
|
366
1927
|
@property
|
|
367
1928
|
def isFinished(self):
|
|
368
|
-
"""`True` if the video is finished (`bool`).
|
|
1929
|
+
"""`True` if the video is finished (`bool`). Reports the same status as
|
|
1930
|
+
`isStopped` if the video is stopped.
|
|
369
1931
|
"""
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
1932
|
+
return self._playbackStatus == FINISHED
|
|
1933
|
+
|
|
1934
|
+
@property
|
|
1935
|
+
def movieTime(self):
|
|
1936
|
+
"""Current movie time in seconds (`float`). This is the time since the
|
|
1937
|
+
movie started playing. If the movie is paused, this time will not
|
|
1938
|
+
advance.
|
|
1939
|
+
"""
|
|
1940
|
+
return self._movieTime
|
|
374
1941
|
|
|
375
1942
|
def play(self, log=True):
|
|
376
1943
|
"""Start or continue a paused movie from current position.
|
|
@@ -381,11 +1948,26 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
381
1948
|
Log the play event.
|
|
382
1949
|
|
|
383
1950
|
"""
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
1951
|
+
if self._player is None:
|
|
1952
|
+
return
|
|
1953
|
+
|
|
1954
|
+
if self._playbackStatus == PLAYING:
|
|
1955
|
+
return # nop
|
|
1956
|
+
|
|
1957
|
+
if not self._noAudio:
|
|
1958
|
+
if self._audioLib == 'sdl2':
|
|
1959
|
+
self._player.mute(False)
|
|
1960
|
+
self._player.setVolume(self._volume)
|
|
1961
|
+
|
|
1962
|
+
self._player.pause(False) # start the player
|
|
1963
|
+
self._playbackStatus = PLAYING
|
|
1964
|
+
self._wasPaused = False # reset the paused flag
|
|
1965
|
+
self._lastFrameAbsTime = core.getTime() # get the current time
|
|
1966
|
+
|
|
1967
|
+
if log:
|
|
1968
|
+
logging.info(
|
|
1969
|
+
"Movie playback {} started at {:.2f} seconds".format(
|
|
1970
|
+
self._filename, self._movieTime))
|
|
389
1971
|
|
|
390
1972
|
def pause(self, log=True):
|
|
391
1973
|
"""Pause the current point in the movie. The image of the last frame
|
|
@@ -397,12 +1979,22 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
397
1979
|
Log this event.
|
|
398
1980
|
|
|
399
1981
|
"""
|
|
400
|
-
self.
|
|
1982
|
+
if not self._noAudio:
|
|
1983
|
+
if self._audioLib == 'sdl2':
|
|
1984
|
+
self._player.mute(True)
|
|
1985
|
+
|
|
1986
|
+
self._player.pause()
|
|
1987
|
+
self._wasPaused = True # set the paused flag
|
|
1988
|
+
self._playbackStatus = PAUSED
|
|
1989
|
+
|
|
1990
|
+
if log:
|
|
1991
|
+
logging.info("Movie {} paused at position {:.2f} seconds".format(
|
|
1992
|
+
self._filename, self._movieTime))
|
|
401
1993
|
|
|
402
1994
|
def toggle(self, log=True):
|
|
403
1995
|
"""Switch between playing and pausing the movie. If the movie is playing,
|
|
404
1996
|
this function will pause it. If the movie is paused, this function will
|
|
405
|
-
|
|
1997
|
+
begin playback from the current position.
|
|
406
1998
|
|
|
407
1999
|
Parameters
|
|
408
2000
|
----------
|
|
@@ -420,6 +2012,10 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
420
2012
|
will not advance and remain on-screen). Once stopped the movie can be
|
|
421
2013
|
restarted from the beginning by calling `play()`.
|
|
422
2014
|
|
|
2015
|
+
Note that this method will fully unload the movie and reset the
|
|
2016
|
+
player instance. If you want to reset the movie without unloading it,
|
|
2017
|
+
use `seek(0.0)` instead.
|
|
2018
|
+
|
|
423
2019
|
Parameters
|
|
424
2020
|
----------
|
|
425
2021
|
log : bool
|
|
@@ -427,8 +2023,20 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
427
2023
|
|
|
428
2024
|
"""
|
|
429
2025
|
# stop should reset the video to the start and pause
|
|
430
|
-
if self._player is
|
|
431
|
-
|
|
2026
|
+
if self._player is None:
|
|
2027
|
+
return # nothing to stop
|
|
2028
|
+
|
|
2029
|
+
if log:
|
|
2030
|
+
logging.debug("Stopping movie: {}".format(self._filename))
|
|
2031
|
+
|
|
2032
|
+
self._player.close() # close the player
|
|
2033
|
+
|
|
2034
|
+
self.loadMovie(self._filename) # reload the movie
|
|
2035
|
+
|
|
2036
|
+
self._playbackStatus = NOT_STARTED
|
|
2037
|
+
|
|
2038
|
+
if log:
|
|
2039
|
+
logging.info("Movie stopped: {}".format(self._filename))
|
|
432
2040
|
|
|
433
2041
|
def seek(self, timestamp, log=True):
|
|
434
2042
|
"""Seek to a particular timestamp in the movie.
|
|
@@ -441,9 +2049,20 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
441
2049
|
Log this event.
|
|
442
2050
|
|
|
443
2051
|
"""
|
|
444
|
-
self.
|
|
2052
|
+
if self._playbackStatus == PLAYING:
|
|
2053
|
+
self._wasPaused = False
|
|
2054
|
+
elif self._playbackStatus == PAUSED:
|
|
2055
|
+
self._wasPaused = True
|
|
445
2056
|
|
|
446
|
-
|
|
2057
|
+
# self._playbackStatus = SEEKING
|
|
2058
|
+
self._movieTime = timestamp
|
|
2059
|
+
# self._player.pause(True) # pause the player
|
|
2060
|
+
self._player.seek(self._movieTime)
|
|
2061
|
+
|
|
2062
|
+
# self._pts = self._movieTime # store the current PTS
|
|
2063
|
+
_ = self.updateVideoFrame()
|
|
2064
|
+
|
|
2065
|
+
def rewind(self, seconds=1, log=True):
|
|
447
2066
|
"""Rewind the video.
|
|
448
2067
|
|
|
449
2068
|
Parameters
|
|
@@ -455,9 +2074,11 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
455
2074
|
Log this event.
|
|
456
2075
|
|
|
457
2076
|
"""
|
|
458
|
-
self.
|
|
2077
|
+
newPts = self._movieTime - seconds
|
|
2078
|
+
self._movieTime = min(max(0.0, newPts), self.duration)
|
|
2079
|
+
self.seek(self._movieTime) # seek to the new position
|
|
459
2080
|
|
|
460
|
-
def fastForward(self, seconds=
|
|
2081
|
+
def fastForward(self, seconds=1, log=True):
|
|
461
2082
|
"""Fast-forward the video.
|
|
462
2083
|
|
|
463
2084
|
Parameters
|
|
@@ -469,7 +2090,9 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
469
2090
|
Log this event.
|
|
470
2091
|
|
|
471
2092
|
"""
|
|
472
|
-
self.
|
|
2093
|
+
newPts = self._movieTime + seconds
|
|
2094
|
+
self._movieTime = min(max(0.0, newPts), self.duration)
|
|
2095
|
+
self.seek(self._movieTime) # seek to the new position
|
|
473
2096
|
|
|
474
2097
|
def replay(self, log=True):
|
|
475
2098
|
"""Replay the movie from the beginning.
|
|
@@ -486,8 +2109,16 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
486
2109
|
you would like to restart the movie without reloading.
|
|
487
2110
|
|
|
488
2111
|
"""
|
|
489
|
-
self.
|
|
2112
|
+
self._movieTime = 0.0 # reset movie time
|
|
2113
|
+
self.seek(self._movieTime)
|
|
2114
|
+
self.play()
|
|
490
2115
|
|
|
2116
|
+
def reset(self):
|
|
2117
|
+
"""Reset the movie to its initial state.
|
|
2118
|
+
"""
|
|
2119
|
+
# self.seek(0.0) # reset movie time to 0
|
|
2120
|
+
self._playbackStatus = NOT_STARTED # reset playback status
|
|
2121
|
+
|
|
491
2122
|
# --------------------------------------------------------------------------
|
|
492
2123
|
# Audio stream control methods
|
|
493
2124
|
#
|
|
@@ -496,11 +2127,14 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
496
2127
|
def muted(self):
|
|
497
2128
|
"""`True` if the stream audio is muted (`bool`).
|
|
498
2129
|
"""
|
|
499
|
-
|
|
2130
|
+
if self._audioLib == 'sdl2':
|
|
2131
|
+
return self._player.mute
|
|
2132
|
+
else:
|
|
2133
|
+
return False # for now
|
|
500
2134
|
|
|
501
2135
|
@muted.setter
|
|
502
2136
|
def muted(self, value):
|
|
503
|
-
self._player.
|
|
2137
|
+
self._player.mute = value
|
|
504
2138
|
|
|
505
2139
|
def volumeUp(self, amount=0.05):
|
|
506
2140
|
"""Increase the volume by a fixed amount.
|
|
@@ -511,7 +2145,9 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
511
2145
|
Amount to increase the volume relative to the current volume.
|
|
512
2146
|
|
|
513
2147
|
"""
|
|
514
|
-
self.
|
|
2148
|
+
if self._audioLib == 'sdl2':
|
|
2149
|
+
currentVolume = self._player.volume
|
|
2150
|
+
self._player.setVolume(currentVolume + amount)
|
|
515
2151
|
|
|
516
2152
|
def volumeDown(self, amount=0.05):
|
|
517
2153
|
"""Decrease the volume by a fixed amount.
|
|
@@ -522,17 +2158,21 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
522
2158
|
Amount to decrease the volume relative to the current volume.
|
|
523
2159
|
|
|
524
2160
|
"""
|
|
525
|
-
self.
|
|
2161
|
+
if self._audioLib == 'sdl2':
|
|
2162
|
+
currentVolume = self._player.volume
|
|
2163
|
+
self._player.setVolume(currentVolume - amount)
|
|
526
2164
|
|
|
527
2165
|
@property
|
|
528
2166
|
def volume(self):
|
|
529
2167
|
"""Volume for the audio track for this movie (`int` or `float`).
|
|
530
2168
|
"""
|
|
531
|
-
|
|
2169
|
+
if self._audioLib == 'sdl2':
|
|
2170
|
+
return self._player.volume
|
|
532
2171
|
|
|
533
2172
|
@volume.setter
|
|
534
2173
|
def volume(self, value):
|
|
535
|
-
self.
|
|
2174
|
+
if self._audioLib == 'sdl2':
|
|
2175
|
+
self._player.volume = value
|
|
536
2176
|
|
|
537
2177
|
# --------------------------------------------------------------------------
|
|
538
2178
|
# Video and playback information
|
|
@@ -541,7 +2181,7 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
541
2181
|
@property
|
|
542
2182
|
def frameIndex(self):
|
|
543
2183
|
"""Current frame index being displayed (`int`)."""
|
|
544
|
-
return
|
|
2184
|
+
return 0
|
|
545
2185
|
|
|
546
2186
|
def getCurrentFrameNumber(self):
|
|
547
2187
|
"""Get the current movie frame number (`int`), same as `frameIndex`.
|
|
@@ -556,7 +2196,7 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
556
2196
|
if not self._player:
|
|
557
2197
|
return -1.0
|
|
558
2198
|
|
|
559
|
-
return self._player.
|
|
2199
|
+
return self._player.getMetadata().duration
|
|
560
2200
|
|
|
561
2201
|
@property
|
|
562
2202
|
def loopCount(self):
|
|
@@ -573,7 +2213,7 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
573
2213
|
if not self._player:
|
|
574
2214
|
return -1
|
|
575
2215
|
|
|
576
|
-
return self.
|
|
2216
|
+
return self._loopCount
|
|
577
2217
|
|
|
578
2218
|
@property
|
|
579
2219
|
def fps(self):
|
|
@@ -592,22 +2232,18 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
592
2232
|
if not self._player:
|
|
593
2233
|
return 1.0
|
|
594
2234
|
|
|
595
|
-
return self._player.
|
|
2235
|
+
return self._player.getFrameRate()
|
|
596
2236
|
|
|
597
2237
|
@property
|
|
598
2238
|
def videoSize(self):
|
|
599
2239
|
"""Size of the video `(w, h)` in pixels (`tuple`). Returns `(0, 0)` if
|
|
600
2240
|
no video is loaded.
|
|
601
2241
|
"""
|
|
602
|
-
|
|
603
|
-
return 0, 0
|
|
604
|
-
|
|
605
|
-
return self._player.metadata.size
|
|
2242
|
+
return self.frameSize
|
|
606
2243
|
|
|
607
2244
|
@property
|
|
608
2245
|
def origSize(self):
|
|
609
|
-
"""
|
|
610
|
-
Alias of videoSize
|
|
2246
|
+
"""Alias of `videoSize`
|
|
611
2247
|
"""
|
|
612
2248
|
return self.videoSize
|
|
613
2249
|
|
|
@@ -618,7 +2254,7 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
618
2254
|
if not self._player:
|
|
619
2255
|
return 0, 0
|
|
620
2256
|
|
|
621
|
-
return self._player.
|
|
2257
|
+
return self._player.getMetadata().size
|
|
622
2258
|
|
|
623
2259
|
@property
|
|
624
2260
|
def pts(self):
|
|
@@ -631,217 +2267,68 @@ class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
|
|
|
631
2267
|
if not self._player:
|
|
632
2268
|
return -1.0
|
|
633
2269
|
|
|
634
|
-
return self.
|
|
2270
|
+
return self._pts
|
|
635
2271
|
|
|
636
2272
|
def getPercentageComplete(self):
|
|
637
2273
|
"""Provides a value between 0.0 and 100.0, indicating the amount of the
|
|
638
2274
|
movie that has been already played (`float`).
|
|
639
2275
|
"""
|
|
640
|
-
return (self.
|
|
641
|
-
|
|
2276
|
+
return (self._movieTime / self.duration) * 100.0
|
|
2277
|
+
|
|
642
2278
|
# --------------------------------------------------------------------------
|
|
643
|
-
#
|
|
2279
|
+
# Miscellaneous methods
|
|
644
2280
|
#
|
|
645
2281
|
|
|
646
|
-
def
|
|
647
|
-
"""
|
|
648
|
-
class or if a movie is stopped.
|
|
649
|
-
"""
|
|
650
|
-
try:
|
|
651
|
-
# delete buffers and textures if previously created
|
|
652
|
-
if self._pixbuffId.value > 0:
|
|
653
|
-
GL.glDeleteBuffers(1, self._pixbuffId)
|
|
654
|
-
self._pixbuffId = GL.GLuint()
|
|
655
|
-
|
|
656
|
-
# delete the old texture if present
|
|
657
|
-
if self._textureId.value > 0:
|
|
658
|
-
GL.glDeleteTextures(1, self._textureId)
|
|
659
|
-
self._textureId = GL.GLuint()
|
|
660
|
-
|
|
661
|
-
except TypeError: # can happen when unloading or shutting down
|
|
662
|
-
pass
|
|
663
|
-
|
|
664
|
-
def _setupTextureBuffers(self):
|
|
665
|
-
"""Setup texture buffers which hold frame data. This creates a 2D
|
|
666
|
-
RGB texture and pixel buffer. The pixel buffer serves as the store for
|
|
667
|
-
texture color data. Each frame, the pixel buffer memory is mapped and
|
|
668
|
-
frame data is copied over to the GPU from the decoder.
|
|
669
|
-
|
|
670
|
-
This is called every time a video file is loaded. The `_freeBuffers`
|
|
671
|
-
method is called in this routine prior to creating new buffers, so it's
|
|
672
|
-
safe to call this right after loading a new movie without having to
|
|
673
|
-
`_freeBuffers` first.
|
|
674
|
-
|
|
675
|
-
"""
|
|
676
|
-
# get the size of the movie frame and compute the buffer size
|
|
677
|
-
vidWidth, vidHeight = self._player.getMetadata().size
|
|
678
|
-
nBufferBytes = vidWidth * vidHeight * 4
|
|
679
|
-
|
|
680
|
-
# Create the pixel buffer object which will serve as the texture memory
|
|
681
|
-
# store. Pixel data will be copied to this buffer each frame.
|
|
682
|
-
GL.glGenBuffers(1, ctypes.byref(self._pixbuffId))
|
|
683
|
-
GL.glBindBuffer(GL.GL_PIXEL_UNPACK_BUFFER, self._pixbuffId)
|
|
684
|
-
GL.glBufferData(
|
|
685
|
-
GL.GL_PIXEL_UNPACK_BUFFER,
|
|
686
|
-
nBufferBytes * ctypes.sizeof(GL.GLubyte),
|
|
687
|
-
None,
|
|
688
|
-
GL.GL_STREAM_DRAW) # one-way app -> GL
|
|
689
|
-
GL.glBindBuffer(GL.GL_PIXEL_UNPACK_BUFFER, 0)
|
|
690
|
-
|
|
691
|
-
# Create a texture which will hold the data streamed to the pixel
|
|
692
|
-
# buffer. Only one texture needs to be allocated.
|
|
693
|
-
GL.glEnable(GL.GL_TEXTURE_2D)
|
|
694
|
-
GL.glGenTextures(1, ctypes.byref(self._textureId))
|
|
695
|
-
GL.glBindTexture(GL.GL_TEXTURE_2D, self._textureId)
|
|
696
|
-
GL.glTexImage2D(
|
|
697
|
-
GL.GL_TEXTURE_2D,
|
|
698
|
-
0,
|
|
699
|
-
GL.GL_RGBA8,
|
|
700
|
-
vidWidth, vidHeight, # frame dims in pixels
|
|
701
|
-
0,
|
|
702
|
-
GL.GL_BGRA,
|
|
703
|
-
GL.GL_UNSIGNED_BYTE,
|
|
704
|
-
None)
|
|
705
|
-
|
|
706
|
-
# setup texture filtering
|
|
707
|
-
if self.interpolate:
|
|
708
|
-
texFilter = GL.GL_LINEAR
|
|
709
|
-
else:
|
|
710
|
-
texFilter = GL.GL_NEAREST
|
|
711
|
-
|
|
712
|
-
GL.glTexParameteri(
|
|
713
|
-
GL.GL_TEXTURE_2D,
|
|
714
|
-
GL.GL_TEXTURE_MAG_FILTER,
|
|
715
|
-
texFilter)
|
|
716
|
-
GL.glTexParameteri(
|
|
717
|
-
GL.GL_TEXTURE_2D,
|
|
718
|
-
GL.GL_TEXTURE_MIN_FILTER,
|
|
719
|
-
texFilter)
|
|
720
|
-
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP)
|
|
721
|
-
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP)
|
|
722
|
-
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
|
|
723
|
-
GL.glDisable(GL.GL_TEXTURE_2D)
|
|
2282
|
+
def getSubtitleText(self):
|
|
2283
|
+
"""Get the subtitle for the current frame.
|
|
724
2284
|
|
|
725
|
-
|
|
2285
|
+
Returns
|
|
2286
|
+
-------
|
|
2287
|
+
str
|
|
2288
|
+
Subtitle for the current frame.
|
|
726
2289
|
|
|
727
|
-
def _pixelTransfer(self):
|
|
728
|
-
"""Copy pixel data from video frame to texture.
|
|
729
2290
|
"""
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
nBufferBytes = vidWidth * vidHeight * 4
|
|
734
|
-
|
|
735
|
-
# bind pixel unpack buffer
|
|
736
|
-
GL.glBindBuffer(GL.GL_PIXEL_UNPACK_BUFFER, self._pixbuffId)
|
|
737
|
-
|
|
738
|
-
# Free last storage buffer before mapping and writing new frame
|
|
739
|
-
# data. This allows the GPU to process the extant buffer in VRAM
|
|
740
|
-
# uploaded last cycle without being stalled by the CPU accessing it.
|
|
741
|
-
GL.glBufferData(
|
|
742
|
-
GL.GL_PIXEL_UNPACK_BUFFER,
|
|
743
|
-
nBufferBytes * ctypes.sizeof(GL.GLubyte),
|
|
744
|
-
None,
|
|
745
|
-
GL.GL_STREAM_DRAW)
|
|
746
|
-
|
|
747
|
-
# Map the buffer to client memory, `GL_WRITE_ONLY` to tell the
|
|
748
|
-
# driver to optimize for a one-way write operation if it can.
|
|
749
|
-
bufferPtr = GL.glMapBuffer(
|
|
750
|
-
GL.GL_PIXEL_UNPACK_BUFFER,
|
|
751
|
-
GL.GL_WRITE_ONLY)
|
|
752
|
-
|
|
753
|
-
bufferArray = np.ctypeslib.as_array(
|
|
754
|
-
ctypes.cast(bufferPtr, ctypes.POINTER(GL.GLubyte)),
|
|
755
|
-
shape=(nBufferBytes,))
|
|
756
|
-
|
|
757
|
-
# copy data
|
|
758
|
-
bufferArray[:] = self._recentFrame.colorData[:]
|
|
759
|
-
|
|
760
|
-
# Very important that we unmap the buffer data after copying, but
|
|
761
|
-
# keep the buffer bound for setting the texture.
|
|
762
|
-
GL.glUnmapBuffer(GL.GL_PIXEL_UNPACK_BUFFER)
|
|
763
|
-
|
|
764
|
-
# bind the texture in OpenGL
|
|
765
|
-
GL.glEnable(GL.GL_TEXTURE_2D)
|
|
766
|
-
GL.glActiveTexture(GL.GL_TEXTURE0)
|
|
767
|
-
GL.glBindTexture(GL.GL_TEXTURE_2D, self._textureId)
|
|
768
|
-
|
|
769
|
-
# copy the PBO to the texture
|
|
770
|
-
GL.glTexSubImage2D(
|
|
771
|
-
GL.GL_TEXTURE_2D, 0, 0, 0,
|
|
772
|
-
vidWidth, vidHeight,
|
|
773
|
-
GL.GL_BGRA,
|
|
774
|
-
GL.GL_UNSIGNED_INT_8_8_8_8_REV,
|
|
775
|
-
0) # point to the presently bound buffer
|
|
776
|
-
|
|
777
|
-
# update texture filtering only if needed
|
|
778
|
-
if self._texFilterNeedsUpdate:
|
|
779
|
-
if self.interpolate:
|
|
780
|
-
texFilter = GL.GL_LINEAR
|
|
781
|
-
else:
|
|
782
|
-
texFilter = GL.GL_NEAREST
|
|
783
|
-
|
|
784
|
-
GL.glTexParameteri(
|
|
785
|
-
GL.GL_TEXTURE_2D,
|
|
786
|
-
GL.GL_TEXTURE_MAG_FILTER,
|
|
787
|
-
texFilter)
|
|
788
|
-
GL.glTexParameteri(
|
|
789
|
-
GL.GL_TEXTURE_2D,
|
|
790
|
-
GL.GL_TEXTURE_MIN_FILTER,
|
|
791
|
-
texFilter)
|
|
792
|
-
|
|
793
|
-
self._texFilterNeedsUpdate = False
|
|
794
|
-
|
|
795
|
-
# important to unbind the PBO
|
|
796
|
-
GL.glBindBuffer(GL.GL_PIXEL_UNPACK_BUFFER, 0)
|
|
797
|
-
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
|
|
798
|
-
GL.glDisable(GL.GL_TEXTURE_2D)
|
|
2291
|
+
if not self._player:
|
|
2292
|
+
return ""
|
|
799
2293
|
|
|
800
|
-
|
|
801
|
-
|
|
2294
|
+
return self._player.getSubtitle()
|
|
2295
|
+
|
|
2296
|
+
def __del__(self):
|
|
2297
|
+
"""Destructor for the MovieStim class.
|
|
802
2298
|
|
|
803
|
-
This is called
|
|
804
|
-
|
|
2299
|
+
This function is called when the object is deleted. It closes the movie
|
|
2300
|
+
player and frees any resources used by the object.
|
|
805
2301
|
|
|
806
2302
|
"""
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
GL.glActiveTexture(GL.GL_TEXTURE0)
|
|
810
|
-
|
|
811
|
-
# sets opacity (1, 1, 1 = RGB placeholder)
|
|
812
|
-
GL.glColor4f(1, 1, 1, self.opacity)
|
|
813
|
-
GL.glPushMatrix()
|
|
814
|
-
self.win.setScale('pix')
|
|
815
|
-
|
|
816
|
-
# move to centre of stimulus and rotate
|
|
817
|
-
vertsPix = self.verticesPix
|
|
2303
|
+
self.unload()
|
|
2304
|
+
|
|
818
2305
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
vertsPix[0, 0], vertsPix[0, 1], 0., # vertex
|
|
822
|
-
0, 1,
|
|
823
|
-
vertsPix[1, 0], vertsPix[1, 1], 0.,
|
|
824
|
-
0, 0,
|
|
825
|
-
vertsPix[2, 0], vertsPix[2, 1], 0.,
|
|
826
|
-
1, 0,
|
|
827
|
-
vertsPix[3, 0], vertsPix[3, 1], 0.,
|
|
828
|
-
)
|
|
829
|
-
GL.glPushAttrib(GL.GL_ENABLE_BIT)
|
|
2306
|
+
def _closeAllMovieReaders():
|
|
2307
|
+
"""Close all movie readers.
|
|
830
2308
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
2309
|
+
This function explicitly closes movie reader interfaces that are presently
|
|
2310
|
+
open, to free resources when the interpreter exits to reduce the chances of
|
|
2311
|
+
any subprocesses spawned by the interface being orphaned.
|
|
2312
|
+
|
|
2313
|
+
Do not call this directly, it is called automatically when the interpreter
|
|
2314
|
+
exits (via `atexit`). If you do, all sorts of bad things will happen if
|
|
2315
|
+
there are any open movie readers still in use.
|
|
834
2316
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
GL.glDrawArrays(GL.GL_QUADS, 0, 4)
|
|
838
|
-
GL.glPopClientAttrib()
|
|
839
|
-
GL.glPopAttrib()
|
|
840
|
-
GL.glPopMatrix()
|
|
2317
|
+
"""
|
|
2318
|
+
global _openMovieReaders
|
|
841
2319
|
|
|
842
|
-
|
|
843
|
-
|
|
2320
|
+
for movieReader in _openMovieReaders:
|
|
2321
|
+
logging.debug(
|
|
2322
|
+
"Closing movie reader interface for file: {}".format(
|
|
2323
|
+
movieReader.filename))
|
|
2324
|
+
if hasattr(movieReader, '_player'):
|
|
2325
|
+
movieReader._freePlayer()
|
|
844
2326
|
|
|
845
2327
|
|
|
2328
|
+
# try an close any players on exit
|
|
2329
|
+
import atexit
|
|
2330
|
+
atexit.register(_closeAllMovieReaders) # call this when the program exits
|
|
2331
|
+
|
|
2332
|
+
|
|
846
2333
|
if __name__ == "__main__":
|
|
847
2334
|
pass
|