#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Classes and functions for working with movies 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__ = [
'MovieFileWriter',
'closeAllMovieWriters',
'addAudioToMovie',
'MOVIE_WRITER_FFPYPLAYER',
'MOVIE_WRITER_OPENCV',
'MOVIE_WRITER_NULL',
'VIDEO_RESOLUTIONS'
]
import os
import time
import threading
import queue
import atexit
import numpy as np
import psychopy.logging as logging
# constants for specifying encoders for the movie writer
MOVIE_WRITER_FFPYPLAYER = u'ffpyplayer'
MOVIE_WRITER_OPENCV = u'opencv'
MOVIE_WRITER_NULL = u'null' # use prefs for default
# Common video resolutions in pixels (width, height). Users should be able to
# pass any of these strings to fields that require a video resolution. Setters
# should uppercase the string before comparing it to the keys in this dict.
VIDEO_RESOLUTIONS = {
'VGA': (640, 480),
'SVGA': (800, 600),
'XGA': (1024, 768),
'SXGA': (1280, 1024),
'UXGA': (1600, 1200),
'QXGA': (2048, 1536),
'WVGA': (852, 480),
'WXGA': (1280, 720),
'WXGA+': (1440, 900),
'WSXGA+': (1680, 1050),
'WUXGA': (1920, 1200),
'WQXGA': (2560, 1600),
'WQHD': (2560, 1440),
'WQXGA+': (3200, 1800),
'UHD': (3840, 2160),
'4K': (4096, 2160),
'8K': (7680, 4320)
}
# Keep track of open movie writers here. This is used to close all movie writers
# when the main thread exits. Any waiting frames are flushed to the file before
# the file is finalized. We identify movie writers by hashing the filename they
# are presently writing to.
_openMovieWriters = set()
[docs]class MovieFileWriter:
"""Create movies from a sequence of images.
This class allows for the creation of movies from a sequence of images using
FFMPEG (via the `ffpyplayer` or `cv2` libraries). Writing movies to disk is
a slow process, so this class uses a separate thread to write the movie in
the background. This means that you can continue to add images to the movie
while frames are still being written to disk. Movie writers are closed
automatically when the main thread exits. Any remaining frames are flushed
to the file before the file is finalized.
Writing audio tracks is not supported. If you need to add audio to your
movie, create the file with the video content first, then add the audio
track to the file. The :func:`addAudioToMovie` function can be used to do
this after the video and audio files have been saved to disk.
Parameters
----------
filename : str
The name (or path) of the file to write the movie to. The file extension
determines the movie format if `codec` is `None` for some backends.
Otherwise it must be explicitly specified.
size : tuple or str
The size of the movie in pixels (width, height). If a string is passed,
it should be one of the keys in the `VIDEO_RESOLUTIONS` dictionary.
fps : float
The number of frames per second.
codec : str or None
The codec to use for encoding the movie. This may be a codec identifier
(e.g., 'libx264') or a FourCC code. The value depends of the
`encoderLib` in use. If `None`, the writer will select the codec based
on the file extension of `filename` (if supported by the backend).
pixelFormat : str
Pixel format for frames being added to the movie. This should be
either 'rgb24' or 'rgba32'. The default is 'rgb24'. When passing frames
to `addFrame()` as a numpy array, the array should be in the format
specified here.
encoderLib : str
The library to use to handle encoding and writing the movie to disk. The
default is 'ffpyplayer'.
encoderOpts : dict or None
A dictionary of options to pass to the encoder. These option can be used
to control the quality of the movie, for example. The options depend on
the `encoderLib` in use. If `None`, the writer will use the default
options for the backend.
Examples
--------
Create a movie from a sequence of generated noise images::
import psychopy.tools.movietools as movietools
import numpy as np
# create a movie writer
writer = movietools.MovieFileWriter(
filename='myMovie.mp4',
size=(640, 480),
fps=30)
# open the movie for writing
writer.open()
# add some frames to the movie
for i in range(5 * writer.fps): # 5 seconds of video
# create a frame, just some random noise
frame = np.random.randint(0, 255, (640, 480, 3), dtype=np.uint8)
# add the frame to the movie
writer.addFrame(frame)
# close the movie, this completes the writing process
writer.close()
Setting additional options for the movie encoder requires passing a
dictionary of options to the `encoderOpts` parameter. The options depend on
the encoder library in use. For example, to set the quality of the movie
when using the `ffpyplayer` library, you can do the following::
ffmpegOpts = {'preset': 'medium', 'crf': 16} # medium quality, crf=16
writer = movietools.MovieFileWriter(
filename='myMovie.mp4',
size='720p',
fps=30,
encoderLib='ffpyplayer',
encoderOpts=ffmpegOpts)
The OpenCV backend specifies options differently. To set the quality of the
movie when using the OpenCV library with a codec that support variable
quality, you can do the following::
cvOpts = {'quality': 80} # set the quality to 80 (0-100)
writer = movietools.MovieFileWriter(
filename='myMovie.mp4',
size='720p',
fps=30,
encoderLib='opencv',
encoderOpts=cvOpts)
"""
# supported pixel formats as constants
PIXEL_FORMAT_RGB24 = 'rgb24'
PIXEL_FORMAT_RGBA32 = 'rgb32'
def __init__(self, filename, size, fps, codec=None, pixelFormat='rgb24',
encoderLib='ffpyplayer', encoderOpts=None):
# objects needed to build up the asynchronous movie writer interface
self._writerThread = None # thread for writing the movie file
self._frameQueue = queue.Queue() # queue for frames to be written
self._dataLock = threading.Lock() # lock for accessing shared data
self._lastVideoFile = None # last video file we wrote to
# set the file name
self._filename = None
self._absPath = None # use for generating a hash of the filename
self.filename = filename # use setter to init self._filename
# Select the default codec based on the encoder library, we want to use
# H264 for OpenCV and libx264 for ffpyplayer. If the user specifies a
# codec, we use that instead.
if encoderLib == 'ffpyplayer':
self._codec = codec or 'libx264' # default codec
elif encoderLib == 'opencv':
self._codec = codec or 'mp4v'
if len(self._codec) != 4:
raise ValueError('OpenCV codecs must be FourCC codes')
else:
raise ValueError('Unknown encoder library: {}'.format(encoderLib))
self._encoderLib = encoderLib
self._encoderOpts = {} if encoderOpts is None else encoderOpts
self._size = None
self.size = size # use setter to init self._size
self._fps = None
self.fps = fps # use setter to init self._fps
self._pixelFormat = pixelFormat
# frame interval in seconds
self._frameInterval = 1.0 / self._fps
# keep track of the number of bytes we saved to the movie file
self._pts = 0.0 # most recent presentation timestamp
self._bytesOut = 0
self._framesOut = 0
def __hash__(self):
"""Use the absolute file path as the hash value since we only allow one
instance per file.
"""
return hash(self._absPath)
@property
def filename(self):
"""The name (path) of the movie file (`str`).
This cannot be changed after the writer has been opened.
"""
return self._filename
@filename.setter
def filename(self, value):
if self.isOpen:
raise RuntimeError(
'Cannot change `filename` after the writer has been opened.')
self._filename = value
self._absPath = os.path.abspath(self._filename)
@property
def size(self):
"""The size `(w, h)` of the movie in pixels (`tuple` or `str`).
If a string is passed, it should be one of the keys in the
`VIDEO_RESOLUTIONS` dictionary.
This can not be changed after the writer has been opened.
"""
return self._size
@size.setter
def size(self, value):
if self.isOpen:
raise RuntimeError(
'Cannot change `size` after the writer has been opened.')
# if a string is passed, try to look up the size in the dictionary
if isinstance(value, str):
try:
value = VIDEO_RESOLUTIONS[value.upper()]
except KeyError:
raise ValueError(
f'Unknown video resolution: {value}. Must be one of: '
f'{", ".join(VIDEO_RESOLUTIONS.keys())}.')
if len(value) != 2:
raise ValueError('`size` must be a collection of two integers.')
self._size = tuple(value)
@property
def frameSize(self):
"""The size `(w, h)` of the movie in pixels (`tuple`).
This is an alias for `size` to synchronize naming with other video
classes around PsychoPy.
"""
return self._size
@frameSize.setter
def frameSize(self, value):
self.size = value
@property
def fps(self):
"""Output frames per second (`float`).
This is the number of frames per second that will be written to the
movie file. The default is 30.
"""
return self._fps
@fps.setter
def fps(self, value):
if self.isOpen:
raise RuntimeError(
'Cannot change `fps` after the writer has been opened.')
if value <= 0:
raise ValueError('`fps` must be greater than 0.')
self._fps = value
self._frameInterval = 1.0 / self._fps
@property
def frameRate(self):
"""Output frames per second (`float`).
This is an alias for `fps` to synchronize naming with other video
classes around PsychoPy.
"""
return self._fps
@frameRate.setter
def frameRate(self, value):
self.fps = value
@property
def codec(self):
"""The codec to use for encoding the movie (`str`).
This may be a codec identifier (e.g., 'libx264'), or a FourCC code (e.g.
'MPV4'). The value depends of the `encoderLib` in use. If `None`, the a
codec determined by the file extension will be used.
"""
return self._codec
@codec.setter
def codec(self, value):
if self.isOpen:
raise RuntimeError(
'Cannot change `codec` after the writer has been opened.')
self._codec = value
@property
def pixelFormat(self):
"""Pixel format for frames being added to the movie (`str`).
This should be either 'rgb24' or 'rgba32'. The default is 'rgb24'. When
passing frames to `addFrame()` as a numpy array, the array should be in
the format specified here.
"""
return self._pixelFormat
@pixelFormat.setter
def pixelFormat(self, value):
if self.isOpen:
raise RuntimeError(
'Cannot change `pixelFormat` after the writer has been opened.')
self._pixelFormat = value
@property
def encoderLib(self):
"""The library to use for writing the movie (`str`).
Can only be set before the movie file is opened. The default is
'ffpyplayer'.
"""
return self._encoderLib
@encoderLib.setter
def encoderLib(self, value):
if not self.isOpen:
raise RuntimeError(
'Cannot change `encoderLib` after the writer has been opened.')
self._encoderLib = value
@property
def encoderOpts(self):
"""Encoder options (`dict`).
These are passed directly to the encoder library. The default is an
empty dictionary.
"""
return self._encoderOpts
@encoderOpts.setter
def encoderOpts(self, value):
if not self.isOpen:
raise RuntimeError(
'Cannot change `encoderOpts` after the writer has been opened.')
self._encoderOpts = value
@property
def lastVideoFile(self):
"""The name of the last video file written to disk (`str` or `None`).
This is `None` if no video file has been written to disk yet. Only valid
after the movie file has been closed (i.e. after calling `close()`.)
"""
return self._lastVideoFile
@property
def isOpen(self):
"""Whether the movie file is open (`bool`).
If `True`, the movie file is open and frames can be added to it. If
`False`, the movie file is closed and no more frames can be added to it.
"""
if self._writerThread is None:
return False
return self._writerThread.is_alive()
@property
def framesOut(self):
"""Total number of frames written to the movie file (`int`).
Use this to monitor the progress of the movie file writing. This value
is updated asynchronously, so it may not be accurate if you are adding
frames to the movie file very quickly.
This value is retained after the movie file is closed. It is cleared
when a new movie file is opened.
"""
with self._dataLock:
return self._framesOut
@property
def bytesOut(self):
"""Total number of bytes (`int`) saved to the movie file.
Use this to monitor how much disk space is occupied by the frames that
have been written so far. This value is updated asynchronously, so it
may not be accurate if you are adding frames to the movie file very
quickly.
This value is retained after the movie file is closed. It is cleared
when a new movie file is opened.
"""
with self._dataLock:
return self._bytesOut
@property
def framesWaiting(self):
"""The number of frames waiting to be written to disk (`int`).
This value increases when you call `addFrame()` and decreases when the
frame is written to disk. This number can be reduced to zero by calling
`flush()`.
"""
return self._frameQueue.qsize()
@property
def totalFrames(self):
"""The total number of frames that will be written to the movie file
(`int`).
This incudes frames that have already been written to disk and frames
that are waiting to be written to disk.
"""
return self.framesOut + self.framesWaiting
@property
def frameInterval(self):
"""The time interval between frames (`float`).
This is the time interval between frames in seconds. This is the
reciprocal of the frame rate.
"""
return self._frameInterval
@property
def duration(self):
"""The duration of the movie in seconds (`float`).
This is the total duration of the movie in seconds based on the number
of frames that have been added to the movie and the frame rate. This
does not represent the actual duration of the movie file on disk, which
may be longer if frames are still being written to disk.
"""
return self.totalFrames * self._frameInterval
[docs] def _openFFPyPlayer(self):
"""Open a movie writer using FFPyPlayer.
This is called by `open()` if `encoderLib` is 'ffpyplayer'. It will
create a background thread to write the movie file. This method is not
intended to be called directly.
"""
# import in the class too avoid hard dependency on ffpyplayer
from ffpyplayer.writer import MediaWriter
from ffpyplayer.pic import SWScale
def _writeFramesAsync(filename, writerOpts, libOpts, frameQueue, readyBarrier,
dataLock):
"""Local function used to write frames to the movie file.
This is executed in a thread to allow the main thread to continue
adding frames to the movie while the movie is being written to
disk.
Parameters
----------
filename : str
Path of the movie file to write.
writerOpts : dict
Options to configure the movie writer. These are FFPyPlayer
settings and are passed directly to the `MediaWriter` object.
libOpts : dict
Option to configure FFMPEG with.
frameQueue : queue.Queue
A queue containing the frames to write to the movie file.
Pushing `None` to the queue will cause the thread to exit.
readyBarrier : threading.Barrier or None
A `threading.Barrier` object used to synchronize the movie
writer with other threads. This guarantees that the movie writer
is ready before frames are passed te the queue. If `None`,
no synchronization is performed.
dataLock : threading.Lock
A lock used to synchronize access to the movie writer object for
accessing variables.
"""
# create the movie writer, don't manipulate this object while the
# movie is being written to disk
try:
writer = MediaWriter(filename, [writerOpts], libOpts=libOpts)
except Exception: # catch all exceptions
raise RuntimeError("Failed to open movie file.")
# wait on a barrier
if readyBarrier is not None:
readyBarrier.wait()
while True:
frame = frameQueue.get() # waited on until a frame is added
if frame is None:
break
# get the frame data
colorData, pts = frame
# do color conversion
frameWidth, frameHeight = colorData.get_size()
sws = SWScale(
frameWidth, frameHeight,
colorData.get_pixel_format(),
ofmt='yuv420p')
# write the frame to the file
bytesOut = writer.write_frame(
img=sws.scale(colorData),
pts=pts,
stream=0)
# update the number of bytes saved
with dataLock:
self._bytesOut += bytesOut
self._framesOut += 1
writer.close()
# options to configure the writer
frameWidth, frameHeight = self.size
writerOptions = {
'pix_fmt_in': 'yuv420p', # default for now using mp4
'width_in': frameWidth,
'height_in': frameHeight,
'codec': self._codec,
'frame_rate': (int(self._fps), 1)}
# create a barrier to synchronize the movie writer with other threads
self._syncBarrier = threading.Barrier(2)
# initialize the thread, the thread will wait on frames to be added to
# the queue
self._writerThread = threading.Thread(
target=_writeFramesAsync,
args=(self._filename,
writerOptions,
self._encoderOpts,
self._frameQueue,
self._syncBarrier,
self._dataLock))
self._writerThread.start()
logging.debug("Waiting for movie writer thread to start...")
self._syncBarrier.wait() # wait for the thread to start
logging.debug("Movie writer thread started.")
[docs] def _openOpenCV(self):
"""Open a movie writer using OpenCV.
This is called by `open()` if `encoderLib` is 'opencv'. It will create
a background thread to write the movie file. This method is not
intended to be called directly.
"""
import cv2
def _writeFramesAsync(writer, filename, frameSize, frameQueue,
readyBarrier, dataLock):
"""Local function used to write frames to the movie file.
This is executed in a thread to allow the main thread to continue
adding frames to the movie while the movie is being written to
disk.
Parameters
----------
writer : cv2.VideoWriter
A `cv2.VideoWriter` object used to write the movie file.
filename : str
Path of the movie file to write.
frameSize : tuple
The size of the frames in pixels as a `(width, height)` tuple.
frameQueue : queue.Queue
A queue containing the frames to write to the movie file.
Pushing `None` to the queue will cause the thread to exit.
readyBarrier : threading.Barrier or None
A `threading.Barrier` object used to synchronize the movie
writer with other threads. This guarantees that the movie writer
is ready before frames are passed te the queue. If `None`,
no synchronization is performed.
dataLock : threading.Lock
A lock used to synchronize access to the movie writer object for
accessing variables.
"""
frameWidth, frameHeight = frameSize
# wait on a barrier
if readyBarrier is not None:
readyBarrier.wait()
# we can accept frames for writing now
while True:
frame = frameQueue.get()
if frame is None: # exit if we get `None`
break
colorData, _ = frame # get the frame data
# Resize and color conversion, this puts the data in the correct
# format for OpenCV's frame writer
colorData = cv2.resize(colorData, (frameWidth, frameHeight))
colorData = cv2.cvtColor(colorData, cv2.COLOR_RGB2BGR)
# write the actual frame out to the file
writer.write(colorData)
# number of bytes the last frame took
# bytesOut = writer.get(cv2.VIDEOWRITER_PROP_FRAMEBYTES)
bytesOut = os.stat(filename).st_size
# update values in a thread safe manner
with dataLock:
self._bytesOut = bytesOut
self._framesOut += 1
writer.release()
# Open the writer outside of the thread so exception opening it can be
# caught beforehand.
writer = cv2.VideoWriter(
self._filename,
cv2.CAP_FFMPEG, # use ffmpeg
cv2.VideoWriter_fourcc(*self._codec),
float(self._fps),
self._size,
1) # is color image?
if self._encoderOpts:
# only supported option for now is `quality`, this doesn't really
# work for teh default OpenCV codec for some reason :(
quality = self._encoderOpts.get('VIDEOWRITER_PROP_QUALITY', None) \
or self._encoderOpts.get('quality', None)
if quality is None:
quality = writer.get(cv2.VIDEOWRITER_PROP_QUALITY)
logging.debug("Quality not specified, using default value of "
f"{quality}.")
writer.set(cv2.VIDEOWRITER_PROP_QUALITY, float(quality))
logging.info(f"Setting movie writer quality to {quality}.")
if not writer.isOpened():
raise RuntimeError("Failed to open movie file.")
# create a barrier to synchronize the movie writer with other threads
self._syncBarrier = threading.Barrier(2)
# initialize the thread, the thread will wait on frames to be added to
# the queue
self._writerThread = threading.Thread(
target=_writeFramesAsync,
args=(writer,
self._filename,
self._size,
self._frameQueue,
self._syncBarrier,
self._dataLock))
self._writerThread.start()
_openMovieWriters.add(self) # add to the list of open movie writers
logging.debug("Waiting for movie writer thread to start...")
self._syncBarrier.wait() # wait for the thread to start
logging.debug("Movie writer thread started.")
[docs] def open(self):
"""Open the movie file for writing.
This creates a new thread that will write the movie file to disk in
the background.
After calling this method, you can add frames to the movie using
`addFrame()`. When you are done adding frames, call `close()` to
finalize the movie file.
"""
if self.isOpen:
raise RuntimeError('Movie writer is already open.')
# register ourselves as an open movie writer
global _openMovieWriters
# check if we already have a movie writer for this file
if self in _openMovieWriters:
raise ValueError(
'A movie writer is already open for file {}'.format(
self._filename))
logging.debug('Creating movie file for writing %s', self._filename)
# reset counters
self._bytesOut = self._framesOut = 0
self._pts = 0.0
# eventually we'll want to support other encoder libraries, for now
# we're just going to hardcode the encoder libraries we support
if self._encoderLib == 'ffpyplayer':
self._openFFPyPlayer()
elif self._encoderLib == 'opencv':
self._openOpenCV()
else:
raise ValueError(
"Unknown encoder library '{}'.".format(self._encoderLib))
_openMovieWriters.add(self) # add to the list of open movie writers
logging.info("Movie file '%s' opened for writing.", self._filename)
[docs] def flush(self):
"""Flush waiting frames to the movie file.
This will cause all frames waiting in the queue to be written to disk
before continuing the program i.e. the thread that called this method.
This is useful for ensuring that all frames are written to disk before
the program exits.
"""
# check if the writer thread present and is alive
if not self.isOpen:
raise RuntimeError('Movie writer is not open.')
# block until the queue is empty
nWaitingAtStart = self.framesWaiting
while not self._frameQueue.empty():
# simple check to see if the queue size is decreasing monotonically
nWaitingNow = self.framesWaiting
if nWaitingNow > nWaitingAtStart:
logging.warn(
"Queue length not decreasing monotonically during "
"`flush()`. This may indicate that frames are still being "
"added ({} -> {}).".format(
nWaitingAtStart, nWaitingNow)
)
nWaitingAtStart = nWaitingNow
time.sleep(0.001) # sleep for 1 ms
[docs] def close(self):
"""Close the movie file.
This shuts down the background thread and finalizes the movie file. Any
frames still waiting in the queue will be written to disk before the
movie file is closed. This will block the program until all frames are
written, therefore, it is recommended for `close()` to be called outside
any time-critical code.
"""
if self._writerThread is None:
return
logging.debug("Closing movie file '{}'.".format(self.filename))
# if the writer thread is alive still, then we need to shut it down
if self._writerThread.is_alive():
self._frameQueue.put(None) # signal the thread to exit
# flush remaining frames, if any
msg = ("File '{}' still has {} frame(s) queued to be written to "
"disk, waiting to complete.")
nWaiting = self.framesWaiting
if nWaiting > 0:
logging.warning(msg.format(self.filename, nWaiting))
self.flush()
self._writerThread.join() # waits until the thread exits
# unregister ourselves as an open movie writer
try:
global _openMovieWriters
_openMovieWriters.remove(self)
except AttributeError:
pass
# set the last video file for later use. This is handy for users wanting
# to add audio tracks to video files they created
self._lastVideoFile = self._filename
self._writerThread = None
logging.info("Movie file '{}' closed.".format(self.filename))
[docs] def _convertImage(self, image):
"""Convert an image to a pixel format appropriate for the encoder.
This is used internally to convert an image (i.e. frame) to the native
frame format which the encoder library can work with. At the very least,
this function should accept a `numpy.array` as a valid type for `image`
no matter what encoder library is being used.
Parameters
----------
image : Any
The image to convert.
Returns
-------
Any
The converted image. Resulting object type depends on the encoder
library being used.
"""
# convert the image to a format that the selected encoder library can
# work with
if self._encoderLib == 'ffpyplayer': # FFPyPlayer `MediaWriter`
import ffpyplayer.pic as pic
if isinstance(image, np.ndarray):
# make sure we are the correct format
image = np.ascontiguousarray(image, dtype=np.uint8).tobytes()
return pic.Image(
plane_buffers=[image],
pix_fmt=self._pixelFormat,
size=self._size)
elif isinstance(image, pic.Image):
# check if the format is valid
if image.get_pixel_format() != self._pixelFormat:
raise ValueError('Invalid pixel format for `image`.')
return image
else:
raise TypeError(
'Unsupported `image` type for OpenCV '
'`MediaWriter.write_frame().')
elif self._encoderLib == 'opencv': # OpenCV `VideoWriter`
if isinstance(image, np.ndarray):
image = image.reshape(self._size[0], self._size[1], 3)
return np.ascontiguousarray(image, dtype=np.uint8)
else:
raise TypeError(
'Unsupported `image` type for OpenCV `VideoWriter.write().')
else:
raise RuntimeError('Unsupported encoder library specified.')
[docs] def addFrame(self, image, pts=None):
"""Add a frame to the movie.
This adds a frame to the movie. The frame will be added to a queue and
written to disk by a background thread. This method will block until the
frame is added to the queue.
Any color space conversion or resizing will be performed in the caller's
thread. This may be threaded too in the future.
Parameters
----------
image : numpy.ndarray or ffpyplayer.pic.Image
The image to add to the movie. The image must be in RGB format and
have the same size as the movie. If the image is an `Image`
instance, it must have the same size as the movie.
pts : float or None
The presentation timestamp for the frame. This is the time at which
the frame should be displayed. The presentation timestamp is in
seconds and should be monotonically increasing. If `None`, the
presentation timestamp will be automatically generated based on the
chosen frame rate for the output video. Not all encoder libraries
support presentation timestamps, so this parameter may be ignored.
Returns
-------
float
Presentation timestamp assigned to the frame. Should match the value
passed in as `pts` if provided, otherwise it will be the computed
presentation timestamp.
"""
if not self.isOpen:
# nb - eventually we can allow frames to be added to a closed movie
# object and have them queued until the movie is opened which will
# commence writing
raise RuntimeError('Movie file not open for writing.')
# convert to a format for the selected writer library
colorData = self._convertImage(image)
# get computed presentation timestamp if not provided
pts = self._pts if pts is None else pts
# pass the image data to the writer thread
self._frameQueue.put((colorData, pts))
# update the presentation timestamp after adding the frame
self._pts += self._frameInterval
return pts
def __del__(self):
"""Close the movie file when the object is deleted.
"""
try:
self.close()
except AttributeError:
pass
[docs]def closeAllMovieWriters():
"""Signal all movie writers to close.
This function should only be called once at the end of the program. This can
be registered `atexit` to ensure that all movie writers are closed when the
program exits. If there are open file writers with frames still queued, this
function will block until all frames remaining are written to disk.
Use caution when calling this function when file writers are being used in a
multi-threaded environment. Threads that are writing movie frames must be
stopped prior to calling this function. If not, the thread may continue to
write frames to the queue during the flush operation and never exit.
"""
global _openMovieWriters
if not _openMovieWriters: # do nothing if no movie writers are open
return
logging.info('Closing all open ({}) movie writers now'.format(
len(_openMovieWriters)))
for movieWriter in _openMovieWriters.copy():
# flush the movie writer, this will block until all frames are written
movieWriter.close()
_openMovieWriters.clear() # clear the set to free references
# register the cleanup function to run when the program exits
atexit.register(closeAllMovieWriters)
[docs]def addAudioToMovie(outputFile, videoFile, audioFile, useThreads=True,
removeFiles=False, writerOpts=None):
"""Add an audio track to a video file.
This function will add an audio track to a video file. If the video file
already has an audio track, it will be replaced with the audio file
provided. If no audio file is provided, the audio track will be removed
from the video file.
The audio track should be exactly the same length as the video track.
Parameters
----------
outputFile : str
Path to the output video file where audio and video will be merged.
videoFile : str
Path to the input video file.
audioFile : str or None
Path to the audio file to add to the video file.
codec : str
The name of the audio codec to use. This should be a valid codec name
for the encoder library being used. If `None`, the default codec for
the encoder library will be used.
useThreads : bool
If `True`, the audio will be added in a separate thread. This allows the
audio to be added in the background while the program continues to run.
If `False`, the audio will be added in the main thread and the program
will block until the audio is added. Defaults to `True`.
removeFiles : bool
If `True`, the input video (`videoFile`) and audio (`audioFile`) files
will be removed (i.e. deleted from disk) after the audio has been added
to the video. Defaults to `False`.
writerOpts : dict or None
Options to pass to the movie writer. This should be a dictionary of
keyword arguments to pass to the movie writer. If `None`, the default
options for the movie writer will be used. Defaults to `None`. See
documentation for `moviepy.video.io.VideoFileClip.write_videofile` for
possible values.
Examples
--------
Combine a video file and an audio file into a single video file::
from psychopy.tools.movietools import addAudioToMovie
addAudioToMovie('output.mp4', 'video.mp4', 'audio.mp3')
"""
from moviepy.video.io.VideoFileClip import VideoFileClip
from moviepy.audio.io.AudioFileClip import AudioFileClip
from moviepy.audio.AudioClip import CompositeAudioClip
# default options for the writer
moviePyOpts = {
'verbose': False,
'logger': None
}
if writerOpts is not None: # make empty dict if not provided
moviePyOpts.update(writerOpts)
def _renderVideo(outputFile, videoFile, audioFile, removeFiles, writerOpts):
"""Render the video file with the audio track.
"""
# merge audio and video tracks, we use MoviePy for this
videoClip = VideoFileClip(videoFile)
audioClip = AudioFileClip(audioFile)
videoClip.audio = CompositeAudioClip([audioClip])
# transcode with the format the user wants
videoClip.write_videofile(
outputFile,
**writerOpts) # expand out options
if removeFiles:
# remove the input files
os.remove(videoFile)
os.remove(audioFile)
# run the audio/video merge in the main thread
if not useThreads:
logging.debug('Adding audio to video file in main thread')
_renderVideo(
outputFile,
videoFile,
audioFile,
removeFiles,
moviePyOpts)
return
# run the audio/video merge in a separate thread
logging.debug('Adding audio to video file in separate thread')
compositorThread = threading.Thread(
target=_renderVideo,
args=(outputFile,
videoFile,
audioFile,
removeFiles,
moviePyOpts))
compositorThread.start()
if __name__ == "__main__":
pass