Entry 3122

Image Sequence to QuickTime

   

Submitted by Chip Chapin on Feb. 1, 2010 at 7:46 a.m.
Language: Python. Code size: 13.0 KB.

#! /usr/bin/env python
"""
Convert one or more still image sequences into QuickTime movies.

Author: Chip Chapin <cchapin@gmail.com>
For More Info: http://cchapin.blogspot.com

You must have the PyWin32 COM interface installed,
see http://sourceforge.net/projects/pywin32/

Acknowledgements:
  The QuickTime interface was originally written as a JScript+WSH
  script, which I based on a 2006 blog post by Luc-Eric Rousseau
  (XSIBlog http://www.xsi-blog.com/archives/103).  Rousseau's script
  was in turn based on sample code by John Cromie, author of the
  book "QuickTime for .NET and COM Developers" (Elsevier 2006,
  http://www.skylark.ie/qt4.net/samplecode.asp).

Latest Update: 2010-01-31

TODO: Some sort of progress indicator during the initial rendering stage.
TODO: Better solution to the QT_PLAYER_DELAY hack.
"""
import datetime
import math
import os
import re
import shutil
import sys
import tempfile
import time
import win32com.client

CODEC_INFO_FILENAME = "C:\\qtMovieFromStillsCodecInfo.xml"
DEFAULT_CHUNK_FILES=7000
DEFAULT_FRAME_RATE = "60"

# HACK: Delay (sec) while the QuickTime player initializes.
QT_PLAYER_DELAY = 7.0

def abort(message=None):
    """Print error message and exit."""
    if message:
        print "ERROR: ", message
    sys.exit(1)

def usage(message=None):
    """Print usage message and exit."""
    if message:
        print message
    sys.exit(2)

def check_file_folder(fpath):
    """Returns true if the directory of fpath exists and is writable."""
    fdir = os.path.dirname(fpath)
    try:
        f = tempfile.TemporaryFile("w", dir=fdir)
    except OSError:
        return False
    f.close()
    return True

def file_exists(fpath):
    """Returns true if file fpath exists and is readable."""
    try:
        f = open(fpath, "r")
    except IOError:
        return False
    f.close()
    return True

def uniqueify(fpath):
    """Check to see if the file exists.  If so, uniqueify the filename."""
    upath = fpath
    count = 0
    while file_exists(upath):
        count = count + 1
        (root, ext) = os.path.splitext(fpath)
        upath = "%s-%02d%s" % (root, count, ext)
    if count:
        print "WARN: You already have a file '%s'" % fpath
        print "   Saving as '%s' instead." % upath
    return upath

def create_new_movie_from_images(sourcePath, frameRate, qtControl):
    """Create the movie from the still image sequence."""
    print "Creating new movie from still sequence '" + sourcePath + "'..."
    try:
        qtControl.CreateNewMovieFromImages(sourcePath,
                                           frameRate,
                                           True) # rate is in frames per second
    except:
        print "ERROR creating movie "
        #+ e.number +
        #           " (" + (e.number>>16 & 0x1FFF) +
        #           "-" + (e.number & 0xffff) + ")");
        #WScript.Echo(e.description);
        # TODO: Find more reliable error reporting.
        # The following doesn't work if the QTControl object is gone.
        print "QuickTime error " + qtControl.ErrorCode
        qte = qtControl.QuickTime.Error
        print "  " + qte.ErrorCode + ", " + qte.Description
        print "  " + qte.SourceReference
        raise

    qtMovie = qtControl.Movie;
    if not qtMovie:
        abort("No movie created (" + qtControl.ErrorCode + ")")
    duration = qtMovie.Duration;
    if (duration == 0):
        # This test isn't as helpful as I thought it would be.  I thought it
        # would catch the case where QT does not have a valid input file, but in
        # that case it seems to create a two-second empty movie 
        # (ie. duration = 20*framerate).
        abort("Movie has duration 0.")

    # Duration is the number of frames * 10.
    print "Created new movie, duration %d" % duration

def get_quicktime_exporter(qtControl):
    """Set up the QuickTime movie exporter."""
    qt = qtControl.QuickTime
    if (qt.Exporters.Count == 0):
        # Only add an exporter if needed.
        qt.Exporters.Add()
        print "Adding new Exporter."
    else:
        print "Using existing Exporter."
    qtExporter = qt.Exporters(1)
    if not qtExporter:
        abort("Unable to get Exporter.")
    qtExporter.TypeName = "QuickTime Movie"
    qtExporter.SetDataSource(qtControl.Movie)

    if file_exists(CODEC_INFO_FILENAME):
        print "Reading codec config from '" + CODEC_INFO_FILENAME + "'"
        CodecFileInfo = open(CODEC_INFO_FILENAME, "r")
        xmlCodecInfoText = CodecFileInfo.read()
        # Cause the exporter to be reconfigured.
        # http://developer.apple.com/technotes/tn2006/tn2120.html
        tempSettings = qtExporter.Settings
        tempSettings.XML = xmlCodecInfoText
        qtExporter.Settings = tempSettings
    else:
        # Use the Settings dialog box, then save the results.
        qtExporter.ShowSettingsDialog()
        xmlCodecInfoText = qtExporter.Settings.XML
        try:
            CodecFileInfo = open(CODEC_INFO_FILENAME, "w")
            CodecFileInfo.write(xmlCodecInfoText)
            CodecFileInfo.close()
        except IOError:
            print ("Warning: failed to save codec info to '" +
                   CODEC_INFO_FILENAME + "'")
            print "continuing ..."
    return qtExporter

def export_movie(qtExporter, destPath):
    """Export the movie."""
    print "Exporting ..."
    try:
        qtExporter.DestinationFileName = destPath
        qtExporter.ShowProgressDialog = True
        # Uncomment this line if you want the export dialog box to appear.
        # qtExporter.ShowExportDialog();
        qtExporter.BeginExport()  # This can take a l-o-n-g time.
        print "Exported to '%s'" % destPath
    except:
        print "ERROR exporting '%s'" % destPath
        #print "ERROR " + (e.number>>16 & 0x1FFF) +
        #      "-" + (e.number & 0xffff) + 
        #      " exporting '" + destPath + "'"
        #print e.description
        #WScript.Echo(JSON.stringify(e, null, 2));
        #qte = qt.Error;
        #print "QuickTime Error %d, %s" % (qte.ErrorCode, qte.Description)
        #WScript.Echo(JSON.stringify(qte, null, 2));
        raise

def qt_movie_from_stills(sourcePath, destPath, frameRate):
    """Create and export a QuickTime movie from a sequence of images."""
    # Launch QuickTime Player Application
    qtPlayerApp = win32com.client.Dispatch(
                    "QuickTimePlayerLib.QuickTimePlayerApp")
    time.sleep(QT_PLAYER_DELAY);  # Give it time to launch.
    if not qtPlayerApp:
        abort("Failed to launch QuickTime Player App.")

    # Get the QuickTime player and its associated controller.
    # NOTE: The script will abort here if the player hasn't had time
    # to initialize.  It should work if you run it again, or you can increase
    # QT_PLAYER_DELAY.
    qtPlayer = qtPlayerApp.Players(1);
    if not qtPlayer:
        abort("Failed to get QuickTime Player.");
    print "Got player '" + qtPlayer.Caption + "'"
    qtControl = qtPlayer.QTControl
    
    create_new_movie_from_images(sourcePath, frameRate, qtControl)
    qtExporter = get_quicktime_exporter(qtControl)
    export_movie(qtExporter, destPath)
    # Closing the player causes failures for subsequent invocations.
    # qtPlayer.Close();

def seq_file_list(a_seq_file):
    """Return the list of file names in the sequence."""
    # Extract the leading invariant part of the sequence filename.
    # For example, if the name is Fooo-0001.jpg, the invariant is "Fooo-".
    # Use non-greedy match so the numbers are kept out.
    (work_folder, sequence_proto) = os.path.split(a_seq_file)
    sm = re.match("(.*?)[0-9]+\.", sequence_proto)
    if not sm:
        usage(("'%s' doesn't look like the start of a sequence."
              % sequence_proto) +
"\nSequences have sequentially numbered file names like Foo-001.jpg, Foo-002.jpg...\n" +
"  sequenceExample -- Path to any file in the sequence.\n" +
"  maxSize -- Maximum number of sequence files to put in a folder.");
        
    # Construct a regexp for matching sequence file names.
    sq_name = sm.group(1)
    sq_re = re.compile("^" + sq_name + "[0-9]+\.");

    # Make a list of files that match the sq_re pattern.  "fnmatch" patterns
    # are not powerful enough to be reliable so we don't use glob.
    sq_files = []
    dir = os.listdir(work_folder)
    dir.sort()
    for f in dir:
      if sq_re.match(f):
        sq_files.append(f)
    return (sq_files, sq_name)

def split_file_list(a_seq_file, sq_files, sq_name, max_size):
    """Split the sequence files into successive split folders.
    Returns the list of split folder names.
    """
    work_folder = os.path.dirname(a_seq_file)

    sq_count = len(sq_files)
    num_splits = math.ceil(sq_count / max_size)
    print ("Splitting file sequence '%s' into %d parts."
           % (sq_name, num_splits))
    parent_folder = os.path.dirname(work_folder)
    file_count = 0
    next_file_in_split = 1
    current_split = 0
    splits = []
    for fname in sq_files:
        if (file_count == 0 or next_file_in_split > max_size):
            current_split = current_split + 1
            next_file_in_split = 1
            split_folder = os.path.join(parent_folder,
                                        ("%s-%d" % (work_folder, current_split)))
            print "New split folder '%s' (%d)" % (split_folder, file_count)
            try:
                os.mkdir(split_folder, 0755)
            except EnvironmentError as (errno, strerror):
                print "ERROR: Failed to create split directory '%s'" % split_folder
                print "[Error %d] %s" % (errno, strerror)
                abort()
            splits.append((split_folder, fname))
            
        # Move the file from work_folder to split_folder
        shutil.move(os.path.join(work_folder, fname),
                    os.path.join(split_folder, fname))
        file_count = file_count + 1
        next_file_in_split = next_file_in_split + 1

    print ("Split %d files in '%s' into %d folders."
           %(file_count, work_folder, current_split))
    return splits

def make_seq_chunks(a_seq_file, max_size):
    """Split a file sequence into folders of no more than max_size files each.
    Returns a list of (folder, starting_file) duples.

    Sequences have file names that end with a sequence number,
    like (Foo-001.jpg, Foo-002.jpg, ...).
    """
    (sq_files, sq_name) = seq_file_list(a_seq_file)
    sq_count = len(sq_files)
    print "Sequence '%s' contains %d members." % (a_seq_file, sq_count)    
    if sq_count <= max_size:
        print "No need to split."
        if (sq_count == 0):
            return []
        else:
            return [os.path.split(a_seq_file)]
    else:
        return split_file_list(a_seq_file, sq_files, sq_name, max_size)

def seq_name_from_filename(fname):
    """Extract the 'sequence name' from a representative filename."""
    sm = re.match("(.*?)[-_]?[0-9]+\.", os.path.basename(fname))
    if not sm:
        print ("'%s' doesn't look like the start of a sequence."
                  % fname)
        return None
    return sm.group(1)

def convert_sequences(base_dir, seqs,
                      frame_rate=DEFAULT_FRAME_RATE,
                      chunk_size=DEFAULT_CHUNK_FILES):
    """Generates QuickTime movies for a set of image sequences.

    base_dir: Full path to the common parent directory.
    seqs: List of duples describing image sequences.
        Each duple contains the FOLDER NAME of the sequence
        and the STARTING IMAGE file name: (folder, file)
        Names are unqualified, i.e. relative to their parent directories.
    frameRate: Numeric frame rate in frames per second.
    chunk_size: Maximum number of frames to be processed in
        a single movie.
    """
    for (sq_dir, sq_file) in seqs:
        src = "%s\\%s\\%s" % (base_dir, sq_dir, sq_file)
        if not file_exists(src):
            print "WARN: '%s' is not readable.  Skipping." % src
            continue
        # Possibly split the image sequence into chunks.
        splits = make_seq_chunks(src, chunk_size)
        # Iterate over the chunks.
        for (split_folder, split_file) in splits:
            sq_name = seq_name_from_filename(sq_file)
            if (sq_name):
                dst = ("%s\\%s_%s.mov" %
                       (base_dir, sq_name, os.path.basename(split_folder)))
                dst = uniqueify(dst)
                print time.strftime("%Y-%m-%d %X  Converting:") 
                print ("    Folder %s\n    Start %s\n    Dest %s"
                       % (split_folder, split_file, dst))
                qt_movie_from_stills(src, dst, frame_rate)
                print time.strftime("Complete at %Y-%m-%d %X")

def main(argv):
    """Main Program"""
    usage("This is the seq_to_qt module.\n" +
          "For batch conversion, edit 'seq_batch_convert.py'.  It calls the functions here.")

if __name__ == "__main__":
    main(sys.argv)

This snippet took 0.05 seconds to highlight.

Back to the Entry List or Home.

Delete this entry (admin only).