Skip to content

Commit

Permalink
Implement chunking of generating thumbnails
Browse files Browse the repository at this point in the history
Closes #90
  • Loading branch information
mzur committed Jul 17, 2017
1 parent c0563a1 commit 1bfe2cc
Show file tree
Hide file tree
Showing 7 changed files with 196 additions and 128 deletions.
3 changes: 2 additions & 1 deletion app/Jobs/GenerateThumbnails.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Biigle\Jobs;

use App;
use Biigle\Volume;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
Expand Down Expand Up @@ -47,7 +48,7 @@ public function __construct(Volume $volume, array $only = [])
*/
public function handle()
{
app()->make('Biigle\Contracts\ThumbnailService')
App::make('Biigle\Contracts\ThumbnailService')
->generateThumbnails($this->volume, $this->only);
}
}
109 changes: 109 additions & 0 deletions app/Jobs/ProcessThumbnailChunkJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

namespace Biigle\Jobs;

use Log;
use File;
use Biigle\Image;
use InterventionImage as IImage;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Intervention\Image\Exception\NotReadableException;

class ProcessThumbnailChunkJob extends Job implements ShouldQueue
{
use InteractsWithQueue, SerializesModels;

/**
* The images to generate thumbnails for.
*
* Public for testability.
*
* @var Collection
*/
public $images;

/**
* The desired thumbnail width.
*
* @var int
*/
protected $width;

/**
* The desired thumbnail height.
*
* @var int
*/
protected $height;

/**
* The desired thumbnail file format.
*
* @var int
*/
protected $format;

/**
* Create a new job instance.
*
* @param Collection $images The images to generate thumbnails for.
*
* @return void
*/
public function __construct($images)
{
$this->images = $images;
}

/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$this->width = config('thumbnails.width');
$this->height = config('thumbnails.height');
$this->format = config('thumbnails.format');

$memoryLimit = ini_get('memory_limit');
// increase memory limit for resizing large images
ini_set('memory_limit', config('thumbnails.memory_limit'));

foreach ($this->images as $image) {
$this->makeThumbnail($image);
}

// restore default memory limit
ini_set('memory_limit', $memoryLimit);
}

/**
* Makes a thumbnail for a single image.
*
* @param Image $image
*/
protected function makeThumbnail(Image $image)
{
// Skip existing thumbnails.
if (File::exists($image->thumbPath)) {
return;
}

try {
IImage::make($image->url)
->resize($this->width, $this->height, function ($constraint) {
// resize images proportionally
$constraint->aspectRatio();
})
->encode($this->format)
->save($image->thumbPath)
// free memory; very important for scaling 1000s of images!!
->destroy();
} catch (NotReadableException $e) {
Log::error('Could not generate thumbnail for image '.$image->id.': '.$e->getMessage());
}
}
}
84 changes: 11 additions & 73 deletions app/Services/Thumbnails/InterventionImage.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,93 +2,31 @@

namespace Biigle\Services\Thumbnails;

use Log;
use File;
use Biigle\Image;
use Biigle\Volume;
use InterventionImage as IImage;
use Biigle\Contracts\ThumbnailService;
use Intervention\Image\Exception\NotReadableException;
use Biigle\Jobs\ProcessThumbnailChunkJob;
use Illuminate\Foundation\Bus\DispatchesJobs;

/**
* The default thumbnails service using the InterventionImage package
* (http://image.intervention.io/).
*/
class InterventionImage implements ThumbnailService
{
/**
* Memory limit to set during resizing the images.
*
* @var string
*/
const MEMORY_LIMIT = '512M';

/**
* Maximum width to scale the thumbnails to.
*
* @var int
*/
public static $width;

/**
* Maximum height to scale the thumbnails to.
*
* @var int
*/
public static $height;

/**
* Makes a thumbnail for a single image.
*
* Must be public so it can be used as a callable.
*
* @param Image $image
*/
public static function makeThumbnail(Image $image)
{
// Skip existing thumbnails.
if (File::exists($image->thumbPath)) {
return;
}

try {
IImage::make($image->url)
->resize(static::$width, static::$height, function ($constraint) {
// resize images proportionally
$constraint->aspectRatio();
})
->encode(config('thumbnails.format'))
->save($image->thumbPath)
// free memory; very important for scaling 1000s of images!!
->destroy();
} catch (NotReadableException $e) {
Log::error('Could not generate thumbnail for image '.$image->id.': '.$e->getMessage());
}
}
use DispatchesJobs;

/**
* {@inheritdoc}
*/
public function generateThumbnails(Volume $volume, array $only)
{
$memoryLimit = ini_get('memory_limit');

// increase memory limit for resizing large images
ini_set('memory_limit', self::MEMORY_LIMIT);
// set dimensions once, so config() is not called for every image
static::$width = config('thumbnails.width');
static::$height = config('thumbnails.height');

$query = $volume->images()->when($only, function ($query) use ($only) {
return $query->whereIn('id', $only);
});

// process the images, 100 at a time
$query->chunk(100, function ($images) {
$images->map([self::class, 'makeThumbnail']);
});

// restore default memory limit
ini_set('memory_limit', $memoryLimit);
$volume->images()
->with('volume')
->when($only, function ($query) use ($only) {
return $query->whereIn('id', $only);
})
->chunk(100, function ($images) {
$this->dispatch(new ProcessThumbnailChunkJob($images));
});
}
}
6 changes: 6 additions & 0 deletions config/thumbnails.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,10 @@
*/
'empty_url' => 'assets/images/empty-thumbnail.svg',

/*
| PHP memory limit to use during processing of the images. After processing, the
| default memory limit will be used.
*/
'memory_limit' => '512M',

];
6 changes: 2 additions & 4 deletions tests/php/Http/Controllers/Api/ImageControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use ApiTestCase;
use Biigle\Volume;
use Biigle\Tests\ImageTest;
use Biigle\Services\Thumbnails\InterventionImage;
use Biigle\Jobs\ProcessThumbnailChunkJob;

class ImageControllerTest extends ApiTestCase
{
Expand Down Expand Up @@ -46,9 +46,7 @@ public function testShow()
public function testShowThumb()
{
// generate thumbnail manually
InterventionImage::$width = 10;
InterventionImage::$height = 10;
InterventionImage::makeThumbnail($this->image);
with(new ProcessThumbnailChunkJob(collect([$this->image])))->handle();
$id = $this->image->id;

$this->doTestApiRoute('GET', "/api/v1/images/{$id}/thumb");
Expand Down
56 changes: 56 additions & 0 deletions tests/php/Jobs/ProcessThumbnailChunkJobTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace Biigle\Tests\Jobs;

use Log;
use File;
use TestCase;
use Biigle\Tests\ImageTest;
use Biigle\Tests\VolumeTest;
use InterventionImage as IImage;
use Biigle\Jobs\ProcessThumbnailChunkJob;

class ProcessThumbnailChunkJobTest extends TestCase
{
public function testHandle()
{
$volume = VolumeTest::create();
$image = ImageTest::create(['volume_id' => $volume->id]);
File::delete($image->thumbPath);

with(new ProcessThumbnailChunkJob([$image]))->handle();

$this->assertTrue(File::exists($image->thumbPath));
$size = getimagesize($image->thumbPath);
$config = [config('thumbnails.width'), config('thumbnails.height')];

$this->assertTrue($size[0] <= $config[0]);
$this->assertTrue($size[1] <= $config[1]);
$this->assertTrue($size[0] == $config[0] || $size[1] == $config[1]);

File::delete($image->thumbPath);
}

public function testHandleNotReadable()
{
Log::shouldReceive('error')->once();
$image = ImageTest::create(['filename' => 'does_not_exist']);
with(new ProcessThumbnailChunkJob([$image]))->handle();
}

public function testSkipExisting()
{
// This actually doesn't work and IImake::make() will throw an error afterwards.
// But we want to test that make() isn't called anyway so if an error is thrown
// this test fails as expected.
IImage::shouldReceive('make')->never();

$image = ImageTest::create(['filename' => 'random']);
touch($image->thumbPath);
try {
with(new ProcessThumbnailChunkJob([$image]))->handle();
} finally {
File::delete($image->thumbPath);
}
}
}
Loading

0 comments on commit 1bfe2cc

Please sign in to comment.