Mimsy Were the Borogoves

Hacks: Articles about programming in Python, Perl, Swift, BASIC, and whatever else I happen to feel like hacking at.

42 Astoundingly Useful Scripts and Automations for the Macintosh

Work faster and more reliably. Add actions to the services menu and the menu bar, create drag-and-drop apps to make your Macintosh play music, roll dice, and talk. Create ASCII art from photos. There’s a script for that in 42 Astounding Scripts for the Macintosh.

Media duration in Python on Mac OS X

Jerry Stratton, August 23, 2008

We’re working on a podcast server and building the upload application using Django. Towards the bottom of my task list on this project is “Populate duration on upload for appropriate media”. That is, find a way to get the duration of each media file from within Python. It’s a step I’ve been dreading, because it looked like not only was it going to require some complicated libraries, it looked like it was going to require complicated libraries per media type: one for mp3, one for mpeg movies, etc.

The server we’re running this on is Mac OS X Leopard. We chose not to use Podcast Producer for it because we need anyone, regardless of platform, to be able to upload files. But I was wondering today if Podcast Producer’s workflows might be able to automatically optimize the uploaded media.

As I browsed through the Podcast Producer Workflow Tutorial, I discovered that the workflows are just Ruby scripts. And then I saw this snippet in a script that joined two media files:

  • first_input_movie_time_range = OSX::QTMakeTimeRange(zero_time, first_input_movie.duration)

That looked a whole lot like a command line script to get the duration of a media file. I quickly used that as an example to whip up a Ruby script to get the duration of a media file.

[toggle code]

  • #!/usr/bin/ruby
  • require 'osx/cocoa'
  • OSX.require_framework 'QTKit'
  • if ARGV.size != 1
    • $stderr.puts "duration filename"
    • exit(-1)
  • end
  • filename = ARGV[0]
  • media, error = OSX::QTMovie.movieWithFile_error(filename)
  • if error != nil or media == nil:
    • $stderr.puts "Could not load media file"
    • exit(1)
  • end
  • duration = media.duration.timeValue/media.duration.timeScale
  • $stderr.puts filename, media.duration.timeValue, media.duration.timeScale
  • $stderr.puts "Duration: ", duration

Durations from QTMovie come with a time scale and a time value. The time value needs to be divided by the scale to provide the duration of that media file in seconds.

This Ruby script uses the Ruby/Cocoa bridge. But there’s also a Python/Cocoa bridge, PyObjC, included with Mac OS X. The next step was to build the same thing in Python:

[toggle code]

  • #!/usr/bin/python
  • from QTKit import QTMovie
  • import sys
  • from optparse import OptionParser
  • parser = OptionParser()
  • (options, args) = parser.parse_args()
  • if len(args) != 1:
    • print "duration filename"
    • sys.exit(0)
  • filename = args[0]
  • attributes = {'QTMovieFileNameAttribute': filename}
  • movie, error = QTMovie.movieWithAttributes_error_(attributes, None)
  • if error or not movie:
    • print "Problem with movie", filename, ":", error.description()
    • sys.exit(0)
  • duration = movie.duration().timeValue/movie.duration().timeScale
  • print filename, "is", duration, "seconds long"

This script will provide the duration of mp3 files, QuickTime files, m4a files, mp4 files, and mpeg files, any file that QuickTime can open. The only file I ended up having trouble with was swf files, and for our purposes we can live without giving them a duration.

No Matching Architecture

So that is an example of how cool it is, sometimes, to work on the OS X command line: easy integration with multimedia from Python and Ruby scripts. I almost never give programming articles headline status on Mimsy, but this was cool enough that I considered it. It opens up an amazing amount of functionality to simple command line scripts. Some people get a tingle talking to politicians; I get a tingle typing “import WebKit”. Here, for example, is a command line script to convert the first page of a web site to a PDF file:

[toggle code]

  • #!/usr/bin/python
  • import WebKit, Foundation, AppKit
  • from optparse import OptionParser
  • import os.path
  • class appDelegate(Foundation.NSObject):
    • def applicationDidFinishLaunching_(self, notification):
      • webview = notification.object().windows()[0].contentView()
      • webview.frameLoadDelegate().loadURL(webview)
  • class webPrinter(Foundation.NSObject, WebKit.protocols.WebFrameLoadDelegate):
    • def loadURL(self, webview):
      • page = Foundation.NSURL.URLWithString_(url)
      • pageRequest = Foundation.NSURLRequest.requestWithURL_(page)
      • webview.mainFrame().loadRequest_(pageRequest)
    • def webView_didFinishLoadForFrame_(self, webView, frame):
      • printAttributes = AppKit.NSPrintInfo.sharedPrintInfo().dictionary()
      • printAttributes[AppKit.NSPrintSavePath] = filePath
      • printAttributes[AppKit.NSPrintJobDisposition] = AppKit.NSPrintSaveJob
      • printInfo = AppKit.NSPrintInfo.alloc().initWithDictionary_(printAttributes)
      • #scale the page horizontally to fit on one page, width-wise
      • printInfo.setHorizontalPagination_(AppKit.NSFitPagination)
      • preferences = webView.preferences()
      • if (options.screen):
        • preferences.setShouldPrintBackgrounds_(True)
        • webView.setMediaStyle_("screen")
      • else:
        • #shouldPrintBackgrounds seems to stick
        • preferences.setShouldPrintBackgrounds_(False)
      • #pdf = frame.frameView().printOperationWithPrintInfo_(printInfo)
      • pdf = AppKit.NSPrintOperation.printOperationWithView_printInfo_(webView, printInfo)
      • pdf.setJobTitle_(webView.mainFrameTitle())
      • pdf.setShowsPrintPanel_(False)
      • pdf.setShowsProgressPanel_(False)
      • pdf.runOperation()
      • AppKit.NSApplication.sharedApplication().terminate_(None)
  • parser = OptionParser()
  • parser.add_option("-s", "--screen", help="print as it appears on the screen", action="store_true", dest="screen")
  • (options, args) = parser.parse_args()
  • if len(args) != 2:
    • print "Usage: webToPDF URL filename"
  • else:
    • url = args[0]
    • filePath = args[1]
    • #NSPrintOperation requires a full path or it will ignore NSPrintSaveJob and print instead
    • filePath = os.path.abspath(filePath)
    • #this looks a lot like inches times 100
    • viewRect = Foundation.NSMakeRect(0, 0, 850, 1100)
    • app = AppKit.NSApplication.sharedApplication()
    • delegate = appDelegate.alloc().init()
    • AppKit.NSApp().setDelegate_(delegate)
    • window = AppKit.NSWindow.alloc()
    • window.initWithContentRect_styleMask_backing_defer_(viewRect, 0, AppKit.NSBackingStoreBuffered, False)
    • view = WebKit.WebView.alloc()
    • view.initWithFrame_(viewRect)
    • view.mainFrame().frameView().setAllowsScrolling_(False)
    • window.setContentView_(view)
    • loadDelegate = webPrinter.alloc().init()
    • view.setFrameLoadDelegate_(loadDelegate)
    • app.run()

Unfortunately, the coolness is mitigated by the inability to use either of these scripts in a server context. In the above example, it won’t run under most servers, because the windowing portions of it requires an active login. And the duration script runs into problems on Intel servers because Apache is 64-bit but PyObjC is not. OS X won’t load PyObjC into Apache because of that mismatch. If I try to “import QTKit” into our Django application, I get:

Could not import files.views. Error was: dlopen(/System/Library/Frameworks/Python.framework/Versions/2.5/Extras/lib/python/PyObjC/objc/_objc.so, 2): no suitable image found. Did find: /System/Library/Frameworks/Python.framework/Versions/2.5/Extras/lib/python/PyObjC/objc/_objc.so: no matching architecture in universal wrapper

Fear of that error was the main reason I dreaded having to install a bunch of libraries in order to get media file durations. There’s pretty much no way around this without seriously hacking either the default PyObjC installation or the default Apache installation. Fortunately, for this purpose I only need to get the duration when someone uploads a large media file. Another second or two from shelling out after an upload won’t matter.

[toggle code]

  • import datetime, subprocess
  • class Upload(models.Model):
    • file = models.FileField(upload_to=fileUploadPath, validator_list=[fileValidator])
    • def save(self):
      • super(Upload, self).save()
      • if self.duration == None:
        • if self.filetype.extension in ['mp3', 'mov', 'm4a', 'mp4', 'mpg']:
          • cmd = "/Users/holden/bin/duration"
          • pipe = subprocess.Popen([cmd, self.file.path], stdout=subprocess.PIPE)
          • duration = pipe.stdout.read()
          • pipe.stdout.close
          • duration = int(duration)
          • if duration:
            • duration = datetime.timedelta(seconds=duration)
            • self.duration = str(duration)
            • self.save()

In the “duration” script, change the “print” line to simply “print duration”, so that it only outputs the number of seconds. Also, in the “fileUploadPath” function that I give upload_to, I have it set instance.duration to None so that I only grab the duration when a new file has been uploaded.

The next version of PyObjC is 64-bit compatible, so if we’re lucky shelling out won’t be necessary in a future update of Leopard server.

February 8, 2011: QTKit duration in Snow Leopard Server

In Snow Leopard, the duration method returns a three-value tuple instead of whatever it was returning in Leopard. Instead of named values, you’ll need to change the last two lines (of the Python script) to:

[toggle code]

  • timevalue, timescale, unknownInt  = movie.duration()
  • duration = timevalue/timescale
  • print filename, "is", duration, "seconds long"

If you’re running it in a terminal on your workstation while you have a GUI session going, everything is fine.

If you’re running it on a server, you will then see an error that looks like this:

[toggle code]

  • $ bin/duration octopus.mp4
  • Tue Feb  8 09:07:38 www.example.com QTKitServer[20450] <Error>: kCGErrorFailure: Set a breakpoint @ CGErrorBreakpoint() to catch errors as they are logged.
  • _RegisterApplication(), FAILED TO establish the default connection to the WindowServer, _CGSDefaultConnection() is NULL.
  • octopus.mp4 is 13 seconds long

Look closely, and you can see that despite complaining about the lack of a WindowServer, the script did work. Fortunately, the warnings go to stderr, so if you’re using this in a script and parsing the results, you only get the printed duration.

There still appears to be an issue importing QTMovie into models.py on Snow Leopard when running in a server context, although I’m not sure what it is—when I added “from QTKit import QTMovie” to the top of models.py, the server never completed its response, which meant that I never received an error.

Finally, I don’t know what the third item in the tuple is, other than being an int and always zero. I thought at first it might be an error response like in QTMovie.movieWithAttributes_error_(), but that value is None when there’s no error, not zero, so who knows. The QTKit documentation calls it “flags”, but doesn’t explain what the flags are.

QTTime. Defines the value and time scale of a time.

[toggle code]

  • typedef struct {
    • long long timeValue;
    • long timeScale;
    • long flags;
  • } QTTime;

QTTime is a simple data structure that consists of three fields. In this case, the timeScale field is the number of units per second you work with when dealing with time. The timeValue field is the number of those units in duration.

To be safe, you may want to check the value and warn if it is ever not zero.

  1. <- Leopard PIL
  2. Upgrading Django ->