class FFPyPlayer(BaseMoviePlayer): """Interface class for the FFPyPlayer library for use with `MovieStim`. This class also serves as the reference implementation for classes which interface with movie codec libraries for use with `MovieStim`. Creating new player classes which closely replicate the behaviour of this one should allow them to smoothly plug into `MovieStim`. """ _movieLib = 'ffpyplayer' def __init__(self, parent): self._filename = u"" self.parent = parent # handle to `ffpyplayer` self._handle = None # thread for reading frames asynchronously self._tStream = None # data from stream thread self._lastFrame = NULL_MOVIE_FRAME_INFO self._frameIndex = -1 self._loopCount = 0 self._metadata = None # metadata from the stream self._lastPlayerOpts = DEFAULT_FF_OPTS.copy() # options from the parent if self.parent.loop: # infinite loop self._lastPlayerOpts['loop'] = 0 else: self._lastPlayerOpts['loop'] = 1 # play once if hasattr(self.parent, '_noAudio'): self._lastPlayerOpts['an'] = self.parent._noAudio # status flags self._status = NOT_STARTED def start(self, log=True): """Initialize and start the decoder. This method will return when a valid frame is made available. """ # clear queued data from previous streams self._lastFrame = None self._frameIndex = -1 # open the media player self._handle = MediaPlayer(self._filename, ff_opts=self._lastPlayerOpts) self._handle.set_pause(True) # Pull the first frame to get metadata. NB - `_enqueueFrame` should be # able to do this but the logic in there depends on having access to # metadata first. That may be rewritten at some point to reduce all of # this to just a single `_enqeueFrame` call. # self._status = NOT_STARTED # hand off the player interface to the thread self._tStream = MovieStreamThreadFFPyPlayer(self._handle) self._tStream.begin() # make sure we have metadata self.update() def load(self, pathToMovie): """Load a movie file from disk. Parameters ---------- pathToMovie : str Path to movie file, stream (URI) or camera. Must be a format that FFMPEG supports. """ # set the file path self._filename = pathToString(pathToMovie) # Check if the player is already started. Close it and load a new # instance if so. if self._handle is not None: # player already started # make sure it's the correct type if not isinstance(self._handle, MediaPlayer): raise TypeError( 'Incorrect type for `FFMovieStim._player`, expected ' '`ffpyplayer.player.MediaPlayer`. Got type `{}` ' 'instead.'.format(type(self._handle).__name__)) # close the player and reset self.unload() # self._selectWindow(self.win) # free buffers here !!! self.start() self._status = NOT_STARTED def unload(self): """Unload the video stream and reset. """ self._handle.close_player() self._filename = u"" self._frameIndex = -1 self._handle = None # reset @property def handle(self): """Handle to the `MediaPlayer` object exposed by FFPyPlayer. If `None`, no media player object has yet been initialized. """ return self._handle @property def isLoaded(self): return self._handle is not None @property def metadata(self): """Most recent metadata (`MovieMetadata`). """ return self.getMetadata() def getMetadata(self): """Get metadata from the movie stream. Returns ------- MovieMetadata Movie metadata object. If no movie is loaded, `NULL_MOVIE_METADATA` is returned. At a minimum, fields `duration`, `size`, and `frameRate` are populated if a valid movie has been previously loaded. """ self._assertMediaPlayer() metadata = self._metadata # write metadata to the fields of a `MovieMetadata` object toReturn = MovieMetadata(mediaPath=self._filename, title=metadata['title'], duration=metadata['duration'], frameRate=metadata['frame_rate'], size=metadata['src_vid_size'], pixelFormat=metadata['src_pix_fmt'], movieLib=self._movieLib, userData=None) return toReturn def _assertMediaPlayer(self): """Ensure the media player instance is available. Raises a `RuntimeError` if no movie is loaded. """ if isinstance(self._handle, MediaPlayer): return # nop if we're good raise RuntimeError( "Calling this class method requires a successful call to " "`load` first.") @property def status(self): """Player status flag (`int`). """ return self._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. # return self.status == PLAYING @property def isNotStarted(self): """`True` if the video has not be started yet (`bool`). This status is given after a video is loaded and play has yet to be called. """ return self.status == NOT_STARTED @property def isStopped(self): """`True` if the movie has been stopped. """ return self.status == STOPPED @property def isPaused(self): """`True` if the movie has been paused. """ self._assertMediaPlayer() return self._handle.get_pause() @property def isFinished(self): """`True` if the video is finished (`bool`). """ # why is this the same as STOPPED? return self.status == FINISHED def play(self, log=False): """Start or continue a paused movie from current position. Parameters ---------- log : bool Log the play event. Returns ------- int or None Frame index playback started at. Should always be `0` if starting at the beginning of the video. Returns `None` if the player has not been initialized. """ self._assertMediaPlayer() self._tStream.play() self._status = PLAYING def stop(self, log=False): """Stop the current point in the movie (sound will stop, current frame will not advance). Once stopped the movie cannot be restarted - it must be loaded again. Use `pause()` instead if you may need to restart the movie. Parameters ---------- log : bool Log the stop event. """ if self._tStream is None: raise RuntimeError("Cannot close stream, not opened yet.") # close the thread if not self._tStream.isDone(): self._tStream.shutdown() self._tStream.join() # wait until thread exits self._tStream = None if self._handle is not None: self._handle.close_player() self._handle = None # reset def pause(self, log=False): """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._assertMediaPlayer() self._tStream.pause() return False def seek(self, timestamp, log=False): """Seek to a particular timestamp in the movie. Parameters ---------- timestamp : float Time in seconds. log : bool Log the seek event. """ raise NotImplementedError( "This feature is not available for the current backend.") def rewind(self, seconds=5, log=False): """Rewind the video. Parameters ---------- seconds : float Time in seconds to rewind from the current position. Default is 5 seconds. log : bool Log this event. Returns ------- float Timestamp after rewinding the video. """ raise NotImplementedError( "This feature is not available for the current backend.") def fastForward(self, seconds=5, log=False): """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. Returns ------- float Timestamp at new position after fast forwarding the video. """ raise NotImplementedError( "This feature is not available for the current backend.") def replay(self, autoStart=True, log=False): """Replay the movie from the beginning. Parameters ---------- autoStart : bool Start playback immediately. If `False`, you must call `play()` afterwards to initiate playback. 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. """ lastMovieFile = self._filename self.stop() # stop the movie # self._autoStart = autoStart self.load(lastMovieFile) # will play if auto start # -------------------------------------------------------------------------- # Audio stream control methods # @property def muted(self): """`True` if the stream audio is muted (`bool`). """ return self._handle.get_mute() # thread-safe? @muted.setter def muted(self, value): self._tStream.setMute(value) def volumeUp(self, amount): """Increase the volume by a fixed amount. Parameters ---------- amount : float or int Amount to increase the volume relative to the current volume. """ self._assertMediaPlayer() # get the current volume from the player self.volume = self.volume + amount return self.volume def volumeDown(self, amount): """Decrease the volume by a fixed amount. Parameters ---------- amount : float or int Amount to decrease the volume relative to the current volume. """ self._assertMediaPlayer() # get the current volume from the player self.volume = self.volume - amount return self.volume @property def volume(self): """Volume for the audio track for this movie (`int` or `float`). """ self._assertMediaPlayer() return self._handle.get_volume() # thread-safe? @volume.setter def volume(self, value): self._assertMediaPlayer() self._tStream.setVolume(max(min(value, 1.0), 0.0)) @property def loopCount(self): """Number of loops completed since playback started (`int`). This value is reset when either `stop` or `loadMovie` is called. """ return self._loopCount # -------------------------------------------------------------------------- # Timing related methods # # The methods here are used to handle timing, such as converting between # movie and experiment timestamps. # @property def pts(self): """Presentation timestamp for the current movie frame in seconds (`float`). The value for this either comes from the decoder or some other time source. This should be synchronized to the start of the audio track. A value of `-1.0` is invalid. """ if self._handle is None: return -1.0 return self._lastFrame.absTime def getStartAbsTime(self): """Get the absolute experiment time in seconds the movie starts at (`float`). This value reflects the time which the movie would have started if played continuously from the start. Seeking and pausing the movie causes this value to change. Returns ------- float Start time of the movie in absolute experiment time. """ self._assertMediaPlayer() return getTime() - self._lastFrame.absTime def movieToAbsTime(self, movieTime): """Convert a movie timestamp to absolute experiment timestamp. Parameters ---------- movieTime : float Movie timestamp to convert to absolute experiment time. Returns ------- float Timestamp in experiment time which is coincident with the provided `movieTime` timestamp. The returned value should usually be precise down to about five decimal places. """ self._assertMediaPlayer() # type checks on parameters if not isinstance(movieTime, float): raise TypeError( "Value for parameter `movieTime` must have type `float` or " "`int`.") return self.getStartAbsTime() + movieTime def absToMovieTime(self, absTime): """Convert absolute experiment timestamp to a movie timestamp. Parameters ---------- absTime : float Absolute experiment time to convert to movie time. Returns ------- float Movie time referenced to absolute experiment time. If the value is negative then provided `absTime` happens before the beginning of the movie from the current time stamp. The returned value should usually be precise down to about five decimal places. """ self._assertMediaPlayer() # type checks on parameters if not isinstance(absTime, float): raise TypeError( "Value for parameter `absTime` must have type `float` or " "`int`.") return absTime - self.getStartAbsTime() def movieTimeFromFrameIndex(self, frameIdx): """Get the movie time a specific a frame with a given index is scheduled to be presented. This is used to handle logic for seeking through a video feed (if permitted by the player). Parameters ---------- frameIdx : int Frame index. Negative values are accepted but they will return negative timestamps. """ self._assertMediaPlayer() return frameIdx * self._metadata.frameInterval def frameIndexFromMovieTime(self, movieTime): """Get the frame index of a given movie time. Parameters ---------- movieTime : float Timestamp in movie time to convert to a frame index. Returns ------- int Frame index that should be presented at the specified movie time. """ self._assertMediaPlayer() return math.floor(movieTime / self._metadata.frameInterval) @property def isSeekable(self): """Is seeking allowed for the video stream (`bool`)? If `False` then `frameIndex` will increase monotonically. """ return False # fixed for now @property def frameInterval(self): """Duration a single frame is to be presented in seconds (`float`). This is derived from the framerate information in the metadata. If not movie is loaded, the returned value will be invalid. """ return self.metadata.frameInterval @property def frameIndex(self): """Current frame index (`int`). Index of the current frame in the stream. If playing from a file or any other seekable source, this value may not increase monotonically with time. A value of `-1` is invalid, meaning either the video is not started or there is some issue with the stream. """ return self._lastFrame.frameIndex def getPercentageComplete(self): """Provides a value between 0.0 and 100.0, indicating the amount of the movie that has been already played (`float`). """ duration = self.metadata.duration return (self.pts / duration) * 100.0 # -------------------------------------------------------------------------- # Methods for getting video frames from the encoder # def _enqueueFrame(self): """Grab the latest frame from the stream. Returns ------- bool `True` if a frame has been enqueued. Returns `False` if the camera is not ready or if the stream was closed. """ self._assertMediaPlayer() # If the queue is empty, the decoder thread has not yielded a new frame # since the last call. enqueuedFrame = self._tStream.getRecentFrame() if enqueuedFrame is None: return False # Unpack the data we got back ... # Note - Bit messy here, we should just hold onto the `enqueuedFrame` # instance and reference its fields from properties. Keeping like this # for now. frameImage = enqueuedFrame.frameImage streamStatus = enqueuedFrame.streamStatus self._metadata = enqueuedFrame.metadata self.parent.status = self._status = streamStatus.status self._frameIndex = streamStatus.frameIndex self._loopCount = streamStatus.loopCount # status information self._streamTime = streamStatus.streamTime # stream time for the camera # if we have a new frame, update the frame information videoBuffer = frameImage.to_bytearray()[0] videoFrameArray = np.frombuffer(videoBuffer, dtype=np.uint8) # provide the last frame self._lastFrame = MovieFrame( frameIndex=self._frameIndex, absTime=self._streamTime, displayTime=self.metadata.frameInterval, size=frameImage.get_size(), colorData=videoFrameArray, audioChannels=0, # not populated yet ... audioSamples=None, metadata=self.metadata, movieLib=u'ffpyplayer', userData=None) return True def update(self): """Update this player. This get the latest data from the video stream and updates the player accordingly. This should be called at a higher frequency than the frame rate of the movie to avoid frame skips. """ self._assertMediaPlayer() # check if the stream reader thread is present and alive, if not the # movie is finished if not self._tStream.isDone(): self._enqueueFrame() else: self.parent.status = self._status = FINISHED def getMovieFrame(self): """Get the movie frame scheduled to be displayed at the current time. Returns ------- `~psychopy.visual.movies.frame.MovieFrame` Current movie frame. """ self.update() return self._lastFrame def __del__(self): """Cleanup when unloading. """ if hasattr(self, '_tStream'): if self._tStream is not None: if not self._tStream.isDone(): self._tStream.shutdown() self._tStream.join() if hasattr(self, '_handle'): if self._handle is not None: self._handle.close_player()