Generate thumbnails based on buckets
authorGilles Dubuc <gdubuc@wikimedia.org>
Tue, 3 Jun 2014 16:09:30 +0000 (18:09 +0200)
committerGergő Tisza <tgr.huwiki@gmail.com>
Tue, 8 Jul 2014 20:03:38 +0000 (20:03 +0000)
Instead of always generating thumbnails based on the original,
this adds the ability to generate thumbnails based on
references buckets. The buckets themselves have their
generation chained following the same process (smaller bucket
generated based on bigger bucket). In situations where no
suitable bucket is found, the original is used, like it used to.

This is entirely optional, as most non-WMF wikis would probably prefer
to keep generating all thumbnails based on originals.

The quality implications have been verified through a survey
aimed at Commons users and people actually preferred the chained version.
Presumably due to the multiple passes of sharpening which maintained
visual details better for small thumbnail sizes.

Change-Id: I285d56b2024c81365247338f85c1e0aa708cb21e
Mingle: https://wikimedia.mingle.thoughtworks.com/projects/multimedia/cards/600
Bug: 67525

includes/DefaultSettings.php
includes/filerepo/file/File.php
includes/media/Bitmap.php
includes/media/ImageHandler.php
includes/media/Jpeg.php
includes/media/MediaHandler.php
includes/media/PNG.php
tests/phpunit/includes/filerepo/file/FileTest.php [new file with mode: 0644]

index ab0ffeb..fceefba 100644 (file)
@@ -1217,6 +1217,33 @@ $wgThumbLimits = array(
        300
 );
 
+/**
+ * When defined, is an array of image widths used as buckets for thumbnail generation.
+ * The goal is to save resources by generating thumbnails based on reference buckets instead of
+ * always using the original. This will incur a speed gain but cause a quality loss.
+ *
+ * The buckets generation is chained, with each bucket generated based on the above bucket
+ * when possible. File handlers have to opt into using that feature. For now only BitmapHandler
+ * supports it.
+ */
+$wgThumbnailBuckets = null;
+
+/**
+ * When using thumbnail buckets as defined above, this sets the minimum distance with the bucket
+ * above the requested size. The distance represents how pany extra pixels of width the bucket needs
+ * in order to be used as the reference for a given thumbnail. For example, with the following buckets:
+ *
+ * $wgThumbnailBuckets = array ( 128, 256, 512 );
+ *
+ * and a distance of 50:
+ *
+ * $wgThumbnailMinimumBucketDistance = 50;
+ *
+ * If we want to render a thumbnail of width 220px, the 512px bucket will be used,
+ * because 220 + 50 = 270 and the closest bucket bigger than 270px is 512.
+ */
+$wgThumbnailMinimumBucketDistance = 0;
+
 /**
  * Default parameters for the "<gallery>" tag
  */
index 1103e38..e970e38 100644 (file)
@@ -149,6 +149,9 @@ abstract class File {
        /** @var string Required Repository class type */
        protected $repoClass = 'FileRepo';
 
+       /** @var array Cache of tmp filepaths pointing to generated bucket thumbnails, keyed by width */
+       protected $tmpBucketedThumbCache = array();
+
        /**
         * Call this constructor from child classes.
         *
@@ -456,6 +459,50 @@ abstract class File {
                return false;
        }
 
+       /**
+        * Return the smallest bucket from $wgThumbnailBuckets which is at least
+        * $wgThumbnailMinimumBucketDistance larger than $desiredWidth. The returned bucket, if any,
+        * will always be bigger than $desiredWidth.
+        *
+        * @param int $desiredWidth
+        * @param int $page
+        * @return bool|int
+        */
+       public function getThumbnailBucket( $desiredWidth, $page = 1 ) {
+               global $wgThumbnailBuckets, $wgThumbnailMinimumBucketDistance;
+
+               $imageWidth = $this->getWidth( $page );
+
+               if ( $imageWidth === false ) {
+                       return false;
+               }
+
+               if ( $desiredWidth > $imageWidth ) {
+                       return false;
+               }
+
+               if ( !$wgThumbnailBuckets ) {
+                       return false;
+               }
+
+               $sortedBuckets = $wgThumbnailBuckets;
+
+               sort( $sortedBuckets );
+
+               foreach ( $sortedBuckets as $bucket ) {
+                       if ( $bucket > $imageWidth ) {
+                               return false;
+                       }
+
+                       if ( $bucket - $wgThumbnailMinimumBucketDistance > $desiredWidth ) {
+                               return $bucket;
+                       }
+               }
+
+               // Image is bigger than any available bucket
+               return false;
+       }
+
        /**
         * Returns ID or name of user who uploaded the file
         * STUB
@@ -877,9 +924,9 @@ abstract class File {
                        return null;
                }
                $extension = $this->getExtension();
-               list( $thumbExt, ) = $this->handler->getThumbType(
+               list( $thumbExt, ) = $this->getHandler()->getThumbType(
                        $extension, $this->getMimeType(), $params );
-               $thumbName = $this->handler->makeParamString( $params ) . '-' . $name;
+               $thumbName = $this->getHandler()->makeParamString( $params ) . '-' . $name;
                if ( $thumbExt != $extension ) {
                        $thumbName .= ".$thumbExt";
                }
@@ -947,7 +994,7 @@ abstract class File {
         * @return MediaTransformOutput|bool False on failure
         */
        function transform( $params, $flags = 0 ) {
-               global $wgUseSquid, $wgIgnoreImageErrors, $wgThumbnailEpoch;
+               global $wgThumbnailEpoch;
 
                wfProfileIn( __METHOD__ );
                do {
@@ -1004,64 +1051,221 @@ abstract class File {
                                } elseif ( $flags & self::RENDER_FORCE ) {
                                        wfDebug( __METHOD__ . " forcing rendering per flag File::RENDER_FORCE\n" );
                                }
-                       }
 
-                       // If the backend is ready-only, don't keep generating thumbnails
-                       // only to return transformation errors, just return the error now.
-                       if ( $this->repo->getReadOnlyReason() !== false ) {
-                               $thumb = $this->transformErrorOutput( $thumbPath, $thumbUrl, $params, $flags );
-                               break;
+                               // If the backend is ready-only, don't keep generating thumbnails
+                               // only to return transformation errors, just return the error now.
+                               if ( $this->repo->getReadOnlyReason() !== false ) {
+                                       $thumb = $this->transformErrorOutput( $thumbPath, $thumbUrl, $params, $flags );
+                                       break;
+                               }
                        }
 
-                       // Create a temp FS file with the same extension and the thumbnail
-                       $thumbExt = FileBackend::extensionFromPath( $thumbPath );
-                       $tmpFile = TempFSFile::factory( 'transform_', $thumbExt );
+                       $tmpFile = $this->makeTransformTmpFile( $thumbPath );
+
                        if ( !$tmpFile ) {
                                $thumb = $this->transformErrorOutput( $thumbPath, $thumbUrl, $params, $flags );
-                               break;
+                       } else {
+                               $thumb = $this->generateAndSaveThumb( $tmpFile, $params, $flags );
                        }
-                       $tmpThumbPath = $tmpFile->getPath(); // path of 0-byte temp file
-
-                       // Actually render the thumbnail...
-                       wfProfileIn( __METHOD__ . '-doTransform' );
-                       $thumb = $handler->doTransform( $this, $tmpThumbPath, $thumbUrl, $params );
-                       wfProfileOut( __METHOD__ . '-doTransform' );
-                       $tmpFile->bind( $thumb ); // keep alive with $thumb
-
-                       if ( !$thumb ) { // bad params?
-                               $thumb = false;
-                       } elseif ( $thumb->isError() ) { // transform error
-                               $this->lastError = $thumb->toText();
-                               // Ignore errors if requested
-                               if ( $wgIgnoreImageErrors && !( $flags & self::RENDER_NOW ) ) {
-                                       $thumb = $handler->getTransform( $this, $tmpThumbPath, $thumbUrl, $params );
-                               }
-                       } elseif ( $this->repo && $thumb->hasFile() && !$thumb->fileIsSource() ) {
-                               // Copy the thumbnail from the file system into storage...
-                               $disposition = $this->getThumbDisposition( $thumbName );
-                               $status = $this->repo->quickImport( $tmpThumbPath, $thumbPath, $disposition );
-                               if ( $status->isOK() ) {
-                                       $thumb->setStoragePath( $thumbPath );
-                               } else {
-                                       $thumb = $this->transformErrorOutput( $thumbPath, $thumbUrl, $params, $flags );
+               } while ( false );
+
+               wfProfileOut( __METHOD__ );
+
+               return is_object( $thumb ) ? $thumb : false;
+       }
+
+       /**
+        * Generates a thumbnail according to the given parameters and saves it to storage
+        * @param TempFSFile $tmpFile Temporary file where the rendered thumbnail will be saved
+        * @param array $transformParams
+        * @param int $flags
+        * @return bool|MediaTransformOutput
+        */
+       public function generateAndSaveThumb( $tmpFile, $transformParams, $flags ) {
+               global $wgUseSquid, $wgIgnoreImageErrors;
+
+               $handler = $this->getHandler();
+
+               $normalisedParams = $transformParams;
+               $handler->normaliseParams( $this, $normalisedParams );
+
+               $thumbName = $this->thumbName( $normalisedParams );
+               $thumbUrl = $this->getThumbUrl( $thumbName );
+               $thumbPath = $this->getThumbPath( $thumbName ); // final thumb path
+
+               $tmpThumbPath = $tmpFile->getPath();
+
+               if ( $handler->supportsBucketing() ) {
+                       $this->generateBucketsIfNeeded( $normalisedParams, $flags );
+               }
+
+               // Actually render the thumbnail...
+               wfProfileIn( __METHOD__ . '-doTransform' );
+               $thumb = $handler->doTransform( $this, $tmpThumbPath, $thumbUrl, $transformParams );
+               wfProfileOut( __METHOD__ . '-doTransform' );
+               $tmpFile->bind( $thumb ); // keep alive with $thumb
+
+               if ( !$thumb ) { // bad params?
+                       $thumb = false;
+               } elseif ( $thumb->isError() ) { // transform error
+                       $this->lastError = $thumb->toText();
+                       // Ignore errors if requested
+                       if ( $wgIgnoreImageErrors && !( $flags & self::RENDER_NOW ) ) {
+                               $thumb = $handler->getTransform( $this, $tmpThumbPath, $thumbUrl, $transformParams );
+                       }
+               } elseif ( $this->repo && $thumb->hasFile() && !$thumb->fileIsSource() ) {
+                       // Copy the thumbnail from the file system into storage...
+                       $disposition = $this->getThumbDisposition( $thumbName );
+                       $status = $this->repo->quickImport( $tmpThumbPath, $thumbPath, $disposition );
+                       if ( $status->isOK() ) {
+                               $thumb->setStoragePath( $thumbPath );
+                       } else {
+                               $thumb = $this->transformErrorOutput( $thumbPath, $thumbUrl, $transformParams, $flags );
+                       }
+                       // Give extensions a chance to do something with this thumbnail...
+                       wfRunHooks( 'FileTransformed', array( $this, $thumb, $tmpThumbPath, $thumbPath ) );
+               }
+
+               // Purge. Useful in the event of Core -> Squid connection failure or squid
+               // purge collisions from elsewhere during failure. Don't keep triggering for
+               // "thumbs" which have the main image URL though (bug 13776)
+               if ( $wgUseSquid ) {
+                       if ( !$thumb || $thumb->isError() || $thumb->getUrl() != $this->getURL() ) {
+                               SquidUpdate::purge( array( $thumbUrl ) );
+                       }
+               }
+
+               return $thumb;
+       }
+
+       /**
+        * Generates chained bucketed thumbnails if needed
+        * @param array $params
+        * @param int $flags
+        * @return bool Whether at least one bucket was generated
+        */
+       protected function generateBucketsIfNeeded( $params, $flags = 0 ) {
+               if ( !$this->repo
+                       || !isset( $params['physicalWidth'] )
+                       || !isset( $params['physicalHeight'] )
+                       || !( $bucket = $this->getThumbnailBucket( $params['physicalWidth'] ) )
+                       || $bucket == $params['physicalWidth'] ) {
+                       return false;
+               }
+
+               $bucketPath = $this->getBucketThumbPath( $bucket );
+
+               if ( $this->repo->fileExists( $bucketPath ) ) {
+                       return false;
+               }
+
+               $params['physicalWidth'] = $bucket;
+               $params['width'] = $bucket;
+
+               $params = $this->getHandler()->sanitizeParamsForBucketing( $params );
+
+               $bucketName = $this->getBucketThumbName( $bucket );
+
+               $tmpFile = $this->makeTransformTmpFile( $bucketPath );
+
+               if ( !$tmpFile ) {
+                       return false;
+               }
+
+               $thumb = $this->generateAndSaveThumb( $tmpFile, $params, $flags );
+
+               if ( !$thumb || $thumb->isError() ) {
+                       return false;
+               }
+
+               $this->tmpBucketedThumbCache[ $bucket ] = $tmpFile->getPath();
+               // For the caching to work, we need to make the tmp file survive as long as
+               // this object exists
+               $tmpFile->bind( $this );
+
+               return true;
+       }
+
+       /**
+        * Returns the most appropriate source image for the thumbnail, given a target thumbnail size
+        * @param array $params
+        * @return array source path and width/height of the source
+        */
+       public function getThumbnailSource( $params ) {
+               if ( $this->repo
+                       && $this->getHandler()->supportsBucketing()
+                       && isset( $params['physicalWidth'] )
+                       && $bucket = $this->getThumbnailBucket( $params['physicalWidth'] )
+               ) {
+                       if ( $this->getWidth() != 0 ) {
+                               $bucketHeight = round( $this->getHeight() * ( $bucket / $this->getWidth() ) );
+                       } else {
+                               $bucketHeight = 0;
+                       }
+
+                       // Try to avoid reading from storage if the file was generated by this script
+                       if ( isset( $this->tmpBucketedThumbCache[ $bucket ] ) ) {
+                               $tmpPath = $this->tmpBucketedThumbCache[ $bucket ];
+
+                               if ( file_exists( $tmpPath ) ) {
+                                       return array(
+                                               'path' => $tmpPath,
+                                               'width' => $bucket,
+                                               'height' => $bucketHeight
+                                       );
                                }
-                               // Give extensions a chance to do something with this thumbnail...
-                               wfRunHooks( 'FileTransformed', array( $this, $thumb, $tmpThumbPath, $thumbPath ) );
                        }
 
-                       // Purge. Useful in the event of Core -> Squid connection failure or squid
-                       // purge collisions from elsewhere during failure. Don't keep triggering for
-                       // "thumbs" which have the main image URL though (bug 13776)
-                       if ( $wgUseSquid ) {
-                               if ( !$thumb || $thumb->isError() || $thumb->getUrl() != $this->getURL() ) {
-                                       SquidUpdate::purge( array( $thumbUrl ) );
+                       $bucketPath = $this->getBucketThumbPath( $bucket );
+
+                       if ( $this->repo->fileExists( $bucketPath ) ) {
+                               $fsFile = $this->repo->getLocalReference( $bucketPath );
+
+                               if ( $fsFile ) {
+                                       return array(
+                                               'path' => $fsFile->getPath(),
+                                               'width' => $bucket,
+                                               'height' => $bucketHeight
+                                       );
                                }
                        }
-               } while ( false );
+               }
 
-               wfProfileOut( __METHOD__ );
+               // Original file
+               return array(
+                       'path' => $this->getLocalRefPath(),
+                       'width' => $this->getWidth(),
+                       'height' => $this->getHeight()
+               );
+       }
 
-               return is_object( $thumb ) ? $thumb : false;
+       /**
+        * Returns the repo path of the thumb for a given bucket
+        * @param int $bucket
+        * @return string
+        */
+       protected function getBucketThumbPath( $bucket ) {
+               $thumbName = $this->getBucketThumbName( $bucket );
+               return $this->getThumbPath( $thumbName );
+       }
+
+       /**
+        * Returns the name of the thumb for a given bucket
+        * @param int $bucket
+        * @return string
+        */
+       protected function getBucketThumbName( $bucket ) {
+               return $this->thumbName( array( 'physicalWidth' => $bucket ) );
+       }
+
+       /**
+        * Creates a temp FS file with the same extension and the thumbnail
+        * @param string $thumbPath Thumbnail path
+        * @returns TempFSFile
+        */
+       protected function makeTransformTmpFile( $thumbPath ) {
+               $thumbExt = FileBackend::extensionFromPath( $thumbPath );
+               return TempFSFile::factory( 'transform_', $thumbExt );
        }
 
        /**
@@ -1741,7 +1945,7 @@ abstract class File {
                        return false;
                }
 
-               return $this->handler->getImageSize( $this, $filePath );
+               return $this->getHandler()->getImageSize( $this, $filePath );
        }
 
        /**
index 44be178..8ac58ff 100644 (file)
@@ -117,6 +117,7 @@ class BitmapHandler extends ImageHandler {
                if ( !$this->normaliseParams( $image, $params ) ) {
                        return new TransformParameterError( $params );
                }
+
                # Create a parameter array to pass to the scaler
                $scalerParams = array(
                        # The size to which the image will be resized
@@ -187,7 +188,12 @@ class BitmapHandler extends ImageHandler {
                }
 
                # Transform functions and binaries need a FS source file
-               $scalerParams['srcPath'] = $image->getLocalRefPath();
+               $thumbnailSource = $image->getThumbnailSource( $params );
+
+               $scalerParams['srcPath'] = $thumbnailSource['path'];
+               $scalerParams['srcWidth'] = $thumbnailSource['width'];
+               $scalerParams['srcHeight'] = $thumbnailSource['height'];
+
                if ( $scalerParams['srcPath'] === false ) { // Failed to get local copy
                        wfDebugLog( 'thumbnail',
                                sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"',
@@ -845,7 +851,7 @@ class BitmapHandler extends ImageHandler {
        }
 
        /**
-        * Rerurns whether the file needs to be rendered. Returns true if the
+        * Returns whether the file needs to be rendered. Returns true if the
         * file requires rotation and we are able to rotate it.
         *
         * @param File $file
index 8a12e7e..6dd0453 100644 (file)
@@ -269,4 +269,20 @@ abstract class ImageHandler extends MediaHandler {
                                ->numParams( $file->getWidth(), $file->getHeight() )->text();
                }
        }
+
+       public function sanitizeParamsForBucketing( $params ) {
+               $params = parent::sanitizeParamsForBucketing( $params );
+
+               // We unset the height parameters in order to let normaliseParams recalculate them
+               // Otherwise there might be a height discrepancy
+               if ( isset( $params['height'] ) ) {
+                       unset( $params['height'] );
+               }
+
+               if ( isset( $params['physicalHeight'] ) ) {
+                       unset( $params['physicalHeight'] );
+               }
+
+               return $params;
+       }
 }
index a0f7acb..918d4ae 100644 (file)
@@ -158,4 +158,19 @@ class JpegHandler extends ExifBitmapHandler {
                        return parent::rotate( $file, $params );
                }
        }
+
+       public function supportsBucketing() {
+               return true;
+       }
+
+       public function sanitizeParamsForBucketing( $params ) {
+               $params = parent::sanitizeParamsForBucketing( $params );
+
+               // Quality needs to be cleared for bucketing. Buckets need to be default quality
+               if ( isset( $params['quality'] ) ) {
+                       unset( $params['quality'] );
+               }
+
+               return $params;
+       }
 }
index efc02fe..fd694f9 100644 (file)
@@ -814,4 +814,24 @@ abstract class MediaHandler {
        public function isExpensiveToThumbnail( $file ) {
                return false;
        }
+
+       /**
+        * Returns whether or not this handler supports the chained generation of thumbnails according
+        * to buckets
+        * @return boolean
+        * @since  1.24
+        */
+       public function supportsBucketing() {
+               return false;
+       }
+
+       /**
+        * Returns a normalised params array for which parameters have been cleaned up for bucketing
+        * purposes
+        * @param array $params
+        * @return array
+        */
+       public function sanitizeParamsForBucketing( $params ) {
+               return $params;
+       }
 }
index 968db10..d879c12 100644 (file)
@@ -173,4 +173,8 @@ class PNGHandler extends BitmapHandler {
 
                return $wgLang->commaList( $info );
        }
+
+       public function supportsBucketing() {
+               return true;
+       }
 }
diff --git a/tests/phpunit/includes/filerepo/file/FileTest.php b/tests/phpunit/includes/filerepo/file/FileTest.php
new file mode 100644 (file)
index 0000000..9232ce4
--- /dev/null
@@ -0,0 +1,348 @@
+<?php
+
+class FileRepoFileTest extends MediaWikiMediaTestCase {
+       /**
+        * @dataProvider getThumbnailBucketProvider
+        * @covers File::getThumbnailBucket
+        */
+       public function testGetThumbnailBucket( $data ) {
+               $this->setMwGlobals( 'wgThumbnailBuckets', $data['buckets'] );
+               $this->setMwGlobals( 'wgThumbnailMinimumBucketDistance', $data['minimumBucketDistance'] );
+
+               $fileMock = $this->getMockBuilder( 'File' )
+                       ->setConstructorArgs( array( 'fileMock', false ) )
+                       ->setMethods( array( 'getWidth' ) )
+                       ->getMockForAbstractClass();
+
+               $fileMock->expects( $this->any() )->method( 'getWidth' )->will(
+                       $this->returnValue( $data['width'] ) );
+
+               $this->assertEquals(
+                       $data['expectedBucket'],
+                       $fileMock->getThumbnailBucket( $data['requestedWidth'] ),
+                       $data['message'] );
+       }
+
+       public function getThumbnailBucketProvider() {
+               $defaultBuckets = array( 256, 512, 1024, 2048, 4096 );
+
+               return array(
+                       array( array(
+                               'buckets' => $defaultBuckets,
+                               'minimumBucketDistance' => 0,
+                               'width' => 3000,
+                               'requestedWidth' => 120,
+                               'expectedBucket' => 256,
+                               'message' => 'Picking bucket bigger than requested size'
+                       ) ),
+                       array( array(
+                               'buckets' => $defaultBuckets,
+                               'minimumBucketDistance' => 0,
+                               'width' => 3000,
+                               'requestedWidth' => 300,
+                               'expectedBucket' => 512,
+                               'message' => 'Picking bucket bigger than requested size'
+                       ) ),
+                       array( array(
+                               'buckets' => $defaultBuckets,
+                               'minimumBucketDistance' => 0,
+                               'width' => 3000,
+                               'requestedWidth' => 1024,
+                               'expectedBucket' => 2048,
+                               'message' => 'Picking bucket bigger than requested size'
+                       ) ),
+                       array( array(
+                               'buckets' => $defaultBuckets,
+                               'minimumBucketDistance' => 0,
+                               'width' => 3000,
+                               'requestedWidth' => 2048,
+                               'expectedBucket' => false,
+                               'message' => 'Picking no bucket because none is bigger than the requested size'
+                       ) ),
+                       array( array(
+                               'buckets' => $defaultBuckets,
+                               'minimumBucketDistance' => 0,
+                               'width' => 3000,
+                               'requestedWidth' => 3500,
+                               'expectedBucket' => false,
+                               'message' => 'Picking no bucket because requested size is bigger than original'
+                       ) ),
+                       array( array(
+                               'buckets' => array( 1024 ),
+                               'minimumBucketDistance' => 0,
+                               'width' => 3000,
+                               'requestedWidth' => 1024,
+                               'expectedBucket' => false,
+                               'message' => 'Picking no bucket because requested size equals biggest bucket'
+                       ) ),
+                       array( array(
+                               'buckets' => null,
+                               'minimumBucketDistance' => 0,
+                               'width' => 3000,
+                               'requestedWidth' => 1024,
+                               'expectedBucket' => false,
+                               'message' => 'Picking no bucket because no buckets have been specified'
+                       ) ),
+                       array( array(
+                               'buckets' => array( 256, 512 ),
+                               'minimumBucketDistance' => 10,
+                               'width' => 3000,
+                               'requestedWidth' => 245,
+                               'expectedBucket' => 256,
+                               'message' => 'Requested width is distant enough from next bucket for it to be picked'
+                       ) ),
+                       array( array(
+                               'buckets' => array( 256, 512 ),
+                               'minimumBucketDistance' => 10,
+                               'width' => 3000,
+                               'requestedWidth' => 246,
+                               'expectedBucket' => 512,
+                               'message' => 'Requested width is too close to next bucket, picking next one'
+                       ) ),
+               );
+       }
+
+       /**
+        * @dataProvider getThumbnailSourceProvider
+        * @covers File::getThumbnailSource
+        */
+       public function testGetThumbnailSource( $data ) {
+               $backendMock = $this->getMockBuilder( 'FSFileBackend' )
+                       ->setConstructorArgs( array( array( 'name' => 'backendMock', 'wikiId' => wfWikiId() ) ) )
+                       ->getMock();
+
+               $repoMock = $this->getMockBuilder( 'FileRepo' )
+                       ->setConstructorArgs( array( array( 'name' => 'repoMock', 'backend' => $backendMock ) ) )
+                       ->setMethods( array( 'fileExists', 'getLocalReference' ) )
+                       ->getMock();
+
+               $fsFile = new FSFile( 'fsFilePath' );
+
+               $repoMock->expects( $this->any() )->method( 'fileExists' )->will(
+                       $this->returnValue( true ) );
+
+               $repoMock->expects( $this->any() )->method( 'getLocalReference' )->will(
+                       $this->returnValue( $fsFile ) );
+
+               $handlerMock = $this->getMock( 'BitmapHandler', array( 'supportsBucketing' ) );
+               $handlerMock->expects( $this->any() )->method( 'supportsBucketing' )->will(
+                       $this->returnValue( $data['supportsBucketing'] ) );
+
+               $fileMock = $this->getMockBuilder( 'File' )
+                       ->setConstructorArgs( array( 'fileMock', $repoMock ) )
+                       ->setMethods( array( 'getThumbnailBucket', 'getLocalRefPath', 'getHandler' ) )
+                       ->getMockForAbstractClass();
+
+               $fileMock->expects( $this->any() )->method( 'getThumbnailBucket' )->will(
+                       $this->returnValue( $data['thumbnailBucket'] ) );
+
+               $fileMock->expects( $this->any() )->method( 'getLocalRefPath' )->will(
+                       $this->returnValue( 'localRefPath' ) );
+
+               $fileMock->expects( $this->any() )->method( 'getHandler' )->will(
+                       $this->returnValue( $handlerMock ) );
+
+               $reflection = new ReflectionClass( $fileMock );
+               $reflection_property = $reflection->getProperty( 'handler' );
+               $reflection_property->setAccessible( true );
+               $reflection_property->setValue( $fileMock, $handlerMock );
+
+               if ( !is_null( $data['tmpBucketedThumbCache'] ) ) {
+                       $reflection_property = $reflection->getProperty( 'tmpBucketedThumbCache' );
+                       $reflection_property->setAccessible( true );
+                       $reflection_property->setValue( $fileMock, $data['tmpBucketedThumbCache'] );
+               }
+
+               $result = $fileMock->getThumbnailSource(
+                       array( 'physicalWidth' => $data['physicalWidth'] ) );
+
+               $this->assertEquals( $data['expectedPath'], $result['path'], $data['message'] );
+       }
+
+       public function getThumbnailSourceProvider() {
+               return array(
+                       array( array(
+                               'supportsBucketing' => true,
+                               'tmpBucketedThumbCache' => null,
+                               'thumbnailBucket' => 1024,
+                               'physicalWidth' => 2048,
+                               'expectedPath' => 'fsFilePath',
+                               'message' => 'Path downloaded from storage'
+                       ) ),
+                       array( array(
+                               'supportsBucketing' => true,
+                               'tmpBucketedThumbCache' => array( 1024 => '/tmp/shouldnotexist' + rand() ),
+                               'thumbnailBucket' => 1024,
+                               'physicalWidth' => 2048,
+                               'expectedPath' => 'fsFilePath',
+                               'message' => 'Path downloaded from storage because temp file is missing'
+                       ) ),
+                       array( array(
+                               'supportsBucketing' => true,
+                               'tmpBucketedThumbCache' => array( 1024 => '/tmp' ),
+                               'thumbnailBucket' => 1024,
+                               'physicalWidth' => 2048,
+                               'expectedPath' => '/tmp',
+                               'message' => 'Temporary path because temp file was found'
+                       ) ),
+                       array( array(
+                               'supportsBucketing' => false,
+                               'tmpBucketedThumbCache' => null,
+                               'thumbnailBucket' => 1024,
+                               'physicalWidth' => 2048,
+                               'expectedPath' => 'localRefPath',
+                               'message' => 'Original file path because bucketing is unsupported by handler'
+                       ) ),
+                       array( array(
+                               'supportsBucketing' => true,
+                               'tmpBucketedThumbCache' => null,
+                               'thumbnailBucket' => false,
+                               'physicalWidth' => 2048,
+                               'expectedPath' => 'localRefPath',
+                               'message' => 'Original file path because no width provided'
+                       ) ),
+               );
+       }
+
+       /**
+        * @dataProvider generateBucketsIfNeededProvider
+        * @covers File::generateBucketsIfNeeded
+        */
+       public function testGenerateBucketsIfNeeded( $data ) {
+               $this->setMwGlobals( 'wgThumbnailBuckets', $data['buckets'] );
+
+               $backendMock = $this->getMockBuilder( 'FSFileBackend' )
+                       ->setConstructorArgs( array( array( 'name' => 'backendMock', 'wikiId' => wfWikiId() ) ) )
+                       ->getMock();
+
+               $repoMock = $this->getMockBuilder( 'FileRepo' )
+                       ->setConstructorArgs( array( array( 'name' => 'repoMock', 'backend' => $backendMock ) ) )
+                       ->setMethods( array( 'fileExists', 'getLocalReference' ) )
+                       ->getMock();
+
+               $fileMock = $this->getMockBuilder( 'File' )
+                       ->setConstructorArgs( array( 'fileMock', $repoMock ) )
+                       ->setMethods( array( 'getWidth', 'getBucketThumbPath', 'makeTransformTmpFile', 'generateAndSaveThumb', 'getHandler' ) )
+                       ->getMockForAbstractClass();
+
+               $handlerMock = $this->getMock( 'JpegHandler', array( 'supportsBucketing' ) );
+               $handlerMock->expects( $this->any() )->method( 'supportsBucketing' )->will(
+                       $this->returnValue( true ) );
+
+               $fileMock->expects( $this->any() )->method( 'getHandler' )->will(
+                       $this->returnValue( $handlerMock ) );
+
+               $reflectionMethod = new ReflectionMethod( 'File', 'generateBucketsIfNeeded' );
+               $reflectionMethod->setAccessible( true );
+
+               $fileMock->expects( $this->any() )
+                       ->method( 'getWidth' )
+                       ->will( $this->returnValue( $data['width'] ) );
+
+               $fileMock->expects( $data['expectedGetBucketThumbPathCalls'] )
+                       ->method( 'getBucketThumbPath' );
+
+               $repoMock->expects( $data['expectedFileExistsCalls'] )
+                       ->method( 'fileExists' )
+                       ->will( $this->returnValue( $data['fileExistsReturn'] ) );
+
+               $fileMock->expects( $data['expectedMakeTransformTmpFile'] )
+                       ->method( 'makeTransformTmpFile' )
+                       ->will( $this->returnValue( $data['makeTransformTmpFileReturn'] ) );
+
+               $fileMock->expects( $data['expectedGenerateAndSaveThumb'] )
+                       ->method( 'generateAndSaveThumb' )
+                       ->will( $this->returnValue( $data['generateAndSaveThumbReturn'] ) );
+
+               $this->assertEquals( $data['expectedResult'],
+                       $reflectionMethod->invoke(
+                               $fileMock,
+                               array(
+                                       'physicalWidth' => $data['physicalWidth'],
+                                       'physicalHeight' => $data['physicalHeight'] )
+                               ),
+                               $data['message'] );
+       }
+
+       public function generateBucketsIfNeededProvider() {
+               $defaultBuckets = array( 256, 512, 1024, 2048, 4096 );
+
+               return array(
+                       array( array(
+                               'buckets' => $defaultBuckets,
+                               'width' => 256,
+                               'physicalWidth' => 256,
+                               'physicalHeight' => 100,
+                               'expectedGetBucketThumbPathCalls' => $this->never(),
+                               'expectedFileExistsCalls' => $this->never(),
+                               'fileExistsReturn' => null,
+                               'expectedMakeTransformTmpFile' => $this->never(),
+                               'makeTransformTmpFileReturn' => false,
+                               'expectedGenerateAndSaveThumb' => $this->never(),
+                               'generateAndSaveThumbReturn' => false,
+                               'expectedResult' => false,
+                               'message' => 'No bucket found, nothing to generate'
+                       ) ),
+                       array( array(
+                               'buckets' => $defaultBuckets,
+                               'width' => 5000,
+                               'physicalWidth' => 300,
+                               'physicalHeight' => 200,
+                               'expectedGetBucketThumbPathCalls' => $this->once(),
+                               'expectedFileExistsCalls' => $this->once(),
+                               'fileExistsReturn' => true,
+                               'expectedMakeTransformTmpFile' => $this->never(),
+                               'makeTransformTmpFileReturn' => false,
+                               'expectedGenerateAndSaveThumb' => $this->never(),
+                               'generateAndSaveThumbReturn' => false,
+                               'expectedResult' => false,
+                               'message' => 'File already exists, no reason to generate buckets'
+                       ) ),
+                       array( array(
+                               'buckets' => $defaultBuckets,
+                               'width' => 5000,
+                               'physicalWidth' => 300,
+                               'physicalHeight' => 200,
+                               'expectedGetBucketThumbPathCalls' => $this->once(),
+                               'expectedFileExistsCalls' => $this->once(),
+                               'fileExistsReturn' => false,
+                               'expectedMakeTransformTmpFile' => $this->once(),
+                               'makeTransformTmpFileReturn' => false,
+                               'expectedGenerateAndSaveThumb' => $this->never(),
+                               'generateAndSaveThumbReturn' => false,
+                               'expectedResult' => false,
+                               'message' => 'Cannot generate temp file for bucket'
+                       ) ),
+                       array( array(
+                               'buckets' => $defaultBuckets,
+                               'width' => 5000,
+                               'physicalWidth' => 300,
+                               'physicalHeight' => 200,
+                               'expectedGetBucketThumbPathCalls' => $this->once(),
+                               'expectedFileExistsCalls' => $this->once(),
+                               'fileExistsReturn' => false,
+                               'expectedMakeTransformTmpFile' => $this->once(),
+                               'makeTransformTmpFileReturn' => new TempFSFile( '/tmp/foo' ),
+                               'expectedGenerateAndSaveThumb' => $this->once(),
+                               'generateAndSaveThumbReturn' => false,
+                               'expectedResult' => false,
+                               'message' => 'Bucket image could not be generated'
+                       ) ),
+                       array( array(
+                               'buckets' => $defaultBuckets,
+                               'width' => 5000,
+                               'physicalWidth' => 300,
+                               'physicalHeight' => 200,
+                               'expectedGetBucketThumbPathCalls' => $this->once(),
+                               'expectedFileExistsCalls' => $this->once(),
+                               'fileExistsReturn' => false,
+                               'expectedMakeTransformTmpFile' => $this->once(),
+                               'makeTransformTmpFileReturn' => new TempFSFile( '/tmp/foo' ),
+                               'expectedGenerateAndSaveThumb' => $this->once(),
+                               'generateAndSaveThumbReturn' => new ThumbnailImage( false, 'bar', false, false ),
+                               'expectedResult' => true,
+                               'message' => 'Bucket image could not be generated'
+                       ) ),
+               );
+       }
+}