Page 1 of 1

php5 image factory class

Posted: Mon Sep 11, 2006 9:51 pm
by neophyte
I've been working on a image factory. Its got four pieces: 1. Image manager 2. image abstract class, 3. image type class and 4. A custom exception class.

As I've had a problem building God classes in the past, I've been wondering if these classes are too tightly coupled. So does it smell?

The code is not polished, but I'm interested in any feedback you can give me. I'm especially interested in design and object relations.

Should gg_image_manager::manage_image() return a gg_image object?

Code: Select all

class gg_image_manager{
	private $img;
	private $img_dest;
	private $img_info;
	private $img_maxd;
	private $img_width;
	private $img_height;
	public function __construct($image, $max_demension, $dest = ''){
		if( $this->gd_loaded() === true ){
			$this->img = $image;
			try{
				$this->set_image_info();
			} catch ( image_exception $e){
				$e->__toString('Image file ('.$this->img.') does not exist! ');
			}
			$this->img_maxd = $max_demension;
			$this->img_dest = $dest;
			$this->calc_image_size($this->img_info[0], $this->img_info[1]);
		} else {
			die('GD extension not loaded. Contact your system administrator.');
		}
	}
	protected function set_image_info(){
		if(file_exists($this->img)){
			$this->img_info = getimagesize($this->img);
		} else {
			throw new image_exception("This image file does not exist.");
		}
	}
	protected function gd_loaded(){
		if (extension_loaded('gd')) {
			return true;
		} else {
			return false;
		}
	}
	public function manage_image(){
		//key values below match key value pairs for array
		//returned by getimagesize. Add a new supported file
		//type by adding a new offspring image class with name
		//gg_{file_ext_type_below}_image
		$image_types = array( 1 => 'gif',
		 	2 => 'jpeg', 	3 => 'png', 	4 => 'swf',
		 	5 => 'psd', 	6 => 'bmp', 	7 => 'tiff',
		 	8 => 'tiff2',	9 => 'jpc', 	10 => 'jp2',
		 	11 => 'jpx', 	12 => 'jb2',	13 => 'swc',
		 	14 => 'iff', 	15 => 'wbmp',	16 => 'xbm');
		$offspring = "gg_".$image_types[$this->img_info[2]]."_image";
		return new $offspring ( $this );
	}
	private function calc_image_size($img_width, $img_height){
		 $aspect_ratio = $img_width / $img_height;
		//Do we need to resize the image?
   		if ( ($img_width > $this->img_maxd) || ($img_height > $this->img_maxd) ){
			//Yes, Width is greater than height.
			if ( $img_width > $img_height ) {
				$this->img_width = $this->img_maxd;
				$this->img_height = $this->img_width / $aspect_ratio;
			//Width is less than height
			} elseif ( $img_width < $img_height ) {
				$this->img_height = $this->img_maxd;
				$this->img_width = $this->img_height * $aspect_ratio;
			//Dimensions equal each other.
			} elseif ( $img_width == $img_height ){
				$this->img_width = $this->img_maxd;
				$this->img_height = $this->img_maxd;
			} else { 
				return FALSE; 
			}
		} else { 
			$this->img_width = $img_width; 
			$this->img_height = $img_height; 
		}
	}
	public function get_image_info(){
		return $this->img_info;
	}
	public function get_image(){
		return $this->img;
	}
	public function get_image_dest(){
		return $this->img_dest;
	}
	public function get_image_maxd(){
		return $this->img_maxd;
	}
	public function get_image_height(){
		return $this->img_height;
	}
	public function get_image_width(){
		return $this->img_width;
	}
}

Code: Select all

abstract class gg_image extends gg_image_manager{
	protected $img_resource;
	protected $img_new;
	protected $img_manager;
	abstract function create_image();
	abstract function image_header();
	abstract function is_gd_capable();
	abstract function create_image_from();
	abstract function generate_image();
	 function __construct( gg_image_manager $img_manager ){
		//make the img_manager object available to the class.	
		$this->img_manager = $img_manager;
	}
	protected function image_resize(){
		$img_info = $this->img_manager->get_image_info();
		$new_width = $this->img_manager->get_image_width();
		$new_height = $this->img_manager->get_image_height();
		$this->img_new = @imagecreatetruecolor($new_width, $new_height );
		if(!empty($this->img_resource)){
			if (!imagecopyresampled ( $this->img_new, $this->img_resource, 0, 0, 0, 0, $new_width, $new_height, $img_info[0], $img_info[1] )){
				throw new image_exception("Image not resampled");
			}
		} else {
			throw new image_exception("Height and or width is missing");
		}
	}
	
	protected function __destruct(){
		if($this->img_resource){
			imagedestroy($this->img_resource);
		}
	}
}

Code: Select all

class gg_jpeg_image extends gg_image {
	protected $img_manager;
	protected $img_res = 72;
	public function generate_image(){
	//make sure gd is capable of handling this file type.
		if($this->is_gd_capable()){
			$this->image_header($this->img_manager->get_image());
			
			try{
			//First try to create a resource from existing image.
				$this->create_image_from();
			
			//Second try to resize the old image.
				$this->image_resize();
		
			//Finally try to output or save image.
				$this->create_image();
			} catch ( image_exception $e){
			//If something goes wrong output error image;
				$e->__toString();
			}
		} else {
			die('Sorry your version of GD does not support jpg file types');
		}
	}
	public function image_header(){
		header('Content-type: image/jpeg');
	}
	public function is_gd_capable(){
		if(imagetypes() & IMG_JPG){
			return true;
		} else {
			return false;
		}
	}
	public function create_image_from(){
		$this->img_resource = @imagecreatefromjpeg ($this->img_manager->get_image());
		if(empty($this->img_resource)){
			throw new image_exception('No image resource/id created');
		}
	}
	public function create_image(){
		$img_dest = $this->img_manager->get_image_dest();
		if( !imagejpeg( $this->img_new, $this->img_dest, $this->img_res ) ){
			throw new image_exception('Image not saved or output.');
		}
	}
	public function set_image_res( int $res){
			$this->img_res = $res;	
	}
	public function get_image_res(){
		return $this->img_res;
	}

}
This is the first time I've used a built in Exception class let alone extend it. Hows this:

Code: Select all

class image_exception extends Exception{
	protected $img_manager;
	function __construct(gg_image_manager $im ){
		$this->img_manager = $im;
	}
	function __toString($message=""){
		//die( $this->img_manager->get_image()." ".$message );
		$image_width = strlen($message)*12;
		$im  = imagecreatetruecolor($image_width, 30); 
		$bgc = imagecolorallocate($im, 255, 255, 255);
		$tc  = imagecolorallocate($im, 0, 0, 0);
		imagefilledrectangle($im, 0, 0, $image_width, 30, $bgc);
		header('Content-type: image/jpeg');
		imagestring($im, 1, 5, 5, $message, $tc);
		imagejpeg($im);	
	}
}
Here's an example of how you might use the classes.

Code: Select all

$imgm = new gg_image_manager('old_house1.jpg', 200);
$img  = $imgm->manage_image();
$img->generate_image();

Posted: Tue Sep 12, 2006 12:58 am
by Christopher
I think you need for focus the classes down on what each one is responsible for. Right now, the gg_image_manager class looks more like and image class than a image factory. I would move everything but the constructor and gd_loaded() into the image class. Then looking at the gg_image class, it looks like it should be an image filter named gg_image_resizer. I think the fact that each supported image type extends the base gg_image makes sense, but then use that polymorphism by passing image object to the rest of the class library.

Posted: Tue Sep 12, 2006 6:38 am
by neophyte
Thanks for the critique Aborint. Will refactor and post later.

Posted: Fri Sep 15, 2006 6:28 pm
by neophyte
Okay so I've refactored my originally posted classes. Hopefully this will be less "smelly." Please evaluate this code for object relations and overall OO design. This isn't polished yet but it is in "working" condition.

Okay first the image factory. Finds the type of file and then returns an image object.

Code: Select all

interface ifactory{
	function create_instance();	
 }
 class gg_image_factory implements ifactory{
 	private $img_info; 
 	function __construct($file){
 		$this->img_info = getimagesize($file);
 		$this->img_info[]=$file;
 	}
 	public function create_instance(){
		//key values below match key value pairs for array
		//returned by getimagesize. Add a new supported file
		//type by adding a new offspring image class with name
		//gg_{file_ext_type_below}_image
		$image_types = array( 1 => 'gif',
		 	2 => 'jpeg', 	3 => 'png', 	4 => 'swf',
		 	5 => 'psd', 	6 => 'bmp', 	7 => 'tiff',
		 	8 => 'tiff2',	9 => 'jpc', 	10 => 'jp2',
		 	11 => 'jpx', 	12 => 'jb2',	13 => 'swc',
		 	14 => 'iff', 	15 => 'wbmp',	16 => 'xbm');
		$offspring = "gg_".$image_types[$this->img_info[2]]."_image";
		return new $offspring ( $this->img_info );
	} 	
 }
Now for the base image abstract class.

Code: Select all

abstract class gg_image {
 	protected $file; 
	public $img_resource;
	public $img_new;
	protected $img_info;
	protected $img_new_width;
	protected $img_new_height;
	protected $img_dest;
	abstract function create_image();
	abstract function image_header();
	abstract function is_gd_capable();
	abstract function create_image_from();
	function __construct( $img_info ){
		$this->img_info = $img_info;
		$this->file = $img_info[4];
	}
	function get_image_width(){
		return $this->img_info[0];
	}
	function get_image_height(){
		return $this->img_info[1];
	}
	function set_new_image_width($width){
		$this->img_new_width = $width;	
	}	
	function set_new_image_height($height){
		$this->img_new_height = $height;
	}
	function get_new_image_width(){
		return $this->img_new_width;	
	}	
	function get_new_image_height(){
		return $this->img_new_height;
	}
	function get_img_new(){
		return $this->img_new;
	}
	function __destruct(){
		if($this->img_resource){
			imagedestroy($this->img_resource);
		}
		if($this->img_new){
			imagedestroy($this->image_new);
		}
	}
}
Each image type (jpeg, gif, png) has their own class.

Code: Select all

class gg_jpeg_image extends gg_image {
	protected $img_res = 72;
	public function image_header(){
		header('Content-type: image/jpeg');
	}
	public function is_gd_capable(){
		if(imagetypes() & IMG_JPG){
			return true;
		} else {
			return false;
		}
	}
	public function create_image_from(){
		$this->img_resource = @imagecreatefromjpeg ($this->file);
		if(empty($this->img_resource)){
			throw new image_exception('No image resource/id created');
		}
	}
	public function create_image(){
		$img_dest = $this->img_dest;
		if( !imagejpeg( $this->img_new, $this->img_dest, $this->img_res ) ){
			throw new image_exception('Image not saved or output.');
		}
	}
	public function set_image_res( int $res){
			$this->img_res = $res;	
	}
	public function get_image_res(){
		return $this->img_res;
	}
	
}
I split out the calculation stuff into its own class. It recieves an image object and allows for the implementation of more than one way to calculate new dimensions.

Code: Select all

interface gg_image_calc{
 	function calc_image_size();
 	function get_image_height();
 	function get_image_width();
 }
 class gg_image_maxd_calc implements gg_image_calc{
 	private  $img_width;
 	private  $img_height;
 	private $img_maxd;
 	private $img;
 	function __construct( gg_image $img ){
 		$this->img = $img;
 	}
 	public function calc_image_size(){
 		 $img_width = $this->img->get_image_width();
		 $img_height = $this->img->get_image_height();
		 $aspect_ratio = $img_width / $img_height;
		//Do we need to resize the image?
   		if ( ($img_width > $this->img_maxd) || ($img_height > $this->img_maxd) ){
			//Yes, Width is greater than height.
			if ( $img_width > $img_height ) {
				$this->img_width = $this->img_maxd;
				$this->img_height = $this->img_width / $aspect_ratio;
			//Width is less than height
			} elseif ( $img_width < $img_height ) {
				$this->img_height = $this->img_maxd;
				$this->img_width = $this->img_height * $aspect_ratio;
			//Dimensions equal each other.
			} elseif ( $img_width == $img_height ){
				$this->img_width = $this->img_maxd;
				$this->img_height = $this->img_maxd;
			} else { 
				throw new image_exception('Image size cannot be calculated.');
			}
		} else { 
			$this->img_width = $img_width; 
			$this->img_height = $img_height; 
		}
		$this->img->set_new_image_width($this->img_width);
		$this->img->set_new_image_height($this->img_height);
	}	
 
 	public function set_image_maxd($dim){
 		$this->img_maxd = $dim;
 	}
 	public function get_image_maxd(){
 		return $this->img_maxd;	
 	}
 	public function get_image_width(){
 		return $this->image_width;
 	}
 	public function get_image_height(){
 			return $this->image_height;
 	}
 }

I split off the resizing function as suggested by aborint. And added a transform interface. This class receives and image object.

Code: Select all

interface gg_image_transform{
 	function gg_image_transform();
 }
 class gg_image_resize implements gg_image_transform{ 
 	protected $img;
 function __construct( gg_image $img){
 	 $this->img = $img;
 }
 public function gg_image_transform(){
		$new_width = $this->img->get_new_image_width();
		$new_height = $this->img->get_new_image_height();
		$this->img->img_new = @imagecreatetruecolor($new_width, $new_height );
		if(!empty($this->img->img_resource)){
			if (!imagecopyresampled ( $this->img->get_img_new(), $this->img->img_resource, 0, 0, 0, 0, $new_width, $new_height, $this->img->get_image_width(), $this->img->get_image_height() ) ){
				throw new image_exception("Image not resampled");
			}
		} else {
			throw new image_exception("Height and or width is missing");
		}
	}
 }
A custom exception class for outputting error messages as images.

Code: Select all

class image_exception extends Exception{
	protected $img;
	function __construct(  ){
		
	}
	function __toString($message=""){
		//die( $this->img_manager->get_image()." ".$message );
		$image_width = strlen($message)*12;
		$im  = imagecreatetruecolor($image_width, 30); 
		$bgc = imagecolorallocate($im, 255, 255, 255);
		$tc  = imagecolorallocate($im, 0, 0, 0);
		imagefilledrectangle($im, 0, 0, $image_width, 30, $bgc);
		header('Content-type: image/jpeg');
		imagestring($im, 1, 5, 5, $message, $tc);
		imagejpeg($im);	
	}
}
Well, that's it. Now for an implementation example.

Code: Select all

$imgf = new gg_image_factory('old_house2.jpg');
	$img = $imgf->create_instance();	
	//make sure gd is capable of handling this file type.
		if($img->is_gd_capable()){
		
			try{
			//First try to create a resource from existing image.
				$img->create_image_from();
			
			//Second try to calculate the new dimensions
				$img_calc = new gg_image_maxd_calc($img);
				$img_calc->set_image_maxd(200);
				$img_calc->calc_image_size();
				
			
			//third try to resize the old image.
				$resizer = new gg_image_resize($img);
				$resizer->gg_image_transform();
				
			//Finally try to output or save image.
				$img->image_header();
			
				$img->create_image();
				
			} catch ( image_exception $e){
			//If something goes wrong output error image;
				$e->__toString();
			}
		} else {
			die('Sorry your version of GD does not support jpg file types');
		}
How's the the oo design?

Posted: Sat Sep 16, 2006 1:01 am
by Christopher
Looking better. Here are some random comments:

- Camel case is the norm these days. You obviously don't like it but if you want others to be interested in your class then use camel case.

- Likewise I would use PEAR naming, so the class "gg_jpeg_image" becomes "Gg_Image_Jpeg" in the file "Gg/Image/Jpeg.php"

- $offspring : should really be $className because that's what it is.

- I am trying to figure out why class gg_image_maxd_calc exists. It seems like Crop or something like that. I think you need to give it a recognizable name like Resize has. The filters need to make sense.

- I am wondering whether you should have the image_header() method or just have getMimeType() and let another class be responsible for header/downoad functionality.

Posted: Sat Sep 16, 2006 11:21 am
by neophyte
arborint wrote: - Camel case is the norm these days. You obviously don't like it but if you want others to be interested in your class then use camel case.
- Likewise I would use PEAR naming, so the class "gg_jpeg_image" becomes "Gg_Image_Jpeg" in the file "Gg/Image/Jpeg.php"
Thanks for clueing me in. I'll do some reading on PEAR naming conventions.
aborint wrote: - $offspring : should really be $className because that's what it is.
Agreed.
- I am trying to figure out why class gg_image_maxd_calc exists. It seems like Crop or something like that. I think you need to give it a recognizable name like Resize has. The filters need to make sense.
maxd_calc is a class for calculating new sizes. It's split off incase there arises a need for other ways to calculate new dimensions besides by max dimension. Crop might not be a bad name for it. Crop implies taking a segment of the picture and chopping the rest off. I'll do some more thinking about what this class might actually be.
I am wondering whether you should have the image_header() method or just have getMimeType() and let another class be responsible for header/downoad functionality.
This is a very good idea that I hadn't considered -- but I think it'll be very useful.

Thanks for the tips aborint. Will refactor and repost.

Posted: Tue Sep 26, 2006 7:57 pm
by neophyte
Okay I've implemented PEAR naming, coding and directory conventions. I've changed almost everything. Here's the resize/calc class. It is the part I'm most interested in getting feedback on.

Resize recieves an image object. And creates a calc object based on the type of calculation requested by the client code.

Code: Select all

class GG_Image_Resize implements GG_Interface_Transform, GG_Interface_InstanceFactory
 { 
 	protected $img;
 	protected $sizeCalculator = "Maxd";
 	private $calc;
	function __construct( GG_Image_AbstractImage $img)
	{
	 	 $this->img = $img;
	}
	public function imageTransform()
	{	
			try{
				$this->calc->processCalcArgs();
			}catch( GG_Image_Exception $e ){
				$e->__toString();	
			}
			try{
				$this->calc->calcImageSize();
			}catch( GG_Image_Exception $e ){
				$e->__toString();	
			}
			$newWidth = $this->calc->getNewImageWidth();
			$newHeight = $this->calc->getNewImageHeight();
			$bf = new GG_Image_Factory(array($newWidth, $newHeight, $this->img->getImageType()));
			$blank = $bf->createInstance();
			$blank->setImageWidth($newWidth);
			$blank->setImageHeight($newHeight);
			$blank->imgResource = @imagecreatetruecolor($newWidth, $newHeight );
			if($this->img->imgResource){
					//die(var_dump($this->img->imgTempRes,$this->img->imgResource, $newWidth, $newHeight, $this->img->getImageWidth(), $this->img->getImageHeight() ));
				if (@imagecopyresampled ( $blank->imgResource, $this->img->imgResource, 0, 0, 0, 0,  $newWidth, $newHeight, $this->img->getImageWidth(), $this->img->getImageHeight() )){
					 $this->img->cleanUp($blank);
				} else {
					throw new GG_Image_Exception("Image not resampled");
				}
			} else {
					throw new GG_Image_Exception("Improper or non existing imgResource.");
			}
	}
	public function setSizeCalculator($calc)
	{
		$this->sizeCalculator = $calc;
	}
	public function getSizeCalculator()
	{
		return $this->sizeCalculator;	
	}
	function createInstance()
 	{
 		$this->className = 'GG_Image_'.$this->sizeCalculator.'Calc';
 		$calcObj =  new $this->className;
		if($calcObj){
			return $calcObj;	
		} else {
			throw new GG_Image_Exception('Calculation object does not exist.');	
		}
 	}	
	/**
	 * $args is an array that contains arguments
	 * specific to the calculation class being used.
	 * To find out more read the comments on/in {CalcClass}Calc::processCalcArgs
	 */
	public function setCalcArgs( array $args)
	{
		try{
			$this->calc = $this->createInstance();
		} catch( GG_Image_Exception $e ){
			$e->__toString('Resize calculator not instantiated');	
		}
		//Now set the sizes
		$this->calc->setImageWidthAndHeight($this->img->getImageWidth(), $this->img->getImageHeight());
		$this->calc->setCalcArgs($args);
	}			 
 }
Here's a sample calculation class. This one calculates by maximum dimension. Other possible calc classes might include: one dimension ( width or height) and exact size.

Code: Select all

class GG_Image_MaxdCalc implements GG_Interface_ResizeCalc{
 	private $imgWidth;
 	private $imgHeight;
 	private $imgNewWidth;
 	private $imgNewHeight;
 	private $imgMaxd;
 	private $img;
 	private $args;
 	public function calcImageSize()
 	{
 		 if(!empty($this->imgMaxd)){		 	
			 $aspectRatio = $this->imgWidth / $this->imgHeight;
			//Do we need to resize the image?
	   		if ( ($this->imgWidth > $this->imgMaxd) || ($this->imgHeight > $this->imgMaxd) ){
				//Yes, Width is greater than height.
				if ( $this->imgWidth > $this->imgHeight ) {
					$this->imgNewWidth = $this->imgMaxd;
					$this->imgNewHeight = $this->imgNewWidth/$aspectRatio;
				} elseif ( $this->imgWidth < $this->imgHeight ) {
					$this->imgNewHeight = $this->imgMaxd;
					$this->imgNewWidth = $this->imgNewHeight * $aspectRatio;
				//Dimensions equal each other.
				} elseif ( $this->imgWidth == $this->imgHeight ){
					$this->imgNewWidth = $this->imgMaxd;
					$this->imgNewHeight = $this->imgMaxd;
				} else { 
					throw new GG_Image_Exception('Image size cannot be calculated.');
				}
			} else { 
				$this->imgNewWidth = $this->imgWidth; 
				$this->imgNewHeight = $this->imgHeight; 
			}
 		} else {
 		 	throw new GG_Image_Exception('imgMaxd is empty');	
 		}
	}	
	public function setCalcArgs( array $args )
	{ 
 		$this->args = $args;	
	}
	function processCalcArgs()
	{
		if(isset($this->args[0])){
			$this->imgMaxd = $this->args[0];	
		} else {
			throw new GG_Image_Exception('No max dimension set');	
		}
	}
 	public function getNewImageWidth()
 	{
 		return $this->imgNewWidth;
 	}
 	public function getNewImageHeight()
 	{
 			return $this->imgNewHeight;
 	}
 	public function setImageWidthAndHeight($imgWidth, $imgHeight)
 	{
 		$this->imgWidth = $imgWidth;
 		$this->imgHeight = $imgHeight;	
 	}
 }
Here are the implemented interfaces:

Code: Select all

interface GG_Interface_ResizeCalc{
 	function calcImageSize();
 	function getNewImageHeight();
 	function getNewImageWidth();
 	function setCalcArgs(array $args);
 	function processCalcArgs();
 	function setImageWidthAndHeight($imgWidth, $imgHeight);
 }
 interface GG_Interface_Transform{
 	function imageTransform();
 }
  interface GG_Interface_InstanceFactory{
	function createInstance();	
 }
Last but not least, here's a possible implementation:

Code: Select all

$resizer = new GG_Image_Resize($img);
				$resizer->setSizeCalculator('Maxd');
				$resizer->setCalcArgs(array(300));
				$resizer->imageTransform();
Again, how is the design?

Thanks for the critique....

(I didn't post the whole project again because it has probably doubled in size since the last post. But if anyone is interested I'd be happy to post the whole thing. )

Posted: Tue Sep 26, 2006 8:12 pm
by Luke
how come you do $e->toString() but you don't print it out or do anything else with it?

Posted: Tue Sep 26, 2006 8:49 pm
by neophyte
Good question. I'm using a custom exception class that prints out an image for the error. Sometimes I put a message in e->__toString() and sometimes I don't. Just depends on whether I think a message will be useful for debuging.

Code: Select all

class GG_Image_Exception extends Exception
 {
	function __toString($message ="")
	{
			$imgf = new GG_Image_Factory(array(10,10,'Jpeg'));
			$eImg = $imgf->createInstance();
		    $eString = parent::__toString();
			$brokenString = wordwrap($eString." ".$message, 24, '\n', true);
			$brokenString = explode( '\n', $brokenString);
			$imageWidth =   24*8;
			$imageHeight = 20*(count($brokenString)+1);
			$eImg->setImageWidth($imageWidth);
			$eImg->setImageHeight($imageHeight);
			$eImg->imgResource = imagecreatetruecolor($eImg->getImageWidth(), $eImg->getImageHeight());
			$bgc = imagecolorallocate($eImg->imgResource, 230, 230, 230);
			$tc  = imagecolorallocate($eImg->imgResource, 0, 0, 0);
			imagefilledrectangle($eImg->imgResource, 0, 0, $eImg->getImageWidth(), $eImg->getImageHeight(), $bgc);
			
			$headers = new GG_Image_MimeTypes;
			$headers->setContentType( 'image/'.$eImg->getImageType());
			$headers->outputHeaders();
			$x=10;
			foreach($brokenString as $val){
				imagestring($eImg->imgResource, 3, 5, $x, $val, $tc);
				$x = $x+20;
			}
			$eImg->createImage();
	}
}

Posted: Tue Sep 26, 2006 8:52 pm
by Luke
Ah... nice!