Mr Speaker

Pixelmator save, Project reload.

End result: Hit save in Pixelmator, web page automatically reloads with new PNGs.
Tools: Pixelmator (image editor), Grunt + LiveReload (task runner), Automator (Mac automation thingo), Bash (Bash).

I'm generally not a fan of automating tasks, unless the task is consistent over multiple projects and over long periods of time. Automating things is fun - so you quickly lose track of how much time you reeeaaallly spend on it and, more importantly, automation tends to be subject to "digital decay" - project structure changes, build tools update dependency versions (or disappear entirely), things require maintenance. Finally, the build tools and scripts become part of your code base: which is a cause of incidental complexity. Anyone touching your project needs to not only understand your code, but also all the extra cruft around it to "save time".

Annnyhoo. Automation obviously not always bad - if you find yourself doing something for the 100th time and everytime you do it you give a lil' sigh... then it's good to automate. For me, it was while makin' some pixels in Pixelmator for a game:

Change a pixel -> FILE -> EXPORT -> "PNG" -> NEXT -> "Export As:" -> EXPORT -> "File already exists. Do you want to replace it?" -> REPLACE. Browser refresh. Lil' sigh.

"Ripe for automation!" I exclaimed to myself. And, as per my fears outlined in the opening paragraph, I spent way too long doing it. But now that it's done it seems fairly stable, despite its "Rube Goldberg Machine" nature. I'm not sure anyone else in the world will want to follow this exact set up, but you don't have to use all the same tools - you can just use bits that fit into your current workflow.

The short version of the process goes like this: Grunt watches any .pxm files for changes. When it detects a change, it runs an Automator task which converts the .pxm file to a .png file. Grunt also watches for changes to PNG files, and uses LiveReload to reload the running app in the browser.

The long version is longer. The actual conversion work is done by Automator. If you have Pixelmator installed then you will see an Automator task called "Change Type of Images". Drag this into the workflow, and save the project as an Automator App. You can then run it from the command line, like so:

open /path/to/app/pxm2png.app --args path/to/pxm/file.pxm

This will convert file.pxm to file.png. Even just this is pretty useful - you can now just hit save in Pixelmator, and run this script... instead of going through the annoying series of steps required to export.

But it'll be nicer to integrate that into our workflow. To do so, we'll put the command in a bash script that accepts the file name as a parameter:

#!/bin/bash
open /path/to/app/pxm2png.app --args "`pwd`/$1"

This will be saved into the same directory as the source .pxm file. Don't forget to +x the script so we can execute it. The next step is to call this from Grunt when something changes. Grunt has a handy "watch" module that will keep an eye out for filesystem changes and let us know about them. Inside my Grunt config file I have the following task:

watch: {
  res: {
    files   : ['project/res/**/*.pxm'],
    tasks   : ['pxm2png'],
    options : {
      nospawn: true
    },
    modified: []
  }
}

When I do grunt watch from my shell, it looks for changes in my project resource folder for anything matching **/*.pxm - any .pxm files in any subdirectory. If one of these files it runs a custom task called "pxm2png". I also have added an empty array called "modified". We'll need this in the next step...

The next step is to keep track of which file changed. Unfortunately, Grunt will tell us when something changes, but it doens't tell us what has changed. The only way to know which file was updated is to listen out to the "watch" event and store the file in the array we added above.

// Set the pxm file changed to be retrieved by pxm2png task
grunt.event.on('watch', function(action, filepath) {
  if (grunt.file.isMatch('project/res/**/*.pxm', filepath)) {
    grunt.log.writeln('setting file path:' + filepath);
    grunt.config("res", { modified: [filepath] })
  }
});

Any time a .pxm file is update, we store it in the modified array (it seems like it needs to be an array, even though we only have a single file... anyhoo...)

Our custom Grunt task is then called, and we know which file we need to convert - so we can pass this to our Automator script:

grunt.registerTask('pxm2png', 'convert Pixelmator files to pngs', function() {
  grunt.log.writeln('Converting pxm file: ' + grunt.config("res.modified"));
  var cp = grunt.util.spawn({
    cmd: "./pxm2png",
    args: grunt.config("res.modified")
  }, function (err, res, code) {
    if (code !== 0) {
      grunt.log.writeln("Error converting .pxm file:", err, res, code);
    }
  });
});

Alrighty! Now our .pngs are automatically updated anytime we hit save on a .pxm file! The last thing to do, is to watch for changes in .png files and reload the project. We need to watch the .png files, rather than doing it at the same time as the original .pxm files change - because the conversion process takes a little bit longer than the time it takes for our project to reload: so the new .pngs will not be ready until we refreshed again. It makes more sense to just wait until the new pngs are written and have a separate watch task on them:

watch: {
  res: { ... },
  src: {
    files : ['project/res/**/*.{png,jpg,jpeg,gif}', 'project/index.html'],
    options : {
      livereload: {
        liveImg: false
      },
      nospawn: true
    }
  }
}

And that's that. Phew. One caveat of the process is that your project reloads everytime a Pixelmator file is updated: that includes when Pixelmator decides to autosave your work, so don't be scared if your game resets in the middle of a test run!

Here's the entire Grunt file for my DIGIBOTS & CO game. It saved me a tonne of time when doing the graphics for the game and I've already used it again on another project... so I think it was a worth-while automation effort. Not counting the time it took me to write this blog post, of course.

Captcha! Please type 'radical' here: *
How did you find this thingo? *