Encoding PNGs in AS3, asynchronously

Mar 27 2010 Published by under ActionScript

Occasionally I make apps that create bitmaps and save them. To do so you need to use an encoder to turn the bitmapdata bits into a byte array that can be saved in some image format. AS3 has a PNGEncoder class, but the main problem with it is that it’s pretty slow. I’m saving a 4000×4000 bitmap and it takes sometimes well over 30 seconds, during which time, the app completely freezes up.

Some time last year I was looking around to see if anyone had created an asynchronous version, i.e. one where you could tell it to encode your bitmap and it would do a little bit each frame and tell you when it was done. I wasn’t able to find one. At the time, I took a quick look at the idea of converting the PNGEncoder to do this, but never followed through. Yesterday I started an app that really needed this functionality, and I took another look at it.

Basically the encoder writes some header stuff into a byte array, then loops through from y = 0 to y = height in an outer loop, and from x = 0 to x = width in an inner loop, where it deals with each pixel, writing it to the byte array. Finally, it sets a few more bits and ends off.

What I did was extract the inner loop into its own method, writeRow. And the stuff after the loop into a method called completeWrite. This required making a lot of local variables into class variables. Finally, I converted the outer loop into an onEnterFrame function that listens to the ENTER_FRAME event of a Sprite that I create for no other purpose than to have an ENTER_FRAME event. It’s pretty ugly, I know, but it seems the enter frame got much better performance than a timer. With a timer, whatever your delay is will be inserted between each loop, whereas the enter frame will run ASAP. You could make a really small delay, like 1 millisecond, but that seems like it’s open to some bad side effects. I felt more comfortable with the magic sprite.

Then I found that rather than doing just a single row on each frame, I got better results if I did a chunk of rows. I’m getting pretty good results at 20 rows at a time for a 4000×4000 bitmap, but I didn’t do any kind of benchmarking or testing. This could (should) probably be exposed as a settable parameter.

Anyway, each time it encodes a chunk of rows, it dispatches a progress event, and when it’s done, it dispatches a complete event. I also made a progress property that is just the current row divided by the total height. And of course a png property that lets you get at the byte array when it’s complete.

Originally, I tried extending the original PNGEncoder class and changing the parts I needed to. But everything in there is private, and I needed it to extend EventDispatcher to be able to dispatch events. So it’s a pure copy, paste, and change job.

////////////////////////////////////////////////////////////////////////////////
//
//  ADOBE SYSTEMS INCORPORATED
//  Copyright 2007 Adobe Systems Incorporated
//  All Rights Reserved.
//
//  NOTICE: Adobe permits you to use, modify, and distribute this file
//  in accordance with the terms of the license agreement accompanying it.
//
////////////////////////////////////////////////////////////////////////////////

package mx.graphics.codec
{

import flash.display.BitmapData;
import flash.display.Sprite;
import flash.events.Event;
import flash.events.EventDispatcher;
import flash.events.ProgressEvent;
import flash.utils.ByteArray;

import flashx.textLayout.formats.Float;

/**
 *  The PNGEncoder class converts raw bitmap images into encoded
 *  images using Portable Network Graphics (PNG) lossless compression.
 *
 *  <p>For the PNG specification, see http://www.w3.org/TR/PNG/</p>.
 *
 *  @langversion 3.0
 *  @playerversion Flash 9
 *  @playerversion AIR 1.1
 *  @productversion Flex 3
 */
public class PNGEncoderAsync extends EventDispatcher
{
//    include "../../core/Version.as";

	//--------------------------------------------------------------------------
	//
	//  Class constants
	//
	//--------------------------------------------------------------------------

    /**
     *  @private
	 *  The MIME type for a PNG image.
     */
    private static const CONTENT_TYPE:String = "image/png";

	//--------------------------------------------------------------------------
	//
	//  Constructor
	//
	//--------------------------------------------------------------------------

    /**
     *  Constructor.
     *
     *  @langversion 3.0
     *  @playerversion Flash 9
     *  @playerversion AIR 1.1
     *  @productversion Flex 3
     */
    public function PNGEncoderAsync()
    {
    	super();

		initializeCRCTable();
	}

	//--------------------------------------------------------------------------
	//
	//  Variables
	//
	//--------------------------------------------------------------------------

    /**
     *  @private
	 *  Used for computing the cyclic redundancy checksum
	 *  at the end of each chunk.
     */
    private var crcTable:Array;
	private var IDAT:ByteArray;
	private var sourceBitmapData:BitmapData;
	private var sourceByteArray:ByteArray;
	private var transparent:Boolean;
	private var width:int;
	private var height:int;
	private var y:int;
	private var _png:ByteArray;
	private var sprite:Sprite;

	//--------------------------------------------------------------------------
	//
	//  Properties
	//
	//--------------------------------------------------------------------------

	//----------------------------------
	//  contentType
	//----------------------------------

    /**
     *  The MIME type for the PNG encoded image.
     *  The value is <code>"image/png"</code>.
     *
     *  @langversion 3.0
     *  @playerversion Flash 9
     *  @playerversion AIR 1.1
     *  @productversion Flex 3
     */
    public function get contentType():String
    {
        return CONTENT_TYPE;
    }

	//--------------------------------------------------------------------------
	//
	//  Methods
	//
	//--------------------------------------------------------------------------

    /**
     *  Converts the pixels of a BitmapData object
	 *  to a PNG-encoded ByteArray object.
     *
     *  @param bitmapData The input BitmapData object.
     *
     *  @return Returns a ByteArray object containing PNG-encoded image data.
     *
     *  @langversion 3.0
     *  @playerversion Flash 9
     *  @playerversion AIR 1.1
     *  @productversion Flex 3
     */
    public function encode(bitmapData:BitmapData):void
    {
        return internalEncode(bitmapData, bitmapData.width, bitmapData.height,
							  bitmapData.transparent);
    }

    /**
     *  Converts a ByteArray object containing raw pixels
	 *  in 32-bit ARGB (Alpha, Red, Green, Blue) format
	 *  to a new PNG-encoded ByteArray object.
	 *  The original ByteArray is left unchanged.
     *
     *  @param byteArray The input ByteArray object containing raw pixels.
	 *  This ByteArray should contain
	 *  <code>4 * width * height</code> bytes.
	 *  Each pixel is represented by 4 bytes, in the order ARGB.
	 *  The first four bytes represent the top-left pixel of the image.
	 *  The next four bytes represent the pixel to its right, etc.
	 *  Each row follows the previous one without any padding.
     *
     *  @param width The width of the input image, in pixels.
     *
     *  @param height The height of the input image, in pixels.
     *
     *  @param transparent If <code>false</code>, alpha channel information
	 *  is ignored but you still must represent each pixel
     *  as four bytes in ARGB format.
     *
     *  @return Returns a ByteArray object containing PNG-encoded image data.
     *
     *  @langversion 3.0
     *  @playerversion Flash 9
     *  @playerversion AIR 1.1
     *  @productversion Flex 3
     */
    public function encodeByteArray(byteArray:ByteArray, width:int, height:int,
									transparent:Boolean = true):void
    {
        internalEncode(byteArray, width, height, transparent);
    }

    /**
	 *  @private
	 */
	private function initializeCRCTable():void
	{
        crcTable = [];

        for (var n:uint = 0; n < 256; n++)
        {
            var c:uint = n;
            for (var k:uint = 0; k < 8; k++)
            {
                if (c & 1)
                    c = uint(uint(0xedb88320) ^ uint(c >>> 1));
				else
                    c = uint(c >>> 1);
             }
            crcTable[n] = c;
        }
	}

    /**
	 *  @private
	 */
	private function internalEncode(source:Object, width:int, height:int,
									transparent:Boolean = true):void
    {
     	// The source is either a BitmapData or a ByteArray.
    	sourceBitmapData = source as BitmapData;
    	sourceByteArray = source as ByteArray;
		this.transparent = transparent;
		this.width = width;
		this.height = height;

    	if (sourceByteArray)
    		sourceByteArray.position = 0;

        // Create output byte array
        _png = new ByteArray();

        // Write PNG signature
		_png.writeUnsignedInt(0x89504E47);
		_png.writeUnsignedInt(0x0D0A1A0A);

        // Build IHDR chunk
        var IHDR:ByteArray = new ByteArray();
        IHDR.writeInt(width);
        IHDR.writeInt(height);
		IHDR.writeByte(8); // bit depth per channel
		IHDR.writeByte(6); // color type: RGBA
		IHDR.writeByte(0); // compression method
		IHDR.writeByte(0); // filter method
        IHDR.writeByte(0); // interlace method
        writeChunk(_png, 0x49484452, IHDR);

        // Build IDAT chunk
        IDAT = new ByteArray();
		y = 0;

		sprite = new Sprite();
		sprite.addEventListener(Event.ENTER_FRAME, onEnterFrame);
	}

	protected function onEnterFrame(event:Event):void
	{
		for(var i:int = 0; i < 20; i++)
		{
			writeRow();
			y++;
			if(y >= height)
			{
				sprite.removeEventListener(Event.ENTER_FRAME, onEnterFrame);
				completeWrite();
			}
		}
	}

	private function completeWrite():void
	{
        IDAT.compress();
        writeChunk(_png, 0x49444154, IDAT);

        // Build IEND chunk
        writeChunk(_png, 0x49454E44, null);

        // return PNG
		_png.position = 0;
		dispatchEvent(new Event(Event.COMPLETE));
    }

	private function writeRow():void
	{
		IDAT.writeByte(0); // no filter

		var x:int;
		var pixel:uint;

		if (!transparent)
		{
			for (x = 0; x < width; x++)
			{
				if (sourceBitmapData)
					pixel = sourceBitmapData.getPixel(x, y);
				else
					pixel = sourceByteArray.readUnsignedInt();

				IDAT.writeUnsignedInt(uint(((pixel & 0xFFFFFF) << 8) | 0xFF));
			}
		}
		else
		{
			for (x = 0; x < width; x++)
			{
				if (sourceBitmapData)
					pixel = sourceBitmapData.getPixel32(x, y);
				else
					pixel = sourceByteArray.readUnsignedInt();

				IDAT.writeUnsignedInt(uint(((pixel & 0xFFFFFF) << 8) |
					(pixel >>> 24)));
			}
		}
		dispatchEvent(new ProgressEvent(ProgressEvent.PROGRESS));
	}

    /**
	 *  @private
	 */
	private function writeChunk(png:ByteArray, type:uint, data:ByteArray):void
    {
        // Write length of data.
        var len:uint = 0;
        if (data)
            len = data.length;
		png.writeUnsignedInt(len);

		// Write chunk type.
		var typePos:uint = png.position;
		png.writeUnsignedInt(type);

		// Write data.
		if (data)
			png.writeBytes(data);

        // Write CRC of chunk type and data.
		var crcPos:uint = png.position;
		png.position = typePos;
        var crc:uint = 0xFFFFFFFF;
        for (var i:uint = typePos; i < crcPos; i++)
        {
            crc = uint(crcTable[(crc ^ png.readUnsignedByte()) & uint(0xFF)] ^
					   uint(crc >>> 8));
        }
        crc = uint(crc ^ uint(0xFFFFFFFF));
		png.position = crcPos;
		png.writeUnsignedInt(crc);
    }

	public function get png():ByteArray
	{
		return _png;
	}

	public function get progress():Number
	{
		return y / height;
	}
}

}

I’m not putting this out here as any kind of proof of my brilliance, as it’s not very pretty code at all. I did the bare minimum refactoring to get the thing to work in my project. But it does work, and works damn well. Well enough to call it a done for the project I need it for. I mentioned it on twitter and found out that as opposed to the last time I checked, a few other people have created similar classes. A few I am aware of now:

http://blog.inspirit.ru/?p=378

http://pastebin.sekati.com/?id=Pngencoderhack@5d892-84c96899-t

And apparently the hype framework has one built in too, that you might be able to steal.

But, there can never be enough free code out there, right? If this helps anyone, or they can use it as a launching point to create something better, great. If not, well, I wasted a few minutes posting this, so be it. :)

17 responses so far. Comments will be closed after post is one year old.

  • Grand stuff. My hack was a last-minute kind of thing, so.. Odd that I didn’t think of just creating an internal Sprite for the enterframe event like you have. I suppose mentally I’ve just divorced things that aren’t on the display list from the events associated with it.

  • adamapasz says:

    I recently tried this with JPEGEncoder. Once caveat is that if you’re uploading the file, you’ll have to deal with Adobe’s new “user interaction” requirements for file uploads. http://www.adobe.com/devnet/flashplayer/articles/fplayer10_security_changes_02.html#head3

  • Nice work. Sounds and looks similar to the Async jpeg encoder I wrote last year.

    http://dgrigg.com/post.cfm/03/05/2009/AS3-JPEGEncoder-and-big-images

  • joshua says:

    @ line 175 shouldn’t it say “return internalEncode…”?

    i am going to try this out, thanks, keith, for poasting, so i want to make sure that line is right.

  • BoyWonder says:

    I got a lot of weird problems until i included a break in the enterframe loop. You do not want the loop to continue after the end is reached. Might be worth an update of the originall code?

    if(y>= height)
    {
    sprite.removeEventListener(Event.ENTER_FRAME, onEnterFrame);
    completeWrite();
    break;
    }

  • Joony says:

    I’m not a huge fan of chunking data this way, there seems to be too huge an overhead per frame for my liking. If it takes 30 seconds to encode a .png straight, how long does it take to do it in chunks?

    There is also a knack in getting the right amount of data processed per frame. You say you get pretty good results at 20 rows, but that may not be the same for everyone. It will differ from machine to machine.

    Another method I experimented with was LocalConnection and connecting to a second swf which has the sole purpose of processing your data and returning it to the main swf. This would create a real thread for the processing. It has a couple of caveats though, there is the 40k limit on LocalConnection parameters (but you can always packet the data), and a second swf may not be available to you.

  • kp says:

    I agree it’s not the ideal solution, but it’s simple and works, makes the ui responsive and gives you feedback. If it takes a few seconds longer to encode, I think that’s a good tradeoff. You could easily extend this to make the number of rows per frame settable, or even do it intelligently with a timer so it would work the same in all machines.

    I don’t see the local connection concept as any better a solution, for the reasons you mentioned and then some.

    This would make a perfect use case if Adobe is looking at any kind of concurrency API.

  • Joony says:

    No, I didn’t find the LocalConnection all that good, for one-off tasks at least. If you can get over the setup and 40k limit it did perform well at running constant background tasks as it runs completely independently. I suppose it’s a bit like message passing concurrency in languages like Erlang.

    I messed around with dynamic chunk sizes to keep the frame rate stable across machines, but the overhead became too much. The time increased ten-fold. Basic chunks like you’ve implemented work the best.

    I spoke to a few of the guys from Adobe at FotB last year and their response was that they’re reluctant to introduce any sort of concurrency API for a couple of reasons, the complexity it would add to the language, and that if they did something they would have to get it right first time as there would be no going back. The only good news is that it’s still a topic amongst the Adobe team.

    For reference, Alex Harui from Adobe started a discussion about this a couple of years ago: http://blogs.adobe.com/aharui/2008/01/threads_in_actionscript_3.html

  • Keith Peters says:

    I wouldn’t be at all surprised if we saw the beginnings of something like this in the coming year.

  • Ivan says:

    Great class. I used it in my project.
    But for some reason it launches COMPLETE event 18 times. So I had to run removeListener right from COMPLETE handler.

  • JD says:

    Whats with this line popping up in the source code ?

    src=”http://www.bit-101.com/blog/wp-includes/images/smilies/icon_cool.gif” alt=”8)” class=”wp-smiley”>

  • Andrew says:

    I was very excited to find this but I simply can’t get it to work. It seems to encode the bitmapdata, launches the complete event (18 times like the above comment) but then the returned bytearray is not as expected. But it does return “something”…

    Does anyone have any usage samples?

  • Andrew says:

    I solved my issue. Originally, I had modified the lines that had the code “p-includes/images/smilies/icon_cool.gif” alt=”8)”… because this seemed like a copy/paste error. But I didn’t modify the lines correctly. So I changed the two lines to:

    1 Old:
    IDAT.writeUnsignedInt(uint(((pixel & 0xFFFFFF) << | 0xFF));

    Change to:
    IDAT.writeUnsignedInt(uint(((pixel & 0xFFFFFF) << 8) | 0xFF));

    2 Old:
    IDAT.writeUnsignedInt(uint(((pixel & 0xFFFFFF) << |
    (pixel >>> 24)));

    Change to:
    IDAT.writeUnsignedInt(uint(((pixel & 0xFFFFFF) <>> 24)));

    This solved my problem and the now the ENcoder works great. Big thanks to the author.

  • snarkbot says:

    For anyone else keeping score, that smiley-face above should be replaced with the numeral 8 followed by a close-paren. But oh well.

  • snarkbot says:

    Also, please add the statement “break’;” or “return;’” after “completeWrite()”.

    Otherwise, if you have a height that is not a multiple of 20 (due to the hard-coded “20″ in your “for” loop), the loop will keep iterating, resulting in an end of file error.

    Thx.

    “8)”

  • snarkbot says:

    One last comment on this generally incomplete inquiry:

    On a large PNG (say, 5MB), these two synchronous operations both take a long time to complete:

    IDAT.compress();
    and
    writeChunk(_png, 0×49444154, IDAT);

    Each of these statements can take over a second, locking up FlashPlayer, and partially negating the benefits of the extra effort involved in setting up the asynchronous encoding performed before that point.

  • Mike says:

    Does anyone has a sample code for using the new class ?
    I use: import mx.graphics.codec.PNGEncoderAsync; var abc:PNGEncoderAsync = new PNGEncoderAsync();

    Flash throws error 1046 and 1180.