#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""A stimulus class for playing movies (mpeg, avi, etc...) in PsychoPy.
"""
# Part of the PsychoPy library
# Copyright (C) 2002-2018 Jonathan Peirce (C) 2019-2024 Open Science Tools Ltd.
# Distributed under the terms of the GNU General Public License (GPL).
__all__ = ['MovieStim']
import ctypes
import os.path
from pathlib import Path
from psychopy import prefs
from psychopy.tools.filetools import pathToString, defaultStim
from psychopy.visual.basevisual import (
BaseVisualStim, DraggingMixin, ContainerMixin, ColorMixin
)
from psychopy.constants import FINISHED, NOT_STARTED, PAUSED, PLAYING, STOPPED
from .players import getMoviePlayer
from .metadata import MovieMetadata, NULL_MOVIE_METADATA
from .frame import MovieFrame, NULL_MOVIE_FRAME_INFO
import numpy as np
import pyglet
pyglet.options['debug_gl'] = False
GL = pyglet.gl
# threshold to stop reporting dropped frames
reportNDroppedFrames = 10
# constants for use with ffpyplayer
FFPYPLAYER_STATUS_EOF = 'eof'
FFPYPLAYER_STATUS_PAUSED = 'paused'
PREFERRED_VIDEO_LIB = 'ffpyplayer'
# ------------------------------------------------------------------------------
# Classes
#
[docs]class MovieStim(BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin):
"""Class for presenting movie clips as stimuli.
Parameters
----------
win : :class:`~psychopy.visual.Window`
Window the video is being drawn to.
filename : str
Name of the file or stream URL to play. If an empty string, no file will
be loaded on initialization but can be set later.
movieLib : str or None
Library to use for video decoding. By default, the 'preferred' library
by PsychoPy developers is used. Default is `'ffpyplayer'`. An alert is
raised if you are not using the preferred player.
units : str
Units to use when sizing the video frame on the window, affects how
`size` is interpreted.
size : ArrayLike or None
Size of the video frame on the window in `units`. If `None`, the native
size of the video will be used.
draggable : bool
Can this stimulus be dragged by a mouse click?
flipVert : bool
If `True` then the movie will be top-bottom flipped.
flipHoriz : bool
If `True` then the movie will be right-left flipped.
volume : int or float
If specifying an `int` the nominal level is 100, and 0 is silence. If a
`float`, values between 0 and 1 may be used.
loop : bool
Whether to start the movie over from the beginning if draw is called and
the movie is done. Default is `False`.
autoStart : bool
Automatically begin playback of the video when `flip()` is called.
"""
def __init__(self,
win,
filename="",
movieLib=u'ffpyplayer',
units='pix',
size=None,
pos=(0.0, 0.0),
ori=0.0,
anchor="center",
draggable=False,
flipVert=False,
flipHoriz=False,
color=(1.0, 1.0, 1.0), # remove?
colorSpace='rgb',
opacity=1.0,
contrast=1,
volume=1.0,
name='',
loop=False,
autoLog=True,
depth=0.0,
noAudio=False,
interpolate=True,
autoStart=True):
# # check if we have the VLC lib
# if not haveFFPyPlayer:
# raise ImportError(
# 'Cannot import package `ffpyplayer`, therefore `FFMovieStim` '
# 'cannot be used this session.')
# what local vars are defined (these are the init params) for use
self._initParams = dir()
self._initParams.remove('self')
super(MovieStim, self).__init__(
win, units=units, name=name, autoLog=False)
# drawing stuff
self.draggable = draggable
self.flipVert = flipVert
self.flipHoriz = flipHoriz
self.pos = pos
self.ori = ori
self.size = size
self.depth = depth
self.anchor = anchor
self.colorSpace = colorSpace
self.color = color
self.opacity = opacity
# playback stuff
self._filename = pathToString(filename)
self._volume = volume
self._noAudio = noAudio # cannot be changed
self.loop = loop
self._recentFrame = None
self._autoStart = autoStart
self._isLoaded = False
# OpenGL data
self.interpolate = interpolate
self._texFilterNeedsUpdate = True
self._metadata = NULL_MOVIE_METADATA
self._pixbuffId = GL.GLuint(0)
self._textureId = GL.GLuint(0)
# get the player interface for the desired `movieLib` and instance it
self._player = getMoviePlayer(movieLib)(self)
# load a file if provided, otherwise the user must call `setMovie()`
self._filename = pathToString(filename)
if self._filename: # load a movie if provided
self.loadMovie(self._filename)
self.autoLog = autoLog
@property
def filename(self):
"""File name for the loaded video (`str`)."""
return self._filename
@filename.setter
def filename(self, value):
self.loadMovie(value)
[docs] def setMovie(self, value):
if self._isLoaded:
self.unload()
self.loadMovie(value)
@property
def autoStart(self):
"""Start playback when `.draw()` is called (`bool`)."""
return self._autoStart
@autoStart.setter
def autoStart(self, value):
self._autoStart = bool(value)
@property
def frameRate(self):
"""Frame rate of the movie in Hertz (`float`).
"""
return self._player.metadata.frameRate
@property
def _hasPlayer(self):
"""`True` if a media player instance is started.
"""
# use this property to check if the player instance is started in
# methods which require it
return self._player is not None
[docs] def loadMovie(self, filename):
"""Load a movie file from disk.
Parameters
----------
filename : str
Path to movie file. Must be a format that FFMPEG supports.
"""
# If given `default.mp4`, sub in full path
if isinstance(filename, str):
# alias default names (so it always points to default.png)
if filename in defaultStim:
filename = Path(prefs.paths['assets']) / defaultStim[filename]
# check if the file has can be loaded
if not os.path.isfile(filename):
raise FileNotFoundError("Cannot open movie file `{}`".format(
filename))
else:
# If given a recording component, use its last clip
if hasattr(filename, "lastClip"):
filename = filename.lastClip
self._filename = filename
self._player.load(self._filename)
self._freeBuffers() # free buffers (if any) before creating a new one
self._setupTextureBuffers()
self._isLoaded = True
[docs] def load(self, filename):
"""Load a movie file from disk (alias of `loadMovie`).
Parameters
----------
filename : str
Path to movie file. Must be a format that FFMPEG supports.
"""
self.loadMovie(filename=filename)
[docs] def unload(self, log=True):
"""Stop and unload the movie.
Parameters
----------
log : bool
Log this event.
"""
self._player.stop(log=log)
self._player.unload()
self._freeBuffers() # free buffer before creating a new one
self._isLoaded = False
@property
def frameTexture(self):
"""Texture ID for the current video frame (`GLuint`). You can use this
as a video texture. However, you must periodically call
`updateVideoFrame` to keep this up to date.
"""
return self._textureId
[docs] def updateVideoFrame(self):
"""Update the present video frame. The next call to `draw()` will make
the retrieved frame appear.
Returns
-------
bool
If `True`, the video texture has been updated and the frame index is
advanced by one. If `False`, the last frame should be kept
on-screen.
"""
# get the current movie frame for the video time
newFrameFromPlayer = self._player.getMovieFrame()
if newFrameFromPlayer is not None:
self._recentFrame = newFrameFromPlayer
# only do a pixel transfer on valid frames
if self._recentFrame is not None:
self._pixelTransfer()
return self._recentFrame
[docs] def draw(self, win=None):
"""Draw the current frame to a particular window.
The current position in the movie will be determined automatically. This
method should be called on every frame that the movie is meant to
appear. If `.autoStart==True` the video will begin playing when this is
called.
Parameters
----------
win : :class:`~psychopy.visual.Window` or `None`
Window the video is being drawn to. If `None`, the window specified
at initialization will be used instead.
Returns
-------
bool
`True` if the frame was updated this draw call.
"""
self._selectWindow(self.win if win is None else win)
# handle autoplay
if self._autoStart and self.isNotStarted:
self.play()
# update the video frame and draw it to a quad
_ = self.updateVideoFrame()
self._drawRectangle() # draw the texture to the target window
return True
# --------------------------------------------------------------------------
# Video playback controls and status
#
@property
def isPlaying(self):
"""`True` if the video is presently playing (`bool`).
"""
# Status flags as properties are pretty useful for users since they are
# self documenting and prevent the user from touching the status flag
# attribute directly.
#
if self._player is not None:
return self._player.isPlaying
return False
@property
def isNotStarted(self):
"""`True` if the video may not have started yet (`bool`). This status is
given after a video is loaded and play has yet to be called.
"""
if self._player is not None:
return self._player.isNotStarted
return True
@property
def isStopped(self):
"""`True` if the video is stopped (`bool`). It will resume from the
beginning if `play()` is called.
"""
if self._player is not None:
return self._player.isStopped
return False
@property
def isPaused(self):
"""`True` if the video is presently paused (`bool`).
"""
if self._player is not None:
return self._player.isPaused
return False
@property
def isFinished(self):
"""`True` if the video is finished (`bool`).
"""
if self._player is not None:
return self._player.isFinished
return False
[docs] def play(self, log=True):
"""Start or continue a paused movie from current position.
Parameters
----------
log : bool
Log the play event.
"""
# get the absolute experiment time the first frame is to be presented
# if self.status == NOT_STARTED:
# self._player.volume = self._volume
self._player.play(log=log)
[docs] def pause(self, log=True):
"""Pause the current point in the movie. The image of the last frame
will persist on-screen until `play()` or `stop()` are called.
Parameters
----------
log : bool
Log this event.
"""
self._player.pause(log=log)
[docs] def toggle(self, log=True):
"""Switch between playing and pausing the movie. If the movie is playing,
this function will pause it. If the movie is paused, this function will
play it.
Parameters
----------
log : bool
Log this event.
"""
if self.isPlaying:
self.pause()
else:
self.play()
[docs] def stop(self, log=True):
"""Stop the current point in the movie (sound will stop, current frame
will not advance and remain on-screen). Once stopped the movie can be
restarted from the beginning by calling `play()`.
Parameters
----------
log : bool
Log this event.
"""
# stop should reset the video to the start and pause
if self._player is not None:
self._player.stop()
[docs] def seek(self, timestamp, log=True):
"""Seek to a particular timestamp in the movie.
Parameters
----------
timestamp : float
Time in seconds.
log : bool
Log this event.
"""
self._player.seek(timestamp, log=log)
[docs] def rewind(self, seconds=5, log=True):
"""Rewind the video.
Parameters
----------
seconds : float
Time in seconds to rewind from the current position. Default is 5
seconds.
log : bool
Log this event.
"""
self._player.rewind(seconds, log=log)
[docs] def fastForward(self, seconds=5, log=True):
"""Fast-forward the video.
Parameters
----------
seconds : float
Time in seconds to fast forward from the current position. Default
is 5 seconds.
log : bool
Log this event.
"""
self._player.fastForward(seconds, log=log)
[docs] def replay(self, log=True):
"""Replay the movie from the beginning.
Parameters
----------
log : bool
Log this event.
Notes
-----
* This tears down the current media player instance and creates a new
one. Similar to calling `stop()` and `loadMovie()`. Use `seek(0.0)` if
you would like to restart the movie without reloading.
"""
self._player.replay(log=log)
# --------------------------------------------------------------------------
# Audio stream control methods
#
@property
def muted(self):
"""`True` if the stream audio is muted (`bool`).
"""
return self._player.muted
@muted.setter
def muted(self, value):
self._player.muted = value
[docs] def volumeUp(self, amount=0.05):
"""Increase the volume by a fixed amount.
Parameters
----------
amount : float or int
Amount to increase the volume relative to the current volume.
"""
self._player.volumeUp(amount)
[docs] def volumeDown(self, amount=0.05):
"""Decrease the volume by a fixed amount.
Parameters
----------
amount : float or int
Amount to decrease the volume relative to the current volume.
"""
self._player.volumeDown(amount)
@property
def volume(self):
"""Volume for the audio track for this movie (`int` or `float`).
"""
return self._player.volume
@volume.setter
def volume(self, value):
self._player.volume = value
# --------------------------------------------------------------------------
# Video and playback information
#
@property
def frameIndex(self):
"""Current frame index being displayed (`int`)."""
return self._player.frameIndex
@property
def duration(self):
"""Duration of the loaded video in seconds (`float`). Not valid unless
the video has been started.
"""
if not self._player:
return -1.0
return self._player.metadata.duration
@property
def loopCount(self):
"""Number of loops completed since playback started (`int`). Incremented
each time the movie begins another loop.
Examples
--------
Compute how long a looping video has been playing until now::
totalMovieTime = (mov.loopCount + 1) * mov.pts
"""
if not self._player:
return -1
return self._player.loopCount
@property
def fps(self):
"""Movie frames per second (`float`)."""
return self.getFPS()
[docs] def getFPS(self):
"""Movie frames per second.
Returns
-------
float
Nominal number of frames to be displayed per second.
"""
if not self._player:
return 1.0
return self._player.metadata.frameRate
@property
def videoSize(self):
"""Size of the video `(w, h)` in pixels (`tuple`). Returns `(0, 0)` if
no video is loaded.
"""
if not self._player:
return 0, 0
return self._player.metadata.size
@property
def origSize(self):
"""
Alias of videoSize
"""
return self.videoSize
@property
def frameSize(self):
"""Size of the video `(w, h)` in pixels (`tuple`). Alias of `videoSize`.
"""
if not self._player:
return 0, 0
return self._player.metadata.size
@property
def pts(self):
"""Presentation timestamp of the most recent frame (`float`).
This value corresponds to the time in movie/stream time the frame is
scheduled to be presented.
"""
if not self._player:
return -1.0
return self._player.pts
[docs] def getPercentageComplete(self):
"""Provides a value between 0.0 and 100.0, indicating the amount of the
movie that has been already played (`float`).
"""
return (self.pts / self.duration) * 100.0
# --------------------------------------------------------------------------
# OpenGL and rendering
#
[docs] def _freeBuffers(self):
"""Free texture and pixel buffers. Call this when tearing down this
class or if a movie is stopped.
"""
try:
# delete buffers and textures if previously created
if self._pixbuffId.value > 0:
GL.glDeleteBuffers(1, self._pixbuffId)
self._pixbuffId = GL.GLuint()
# delete the old texture if present
if self._textureId.value > 0:
GL.glDeleteTextures(1, self._textureId)
self._textureId = GL.GLuint()
except TypeError: # can happen when unloading or shutting down
pass
[docs] def _setupTextureBuffers(self):
"""Setup texture buffers which hold frame data. This creates a 2D
RGB texture and pixel buffer. The pixel buffer serves as the store for
texture color data. Each frame, the pixel buffer memory is mapped and
frame data is copied over to the GPU from the decoder.
This is called every time a video file is loaded. The `_freeBuffers`
method is called in this routine prior to creating new buffers, so it's
safe to call this right after loading a new movie without having to
`_freeBuffers` first.
"""
# get the size of the movie frame and compute the buffer size
vidWidth, vidHeight = self._player.getMetadata().size
nBufferBytes = vidWidth * vidHeight * 4
# Create the pixel buffer object which will serve as the texture memory
# store. Pixel data will be copied to this buffer each frame.
GL.glGenBuffers(1, ctypes.byref(self._pixbuffId))
GL.glBindBuffer(GL.GL_PIXEL_UNPACK_BUFFER, self._pixbuffId)
GL.glBufferData(
GL.GL_PIXEL_UNPACK_BUFFER,
nBufferBytes * ctypes.sizeof(GL.GLubyte),
None,
GL.GL_STREAM_DRAW) # one-way app -> GL
GL.glBindBuffer(GL.GL_PIXEL_UNPACK_BUFFER, 0)
# Create a texture which will hold the data streamed to the pixel
# buffer. Only one texture needs to be allocated.
GL.glEnable(GL.GL_TEXTURE_2D)
GL.glGenTextures(1, ctypes.byref(self._textureId))
GL.glBindTexture(GL.GL_TEXTURE_2D, self._textureId)
GL.glTexImage2D(
GL.GL_TEXTURE_2D,
0,
GL.GL_RGBA8,
vidWidth, vidHeight, # frame dims in pixels
0,
GL.GL_BGRA,
GL.GL_UNSIGNED_BYTE,
None)
# setup texture filtering
if self.interpolate:
texFilter = GL.GL_LINEAR
else:
texFilter = GL.GL_NEAREST
GL.glTexParameteri(
GL.GL_TEXTURE_2D,
GL.GL_TEXTURE_MAG_FILTER,
texFilter)
GL.glTexParameteri(
GL.GL_TEXTURE_2D,
GL.GL_TEXTURE_MIN_FILTER,
texFilter)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP)
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
GL.glDisable(GL.GL_TEXTURE_2D)
GL.glFlush() # make sure all buffers are ready
[docs] def _pixelTransfer(self):
"""Copy pixel data from video frame to texture.
"""
# get the size of the movie frame and compute the buffer size
vidWidth, vidHeight = self._player.getMetadata().size
nBufferBytes = vidWidth * vidHeight * 4
# bind pixel unpack buffer
GL.glBindBuffer(GL.GL_PIXEL_UNPACK_BUFFER, self._pixbuffId)
# Free last storage buffer before mapping and writing new frame
# data. This allows the GPU to process the extant buffer in VRAM
# uploaded last cycle without being stalled by the CPU accessing it.
GL.glBufferData(
GL.GL_PIXEL_UNPACK_BUFFER,
nBufferBytes * ctypes.sizeof(GL.GLubyte),
None,
GL.GL_STREAM_DRAW)
# Map the buffer to client memory, `GL_WRITE_ONLY` to tell the
# driver to optimize for a one-way write operation if it can.
bufferPtr = GL.glMapBuffer(
GL.GL_PIXEL_UNPACK_BUFFER,
GL.GL_WRITE_ONLY)
bufferArray = np.ctypeslib.as_array(
ctypes.cast(bufferPtr, ctypes.POINTER(GL.GLubyte)),
shape=(nBufferBytes,))
# copy data
bufferArray[:] = self._recentFrame.colorData[:]
# Very important that we unmap the buffer data after copying, but
# keep the buffer bound for setting the texture.
GL.glUnmapBuffer(GL.GL_PIXEL_UNPACK_BUFFER)
# bind the texture in OpenGL
GL.glEnable(GL.GL_TEXTURE_2D)
GL.glActiveTexture(GL.GL_TEXTURE0)
GL.glBindTexture(GL.GL_TEXTURE_2D, self._textureId)
# copy the PBO to the texture
GL.glTexSubImage2D(
GL.GL_TEXTURE_2D, 0, 0, 0,
vidWidth, vidHeight,
GL.GL_BGRA,
GL.GL_UNSIGNED_INT_8_8_8_8_REV,
0) # point to the presently bound buffer
# update texture filtering only if needed
if self._texFilterNeedsUpdate:
if self.interpolate:
texFilter = GL.GL_LINEAR
else:
texFilter = GL.GL_NEAREST
GL.glTexParameteri(
GL.GL_TEXTURE_2D,
GL.GL_TEXTURE_MAG_FILTER,
texFilter)
GL.glTexParameteri(
GL.GL_TEXTURE_2D,
GL.GL_TEXTURE_MIN_FILTER,
texFilter)
self._texFilterNeedsUpdate = False
# important to unbind the PBO
GL.glBindBuffer(GL.GL_PIXEL_UNPACK_BUFFER, 0)
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
GL.glDisable(GL.GL_TEXTURE_2D)
[docs] def _drawRectangle(self):
"""Draw the video frame to the window.
This is called by the `draw()` method to blit the video to the display
window.
"""
# make sure that textures are on and GL_TEXTURE0 is active
GL.glEnable(GL.GL_TEXTURE_2D)
GL.glActiveTexture(GL.GL_TEXTURE0)
# sets opacity (1, 1, 1 = RGB placeholder)
GL.glColor4f(1, 1, 1, self.opacity)
GL.glPushMatrix()
self.win.setScale('pix')
# move to centre of stimulus and rotate
vertsPix = self.verticesPix
array = (GL.GLfloat * 32)(
1, 1, # texture coords
vertsPix[0, 0], vertsPix[0, 1], 0., # vertex
0, 1,
vertsPix[1, 0], vertsPix[1, 1], 0.,
0, 0,
vertsPix[2, 0], vertsPix[2, 1], 0.,
1, 0,
vertsPix[3, 0], vertsPix[3, 1], 0.,
)
GL.glPushAttrib(GL.GL_ENABLE_BIT)
GL.glActiveTexture(GL.GL_TEXTURE0)
GL.glBindTexture(GL.GL_TEXTURE_2D, self._textureId)
GL.glPushClientAttrib(GL.GL_CLIENT_VERTEX_ARRAY_BIT)
# 2D texture array, 3D vertex array
GL.glInterleavedArrays(GL.GL_T2F_V3F, 0, array)
GL.glDrawArrays(GL.GL_QUADS, 0, 4)
GL.glPopClientAttrib()
GL.glPopAttrib()
GL.glPopMatrix()
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
GL.glDisable(GL.GL_TEXTURE_2D)
if __name__ == "__main__":
pass