psychopy 2025.1.1__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.

Files changed (220) hide show
  1. psychopy/VERSION +1 -1
  2. psychopy/alerts/alertsCatalogue/4810.yaml +19 -0
  3. psychopy/alerts/alertsCatalogue/alertCategories.yaml +4 -0
  4. psychopy/alerts/alertsCatalogue/alertmsg.py +15 -1
  5. psychopy/alerts/alertsCatalogue/generateAlertmsg.py +2 -2
  6. psychopy/app/Resources/classic/add_many.png +0 -0
  7. psychopy/app/Resources/classic/add_many@2x.png +0 -0
  8. psychopy/app/Resources/classic/devices.png +0 -0
  9. psychopy/app/Resources/classic/devices@2x.png +0 -0
  10. psychopy/app/Resources/classic/photometer.png +0 -0
  11. psychopy/app/Resources/classic/photometer@2x.png +0 -0
  12. psychopy/app/Resources/dark/add_many.png +0 -0
  13. psychopy/app/Resources/dark/add_many@2x.png +0 -0
  14. psychopy/app/Resources/dark/devices.png +0 -0
  15. psychopy/app/Resources/dark/devices@2x.png +0 -0
  16. psychopy/app/Resources/dark/photometer.png +0 -0
  17. psychopy/app/Resources/dark/photometer@2x.png +0 -0
  18. psychopy/app/Resources/light/add_many.png +0 -0
  19. psychopy/app/Resources/light/add_many@2x.png +0 -0
  20. psychopy/app/Resources/light/devices.png +0 -0
  21. psychopy/app/Resources/light/devices@2x.png +0 -0
  22. psychopy/app/Resources/light/photometer.png +0 -0
  23. psychopy/app/Resources/light/photometer@2x.png +0 -0
  24. psychopy/app/_psychopyApp.py +35 -13
  25. psychopy/app/builder/builder.py +88 -35
  26. psychopy/app/builder/dialogs/__init__.py +69 -220
  27. psychopy/app/builder/dialogs/dlgsCode.py +29 -8
  28. psychopy/app/builder/dialogs/paramCtrls.py +1468 -904
  29. psychopy/app/builder/validators.py +25 -17
  30. psychopy/app/coder/coder.py +12 -1
  31. psychopy/app/coder/repl.py +5 -2
  32. psychopy/app/colorpicker/__init__.py +1 -1
  33. psychopy/app/deviceManager/__init__.py +1 -0
  34. psychopy/app/deviceManager/addDialog.py +218 -0
  35. psychopy/app/deviceManager/dialog.py +185 -0
  36. psychopy/app/deviceManager/panel.py +191 -0
  37. psychopy/app/deviceManager/utils.py +60 -0
  38. psychopy/app/idle.py +7 -0
  39. psychopy/app/locale/ar_001/LC_MESSAGE/messages.mo +0 -0
  40. psychopy/app/locale/ar_001/LC_MESSAGE/messages.po +12695 -10592
  41. psychopy/app/locale/cs_CZ/LC_MESSAGE/messages.mo +0 -0
  42. psychopy/app/locale/cs_CZ/LC_MESSAGE/messages.po +10199 -24
  43. psychopy/app/locale/da_DK/LC_MESSAGE/messages.mo +0 -0
  44. psychopy/app/locale/da_DK/LC_MESSAGE/messages.po +10199 -24
  45. psychopy/app/locale/de_DE/LC_MESSAGE/messages.mo +0 -0
  46. psychopy/app/locale/de_DE/LC_MESSAGE/messages.po +11221 -9712
  47. psychopy/app/locale/el_GR/LC_MESSAGE/messages.mo +0 -0
  48. psychopy/app/locale/el_GR/LC_MESSAGE/messages.po +10200 -25
  49. psychopy/app/locale/en_NZ/LC_MESSAGE/messages.mo +0 -0
  50. psychopy/app/locale/en_NZ/LC_MESSAGE/messages.po +10200 -25
  51. psychopy/app/locale/en_US/LC_MESSAGE/messages.mo +0 -0
  52. psychopy/app/locale/en_US/LC_MESSAGE/messages.po +10195 -18
  53. psychopy/app/locale/es_CO/LC_MESSAGE/messages.mo +0 -0
  54. psychopy/app/locale/es_CO/LC_MESSAGE/messages.po +11917 -9101
  55. psychopy/app/locale/es_ES/LC_MESSAGE/messages.mo +0 -0
  56. psychopy/app/locale/es_ES/LC_MESSAGE/messages.po +11924 -9103
  57. psychopy/app/locale/es_US/LC_MESSAGE/messages.mo +0 -0
  58. psychopy/app/locale/es_US/LC_MESSAGE/messages.po +11917 -9101
  59. psychopy/app/locale/et_EE/LC_MESSAGE/messages.mo +0 -0
  60. psychopy/app/locale/et_EE/LC_MESSAGE/messages.po +11084 -9569
  61. psychopy/app/locale/fa_IR/LC_MESSAGE/messages.mo +0 -0
  62. psychopy/app/locale/fa_IR/LC_MESSAGE/messages.po +11590 -5806
  63. psychopy/app/locale/fi_FI/LC_MESSAGE/messages.mo +0 -0
  64. psychopy/app/locale/fi_FI/LC_MESSAGE/messages.po +10199 -24
  65. psychopy/app/locale/fr_FR/LC_MESSAGE/messages.mo +0 -0
  66. psychopy/app/locale/fr_FR/LC_MESSAGE/messages.po +11091 -9577
  67. psychopy/app/locale/he_IL/LC_MESSAGE/messages.mo +0 -0
  68. psychopy/app/locale/he_IL/LC_MESSAGE/messages.po +11072 -9549
  69. psychopy/app/locale/hi_IN/LC_MESSAGE/messages.mo +0 -0
  70. psychopy/app/locale/hi_IN/LC_MESSAGE/messages.po +11071 -9559
  71. psychopy/app/locale/hu_HU/LC_MESSAGE/messages.mo +0 -0
  72. psychopy/app/locale/hu_HU/LC_MESSAGE/messages.po +10200 -25
  73. psychopy/app/locale/it_IT/LC_MESSAGE/messages.mo +0 -0
  74. psychopy/app/locale/it_IT/LC_MESSAGE/messages.po +11072 -9560
  75. psychopy/app/locale/ja_JP/LC_MESSAGE/messages.mo +0 -0
  76. psychopy/app/locale/ja_JP/LC_MESSAGE/messages.po +1485 -1137
  77. psychopy/app/locale/ko_KR/LC_MESSAGE/messages.mo +0 -0
  78. psychopy/app/locale/ko_KR/LC_MESSAGE/messages.po +10199 -24
  79. psychopy/app/locale/ms_MY/LC_MESSAGE/messages.mo +0 -0
  80. psychopy/app/locale/ms_MY/LC_MESSAGE/messages.po +11463 -8757
  81. psychopy/app/locale/nl_NL/LC_MESSAGE/messages.mo +0 -0
  82. psychopy/app/locale/nl_NL/LC_MESSAGE/messages.po +10200 -25
  83. psychopy/app/locale/nn_NO/LC_MESSAGE/messages.mo +0 -0
  84. psychopy/app/locale/nn_NO/LC_MESSAGE/messages.po +10200 -25
  85. psychopy/app/locale/pl_PL/LC_MESSAGE/messages.mo +0 -0
  86. psychopy/app/locale/pl_PL/LC_MESSAGE/messages.po +10200 -25
  87. psychopy/app/locale/pt_PT/LC_MESSAGE/messages.mo +0 -0
  88. psychopy/app/locale/pt_PT/LC_MESSAGE/messages.po +11288 -9434
  89. psychopy/app/locale/ro_RO/LC_MESSAGE/messages.mo +0 -0
  90. psychopy/app/locale/ro_RO/LC_MESSAGE/messages.po +10200 -25
  91. psychopy/app/locale/ru_RU/LC_MESSAGE/messages.mo +0 -0
  92. psychopy/app/locale/ru_RU/LC_MESSAGE/messages.po +10199 -24
  93. psychopy/app/locale/sv_SE/LC_MESSAGE/messages.mo +0 -0
  94. psychopy/app/locale/sv_SE/LC_MESSAGE/messages.po +11441 -8747
  95. psychopy/app/locale/tr_TR/LC_MESSAGE/messages.mo +0 -0
  96. psychopy/app/locale/tr_TR/LC_MESSAGE/messages.po +11069 -9545
  97. psychopy/app/locale/zh_CN/LC_MESSAGE/messages.mo +0 -0
  98. psychopy/app/locale/zh_CN/LC_MESSAGE/messages.po +12085 -8268
  99. psychopy/app/locale/zh_TW/LC_MESSAGE/messages.mo +0 -0
  100. psychopy/app/locale/zh_TW/LC_MESSAGE/messages.po +11929 -8022
  101. psychopy/app/plugin_manager/dialog.py +12 -3
  102. psychopy/app/plugin_manager/packageIndex.py +303 -0
  103. psychopy/app/plugin_manager/packages.py +203 -63
  104. psychopy/app/plugin_manager/plugins.py +120 -240
  105. psychopy/app/preferencesDlg.py +6 -1
  106. psychopy/app/psychopyApp.py +16 -4
  107. psychopy/app/runner/runner.py +10 -2
  108. psychopy/app/runner/scriptProcess.py +8 -3
  109. psychopy/app/stdout/stdOutRich.py +11 -4
  110. psychopy/app/themes/icons.py +3 -0
  111. psychopy/app/utils.py +61 -0
  112. psychopy/data/experiment.py +133 -23
  113. psychopy/data/routine.py +12 -0
  114. psychopy/data/staircase.py +42 -20
  115. psychopy/data/trial.py +20 -12
  116. psychopy/data/utils.py +42 -2
  117. psychopy/demos/builder/Experiments/dragAndDrop/drag_and_drop.psyexp +22 -5
  118. psychopy/demos/builder/Experiments/dragAndDrop/stimuli/solutions.xlsx +0 -0
  119. psychopy/demos/builder/Experiments/stroopVoice/stroopVoice.psyexp +2 -12
  120. psychopy/demos/builder/Feature Demos/buttonBox/buttonBoxDemo.psyexp +3 -8
  121. psychopy/demos/builder/Feature Demos/movies/movie.psyexp +220 -0
  122. psychopy/demos/builder/Feature Demos/movies/readme.md +3 -0
  123. psychopy/demos/builder/Feature Demos/visualValidator/visualValidator.psyexp +1 -2
  124. psychopy/demos/builder/Hardware/camera/camera.psyexp +3 -16
  125. psychopy/demos/builder/Hardware/microphone/microphone.psyexp +3 -16
  126. psychopy/demos/coder/hardware/hdf5_extract.py +133 -0
  127. psychopy/event.py +20 -15
  128. psychopy/experiment/_experiment.py +86 -10
  129. psychopy/experiment/components/__init__.py +3 -10
  130. psychopy/experiment/components/_base.py +9 -20
  131. psychopy/experiment/components/button/__init__.py +1 -1
  132. psychopy/experiment/components/buttonBox/__init__.py +50 -54
  133. psychopy/experiment/components/camera/__init__.py +137 -359
  134. psychopy/experiment/components/keyboard/__init__.py +17 -24
  135. psychopy/experiment/components/microphone/__init__.py +61 -110
  136. psychopy/experiment/components/movie/__init__.py +2 -3
  137. psychopy/experiment/components/serialOut/__init__.py +192 -93
  138. psychopy/experiment/components/settings/__init__.py +45 -27
  139. psychopy/experiment/components/sound/__init__.py +82 -73
  140. psychopy/experiment/components/soundsensor/__init__.py +43 -80
  141. psychopy/experiment/devices.py +303 -0
  142. psychopy/experiment/exports.py +20 -18
  143. psychopy/experiment/flow.py +7 -0
  144. psychopy/experiment/loops.py +47 -29
  145. psychopy/experiment/monitor.py +74 -0
  146. psychopy/experiment/params.py +48 -10
  147. psychopy/experiment/plugins.py +28 -108
  148. psychopy/experiment/py2js_transpiler.py +1 -1
  149. psychopy/experiment/routines/__init__.py +1 -1
  150. psychopy/experiment/routines/_base.py +59 -24
  151. psychopy/experiment/routines/audioValidator/__init__.py +19 -155
  152. psychopy/experiment/routines/visualValidator/__init__.py +25 -25
  153. psychopy/hardware/__init__.py +20 -57
  154. psychopy/hardware/button.py +15 -2
  155. psychopy/hardware/camera/__init__.py +2237 -1394
  156. psychopy/hardware/joystick/__init__.py +1 -1
  157. psychopy/hardware/keyboard.py +5 -8
  158. psychopy/hardware/listener.py +4 -1
  159. psychopy/hardware/manager.py +75 -35
  160. psychopy/hardware/microphone.py +52 -6
  161. psychopy/hardware/monitor.py +144 -0
  162. psychopy/hardware/photometer/__init__.py +156 -117
  163. psychopy/hardware/serialdevice.py +16 -2
  164. psychopy/hardware/soundsensor.py +4 -1
  165. psychopy/iohub/devices/deviceConfigValidation.py +2 -1
  166. psychopy/iohub/devices/keyboard/darwin.py +8 -5
  167. psychopy/iohub/util/__init__.py +7 -8
  168. psychopy/localization/generateTranslationTemplate.py +208 -116
  169. psychopy/localization/messages.pot +4305 -3502
  170. psychopy/monitors/MonitorCenter.py +174 -74
  171. psychopy/plugins/__init__.py +6 -4
  172. psychopy/preferences/devices.py +80 -0
  173. psychopy/preferences/generateHints.py +2 -1
  174. psychopy/preferences/preferences.py +35 -11
  175. psychopy/scripts/psychopy-pkgutil.py +969 -0
  176. psychopy/scripts/psyexpCompile.py +1 -1
  177. psychopy/session.py +34 -38
  178. psychopy/sound/__init__.py +6 -260
  179. psychopy/sound/audioclip.py +164 -0
  180. psychopy/sound/backend_ptb.py +8 -0
  181. psychopy/sound/backend_pygame.py +10 -0
  182. psychopy/sound/backend_pysound.py +9 -0
  183. psychopy/sound/backends/__init__.py +0 -0
  184. psychopy/sound/microphone.py +3 -0
  185. psychopy/sound/sound.py +58 -0
  186. psychopy/tests/data/correctScript/python/correctNoiseStimComponent.py +1 -1
  187. psychopy/tests/data/duplicateHeaders.csv +2 -0
  188. psychopy/tests/test_app/test_builder/test_BuilderFrame.py +22 -7
  189. psychopy/tests/test_app/test_builder/test_CompileFromBuilder.py +0 -2
  190. psychopy/tests/test_data/test_utils.py +5 -1
  191. psychopy/tests/test_experiment/test_components/test_ButtonBoxComponent.py +22 -2
  192. psychopy/tests/test_hardware/test_ports.py +0 -12
  193. psychopy/tests/test_tools/test_stringtools.py +1 -1
  194. psychopy/tools/attributetools.py +12 -5
  195. psychopy/tools/fontmanager.py +17 -14
  196. psychopy/tools/movietools.py +43 -2
  197. psychopy/tools/stringtools.py +33 -8
  198. psychopy/tools/versionchooser.py +1 -1
  199. psychopy/validation/audio.py +5 -1
  200. psychopy/validation/visual.py +5 -1
  201. psychopy/visual/basevisual.py +8 -7
  202. psychopy/visual/circle.py +2 -2
  203. psychopy/visual/image.py +29 -109
  204. psychopy/visual/movies/__init__.py +1800 -313
  205. psychopy/visual/polygon.py +4 -0
  206. psychopy/visual/shape.py +2 -2
  207. psychopy/visual/window.py +34 -11
  208. psychopy/voicekey/__init__.py +41 -669
  209. psychopy/voicekey/labjack_vks.py +7 -48
  210. psychopy/voicekey/parallel_vks.py +7 -42
  211. psychopy/voicekey/vk_tools.py +114 -263
  212. {psychopy-2025.1.1.dist-info → psychopy-2025.2.1.dist-info}/METADATA +17 -11
  213. {psychopy-2025.1.1.dist-info → psychopy-2025.2.1.dist-info}/RECORD +216 -184
  214. {psychopy-2025.1.1.dist-info → psychopy-2025.2.1.dist-info}/WHEEL +1 -1
  215. psychopy/visual/movies/players/__init__.py +0 -62
  216. psychopy/visual/movies/players/ffpyplayer_player.py +0 -1401
  217. psychopy/voicekey/demo_vks.py +0 -12
  218. psychopy/voicekey/signal.py +0 -42
  219. {psychopy-2025.1.1.dist-info → psychopy-2025.2.1.dist-info}/entry_points.txt +0 -0
  220. {psychopy-2025.1.1.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
- from psychopy import prefs
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 FINISHED, NOT_STARTED, PAUSED, PLAYING, STOPPED
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
- from .players import getMoviePlayer
26
- from .metadata import MovieMetadata, NULL_MOVIE_METADATA
27
- from .frame import MovieFrame, NULL_MOVIE_FRAME_INFO
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
- import numpy as np
30
- import pyglet
31
- pyglet.options['debug_gl'] = False
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
- # threshold to stop reporting dropped frames
35
- reportNDroppedFrames = 10
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
- # constants for use with ffpyplayer
38
- FFPYPLAYER_STATUS_EOF = 'eof'
39
- FFPYPLAYER_STATUS_PAUSED = 'paused'
1019
+ """
1020
+ if self._player is None:
1021
+ return ''
1022
+ #raise ValueError('Movie reader is not open. Cannot get subtitle.')
40
1023
 
41
- PREFERRED_VIDEO_LIB = 'ffpyplayer'
1024
+ return ''
42
1025
 
1026
+ def _getVolumeFFPyPlayer(self):
1027
+ """Get the volume of the movie player using the ffpyplayer library.
43
1028
 
44
- # ------------------------------------------------------------------------------
45
- # Classes
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.loop = loop
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
- def loadMovie(self, filename):
200
- """Load a movie file from disk.
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
- Path to movie file. Must be a format that FFMPEG supports.
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 FileNotFoundError("Cannot open movie file `{}`".format(
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
- self._freeBuffers() # free buffers (if any) before creating a new one
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 `loadMovie`).
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.loadMovie(filename=filename)
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._player.stop(log=log)
252
- self._player.unload()
253
- self._freeBuffers() # free buffer before creating a new one
254
- self._isLoaded = False
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
- def updateVideoFrame(self):
266
- """Update the present video frame. The next call to `draw()` will make
267
- the retrieved frame appear.
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
- Returns
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
- # get the current movie frame for the video time
278
- newFrameFromPlayer = self._player.getMovieFrame()
279
- if newFrameFromPlayer is not None:
280
- self._recentFrame = newFrameFromPlayer
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
- # only do a pixel transfer on valid frames
283
- if self._recentFrame is not None:
284
- self._pixelTransfer()
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
- return self._recentFrame
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
- self.play()
1884
+ self.play()
313
1885
 
314
1886
  # update the video frame and draw it to a quad
315
- _ = self.updateVideoFrame()
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
- # Status flags as properties are pretty useful for users since they are
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
- if self._player is not None:
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
- if self._player is not None:
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
- if self._player is not None:
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
- if self._player is not None:
371
- return self._player.isFinished
372
-
373
- return False
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
- # get the absolute experiment time the first frame is to be presented
385
- # if self.status == NOT_STARTED:
386
- # self._player.volume = self._volume
387
-
388
- self._player.play(log=log)
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._player.pause(log=log)
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
- play it.
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 not None:
431
- self._player.stop()
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._player.seek(timestamp, log=log)
2052
+ if self._playbackStatus == PLAYING:
2053
+ self._wasPaused = False
2054
+ elif self._playbackStatus == PAUSED:
2055
+ self._wasPaused = True
445
2056
 
446
- def rewind(self, seconds=5, log=True):
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._player.rewind(seconds, log=log)
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=5, log=True):
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._player.fastForward(seconds, log=log)
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._player.replay(log=log)
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
- return self._player.muted
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.muted = value
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._player.volumeUp(amount)
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._player.volumeDown(amount)
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
- return self._player.volume
2169
+ if self._audioLib == 'sdl2':
2170
+ return self._player.volume
532
2171
 
533
2172
  @volume.setter
534
2173
  def volume(self, value):
535
- self._player.volume = value
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 self._player.frameIndex
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.metadata.duration
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._player.loopCount
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.metadata.frameRate
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
- if not self._player:
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.metadata.size
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._player.pts
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.pts / self.duration) * 100.0
641
-
2276
+ return (self._movieTime / self.duration) * 100.0
2277
+
642
2278
  # --------------------------------------------------------------------------
643
- # OpenGL and rendering
2279
+ # Miscellaneous methods
644
2280
  #
645
2281
 
646
- def _freeBuffers(self):
647
- """Free texture and pixel buffers. Call this when tearing down this
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
- GL.glFlush() # make sure all buffers are ready
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
- # get the size of the movie frame and compute the buffer size
731
- vidWidth, vidHeight = self._player.getMetadata().size
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
- def _drawRectangle(self):
801
- """Draw the video frame to the window.
2294
+ return self._player.getSubtitle()
2295
+
2296
+ def __del__(self):
2297
+ """Destructor for the MovieStim class.
802
2298
 
803
- This is called by the `draw()` method to blit the video to the display
804
- window.
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
- # make sure that textures are on and GL_TEXTURE0 is active
808
- GL.glEnable(GL.GL_TEXTURE_2D)
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
- array = (GL.GLfloat * 32)(
820
- 1, 1, # texture coords
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
- GL.glActiveTexture(GL.GL_TEXTURE0)
832
- GL.glBindTexture(GL.GL_TEXTURE_2D, self._textureId)
833
- GL.glPushClientAttrib(GL.GL_CLIENT_VERTEX_ARRAY_BIT)
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
- # 2D texture array, 3D vertex array
836
- GL.glInterleavedArrays(GL.GL_T2F_V3F, 0, array)
837
- GL.glDrawArrays(GL.GL_QUADS, 0, 4)
838
- GL.glPopClientAttrib()
839
- GL.glPopAttrib()
840
- GL.glPopMatrix()
2317
+ """
2318
+ global _openMovieReaders
841
2319
 
842
- GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
843
- GL.glDisable(GL.GL_TEXTURE_2D)
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