Backport asynchronous thumbnail generation from Kabuki.

This commit is contained in:
Aaron van Geffen 2017-12-20 14:51:23 +01:00
parent 981b652e25
commit 1def1484cb
9 changed files with 566 additions and 214 deletions

View File

@ -76,11 +76,11 @@ class EditAsset extends HTMLController
$image->removeAllThumbnails(); $image->removeAllThumbnails();
} }
} }
elseif (preg_match('~^thumb_(\d+)x(\d+)(_c[best]?)?$~', $_POST['replacement_target'])) elseif (preg_match('~^thumb_(\d+x\d+(?:_c[best]?)?)$~', $_POST['replacement_target'], $match))
{ {
$image = $asset->getImage(); $image = $asset->getImage();
if (($replace_result = $image->replaceThumbnail($_POST['replacement_target'], $_FILES['replacement']['tmp_name'])) !== 0) if (($replace_result = $image->replaceThumbnail($match[1], $_FILES['replacement']['tmp_name'])) !== 0)
throw new Exception('Could not replace thumbnail \'' . $_POST['replacement_target'] . '\' with the uploaded file. Error code: ' . $replace_result); throw new Exception('Could not replace thumbnail \'' . $match[1] . '\' with the uploaded file. Error code: ' . $replace_result);
} }
} }
@ -97,12 +97,18 @@ class EditAsset extends HTMLController
private function getThumbs(Asset $asset) private function getThumbs(Asset $asset)
{ {
$path = $asset->getPath(); if (!$asset->isImage())
return [];
$image = $asset->getImage();
$subdir = $image->getSubdir();
$metadata = $image->getMeta();
$thumb_selectors = $image->getThumbnails();
$thumbs = []; $thumbs = [];
$metadata = $asset->getMeta(); foreach ($thumb_selectors as $selector => $filename)
foreach ($metadata as $key => $meta)
{ {
if (!preg_match('~^thumb_(?<width>\d+)x(?<height>\d+)(?<suffix>_c(?<method>[best]?))?$~', $key, $thumb)) if (!preg_match('~^(?<width>\d+)x(?<height>\d+)(?<suffix>_c(?<method>[best]?))?$~', $selector, $thumb))
continue; continue;
$has_crop_boundary = isset($metadata['crop_' . $thumb['width'] . 'x' . $thumb['height']]); $has_crop_boundary = isset($metadata['crop_' . $thumb['width'] . 'x' . $thumb['height']]);
@ -113,10 +119,10 @@ class EditAsset extends HTMLController
'crop_method' => !$has_custom_image && !empty($thumb['method']) ? $thumb['method'] : (!empty($thumb['suffix']) ? 'c' : null), 'crop_method' => !$has_custom_image && !empty($thumb['method']) ? $thumb['method'] : (!empty($thumb['suffix']) ? 'c' : null),
'crop_region' => $has_crop_boundary ? $metadata['crop_' . $thumb['width'] . 'x' . $thumb['height']] : null, 'crop_region' => $has_crop_boundary ? $metadata['crop_' . $thumb['width'] . 'x' . $thumb['height']] : null,
'custom_image' => $has_custom_image, 'custom_image' => $has_custom_image,
'filename' => $meta, 'filename' => $filename,
'full_path' => THUMBSDIR . '/' . $path . '/' . $meta, 'full_path' => THUMBSDIR . '/' . $subdir . '/' . $filename,
'url' => THUMBSURL . '/' . $path . '/' . $meta, 'url' => THUMBSURL . '/' . $subdir . '/' . $filename,
'status' => file_exists(THUMBSDIR . '/' . $path . '/' . $meta), 'status' => file_exists(THUMBSDIR . '/' . $subdir . '/' . $filename),
]; ];
} }

View File

@ -0,0 +1,27 @@
<?php
/*****************************************************************************
* GenerateThumbnail.php
* Contains the asynchronous thumbnail generation controller
*
* Kabuki CMS (C) 2013-2017, Aaron van Geffen
*****************************************************************************/
class GenerateThumbnail extends HTMLController
{
public function __construct()
{
$asset = Asset::fromId($_GET['id']);
if (empty($asset) || !$asset->isImage())
throw new NotFoundException('Image not found');
$image = $asset->getImage();
$crop_mode = isset($_GET['mode']) ? $_GET['mode'] : false;
$url = $image->getThumbnailUrl($_GET['width'], $_GET['height'], $crop_mode, true, true);
if ($url)
{
header('Location: ' . $url);
exit;
}
}
}

53
migrate_thumbs.php Normal file
View File

@ -0,0 +1,53 @@
<?php
/*****************************************************************************
* migrate_thumbs.php
* Migrates old-style thumbnails (meta) to new table.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
*****************************************************************************/
// Include the project's configuration.
require_once 'config.php';
// Set up the autoloader.
require_once 'vendor/autoload.php';
// Initialise the database.
$db = new Database(DB_SERVER, DB_USER, DB_PASS, DB_NAME);
Registry::set('db', $db);
// Do some authentication checks.
Session::start();
Registry::set('user', Authentication::isLoggedIn() ? Member::fromId($_SESSION['user_id']) : new Guest());
$res = $db->query('
SELECT id_asset, variable, value
FROM assets_meta
WHERE variable LIKE {string:thumbs}',
['thumbs' => 'thumb_%']);
while ($row = $db->fetch_assoc($res))
{
if (!preg_match('~^thumb_(?<width>\d+)x(?<height>\d+)(?:_(?<mode>c[best]?))?$~', $row['variable'], $match))
continue;
echo 'Migrating ... ', $row['value'], '(#', $row['id_asset'], ")\r";
$db->insert('replace', 'assets_thumbs', [
'id_asset' => 'int',
'width' => 'int',
'height' => 'int',
'mode' => 'string-3',
'filename' => 'string-255',
], [
'id_asset' => $row['id_asset'],
'width' => $match['width'],
'height' => $match['height'],
'mode' => $match['mode'] ?? '',
'filename' => $row['value'],
]);
}
echo "\nDone\n";

View File

@ -18,8 +18,10 @@ class Asset
protected $image_height; protected $image_height;
protected $date_captured; protected $date_captured;
protected $priority; protected $priority;
protected $meta; protected $meta;
protected $tags; protected $tags;
protected $thumbnails;
protected function __construct(array $data) protected function __construct(array $data)
{ {
@ -58,8 +60,10 @@ class Asset
public static function byRow(array $row, $return_format = 'object') public static function byRow(array $row, $return_format = 'object')
{ {
$db = Registry::get('db');
// Supplement with metadata. // Supplement with metadata.
$row['meta'] = Registry::get('db')->queryPair(' $row['meta'] = $db->queryPair('
SELECT variable, value SELECT variable, value
FROM assets_meta FROM assets_meta
WHERE id_asset = {int:id_asset}', WHERE id_asset = {int:id_asset}',
@ -67,6 +71,24 @@ class Asset
'id_asset' => $row['id_asset'], 'id_asset' => $row['id_asset'],
]); ]);
// And thumbnails.
$row['thumbnails'] = $db->queryPair('
SELECT
CONCAT(
width,
{string:x},
height,
IF(mode != {string:empty}, CONCAT({string:_}, mode), {string:empty})
) AS selector, filename
FROM assets_thumbs
WHERE id_asset = {int:id_asset}',
[
'id_asset' => $row['id_asset'],
'empty' => '',
'x' => 'x',
'_' => '_',
]);
return $return_format == 'object' ? new Asset($row) : $row; return $return_format == 'object' ? new Asset($row) : $row;
} }
@ -91,6 +113,7 @@ class Asset
{ {
$assets[$asset['id_asset']] = $asset; $assets[$asset['id_asset']] = $asset;
$assets[$asset['id_asset']]['meta'] = []; $assets[$asset['id_asset']]['meta'] = [];
$assets[$asset['id_asset']]['thumbnails'] = [];
} }
$metas = $db->queryRows(' $metas = $db->queryRows('
@ -105,6 +128,27 @@ class Asset
foreach ($metas as $meta) foreach ($metas as $meta)
$assets[$meta[0]]['meta'][$meta[1]] = $meta[2]; $assets[$meta[0]]['meta'][$meta[1]] = $meta[2];
$thumbnails = $db->queryRows('
SELECT id_asset,
CONCAT(
width,
{string:x},
height,
IF(mode != {string:empty}, CONCAT({string:_}, mode), {string:empty})
) AS selector, filename
FROM assets_thumbs
WHERE id_asset IN ({array_int:id_assets})
ORDER BY id_asset',
[
'id_assets' => $id_assets,
'empty' => '',
'x' => 'x',
'_' => '_',
]);
foreach ($thumbnails as $thumb)
$assets[$thumb[0]]['thumbnails'][$thumb[1]] = $thumb[2];
if ($return_format == 'array') if ($return_format == 'array')
return $assets; return $assets;
else else
@ -270,7 +314,7 @@ class Asset
return ASSETSDIR . '/' . $this->subdir . '/' . $this->filename; return ASSETSDIR . '/' . $this->subdir . '/' . $this->filename;
} }
public function getPath() public function getSubdir()
{ {
return $this->subdir; return $this->subdir;
} }

View File

@ -11,12 +11,14 @@ class AssetIterator extends Asset
private $return_format; private $return_format;
private $res_assets; private $res_assets;
private $res_meta; private $res_meta;
private $res_thumbs;
protected function __construct($res_assets, $res_meta, $return_format) protected function __construct($res_assets, $res_meta, $res_thumbs, $return_format)
{ {
$this->db = Registry::get('db'); $this->db = Registry::get('db');
$this->res_assets = $res_assets; $this->res_assets = $res_assets;
$this->res_meta = $res_meta; $this->res_meta = $res_meta;
$this->res_thumbs = $res_thumbs;
$this->return_format = $return_format; $this->return_format = $return_format;
} }
@ -41,6 +43,19 @@ class AssetIterator extends Asset
// Reset internal pointer for next asset. // Reset internal pointer for next asset.
$this->db->data_seek($this->res_meta, 0); $this->db->data_seek($this->res_meta, 0);
// Looks up thumbnails.
$row['thumbnails'] = [];
while ($thumbs = $this->db->fetch_assoc($this->res_thumbs))
{
if ($thumbs['id_asset'] != $row['id_asset'])
continue;
$row['thumbnails'][$thumbs['selector']] = $thumbs['filename'];
}
// Reset internal pointer for next asset.
$this->db->data_seek($this->res_thumbs, 0);
if ($this->return_format == 'object') if ($this->return_format == 'object')
return new Asset($row); return new Asset($row);
else else
@ -51,6 +66,7 @@ class AssetIterator extends Asset
{ {
$this->db->data_seek($this->res_assets, 0); $this->db->data_seek($this->res_assets, 0);
$this->db->data_seek($this->res_meta, 0); $this->db->data_seek($this->res_meta, 0);
$this->db->data_seek($this->res_thumbs, 0);
} }
public function clean() public function clean()
@ -135,7 +151,29 @@ class AssetIterator extends Asset
ORDER BY id_asset', ORDER BY id_asset',
$params); $params);
$iterator = new self($res_assets, $res_meta, $return_format); // Get a resource object for the asset thumbs.
$res_thumbs = $db->query('
SELECT id_asset, filename,
CONCAT(
width,
{string:x},
height,
IF(mode != {string:empty}, CONCAT({string:_}, mode), {string:empty})
) AS selector
FROM assets_thumbs
WHERE id_asset IN(
SELECT id_asset
FROM assets AS a
WHERE ' . $where . '
)
ORDER BY id_asset',
$params + [
'empty' => '',
'x' => 'x',
'_' => '_',
]);
$iterator = new self($res_assets, $res_meta, $res_thumbs, $return_format);
// Returning total count, too? // Returning total count, too?
if ($return_count) if ($return_count)

View File

@ -44,6 +44,12 @@ class Dispatcher
{ {
return new ViewPhotoAlbum(); return new ViewPhotoAlbum();
} }
// Asynchronously generating thumbnails?
elseif (preg_match('~^/thumbnail/(?<id>\d+)/(?<width>\d+)x(?<height>\d+)(?:_(?<mode>c(t|b|s|)))?/?~', $_SERVER['PATH_INFO'], $path))
{
$_GET = array_merge($_GET, $path);
return new GenerateThumbnail();
}
// Look for particular actions... // Look for particular actions...
elseif (preg_match('~^/(?<action>[a-z]+)(?:/page/(?<page>\d+))?/?~', $_SERVER['PATH_INFO'], $path) && isset($possibleActions[$path['action']])) elseif (preg_match('~^/(?<action>[a-z]+)(?:/page/(?<page>\d+))?/?~', $_SERVER['PATH_INFO'], $path) && isset($possibleActions[$path['action']]))
{ {

View File

@ -82,191 +82,12 @@ class Image extends Asset
* @param height: height of the thumbnail. * @param height: height of the thumbnail.
* @param crop: whether and how to crop original image to fit. [false|true|'top'|'center'|'bottom'] * @param crop: whether and how to crop original image to fit. [false|true|'top'|'center'|'bottom']
* @param fit: whether to fit the image to given boundaries [true], or use them merely as an estimation [false]. * @param fit: whether to fit the image to given boundaries [true], or use them merely as an estimation [false].
* @param generate: whether or not to generate a thumbnail if no existing file was found.
*/ */
public function getThumbnailUrl($width, $height, $crop = true, $fit = true) public function getThumbnailUrl($width, $height, $crop = true, $fit = true, $generate = false)
{ {
// First, assert the image's dimensions are properly known in the database. $thumbnail = new Thumbnail($this);
if (!isset($this->image_height, $this->image_width)) return $thumbnail->getUrl($width, $height, $crop, $fit, $generate);
throw new UnexpectedValueException('Image width or height is undefined -- inconsistent database?');
// Inferring width or height?
if (!$height)
$height = ceil($width / $this->image_width * $this->image_height);
elseif (!$width)
$width = ceil($height / $this->image_height * $this->image_width);
// Inferring the height from the original image's ratio?
if (!$fit)
$height = floor($width / ($this->image_width / $this->image_height));
// Assert we have both, now...
if (empty($width) || empty($height))
throw new InvalidArgumentException('Expecting at least either width or height as argument.');
// If we're cropping, verify we're in the right mode.
if ($crop)
{
// If the original image's aspect ratio is much wider, take a slice instead.
if ($this->image_width / $this->image_height > $width / $height)
$crop = 'slice';
// We won't be cropping if the thumbnail is proportional to its original.
if (abs($width / $height - $this->image_width / $this->image_height) <= 0.05)
$crop = false;
}
// Do we have an exact crop boundary for these dimensions?
$crop_selector = "crop_{$width}x{$height}";
if (isset($this->meta[$crop_selector]))
$crop = 'exact';
// Now, do we need to suffix the filename?
if ($crop)
$suffix = '_c' . (is_string($crop) && $crop !== 'center' ? substr($crop, 0, 1) : '');
else
$suffix = '';
// Check whether we already resized this earlier.
$thumb_selector = "thumb_{$width}x{$height}{$suffix}";
if (isset($this->meta[$thumb_selector]) && file_exists(THUMBSDIR . '/' . $this->subdir . '/' . $this->meta[$thumb_selector]))
return THUMBSURL . '/' . $this->subdir . '/' . $this->meta[$thumb_selector];
// Do we have a custom thumbnail on file?
$custom_selector = "custom_{$width}x{$height}";
if (isset($this->meta[$custom_selector]))
{
if (file_exists(ASSETSDIR . '/' . $this->subdir . '/' . $this->meta[$custom_selector]))
{
// Copy the custom thumbail to the general thumbnail directory.
copy(ASSETSDIR . '/' . $this->subdir . '/' . $this->meta[$custom_selector],
THUMBSDIR . '/' . $this->subdir . '/' . $this->meta[$custom_selector]);
// Let's remember this for future reference.
$this->meta[$thumb_selector] = $this->meta[$custom_selector];
$this->save();
return THUMBSURL . '/' . $this->subdir . '/' . $this->meta[$custom_selector];
}
else
throw new UnexpectedValueException('Custom thumbnail expected, but missing in file system!');
}
// Let's try some arcane stuff...
try
{
if (!class_exists('Imagick'))
throw new Exception("The PHP module 'imagick' appears to be disabled. Please enable it to use image resampling functions.");
$thumb = new Imagick(ASSETSDIR . '/' . $this->subdir . '/' . $this->filename);
// The image might have some orientation set through EXIF. Let's apply this first.
self::applyRotation($thumb);
// Just resizing? Easy peasy.
if (!$crop)
$thumb->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1);
// Cropping in the center?
elseif ($crop === true || $crop === 'center')
$thumb->cropThumbnailImage($width, $height);
// Exact cropping? We can do that.
elseif ($crop === 'exact')
{
list($crop_width, $crop_height, $crop_x_pos, $crop_y_pos) = explode(',', $this->meta[$crop_selector]);
$thumb->cropImage($crop_width, $crop_height, $crop_x_pos, $crop_y_pos);
$thumb->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1);
}
// Advanced cropping? Fun!
else
{
$size = $thumb->getImageGeometry();
// Taking a horizontal slice from the top or bottom of the original image?
if ($crop === 'top' || $crop === 'bottom')
{
$crop_width = $size['width'];
$crop_height = floor($size['width'] / $width * $height);
$target_x = 0;
$target_y = $crop === 'top' ? 0 : $size['height'] - $crop_height;
}
// Otherwise, we're taking a vertical slice from the centre.
else
{
$crop_width = floor($size['height'] / $height * $width);
$crop_height = $size['height'];
$target_x = floor(($size['width'] - $crop_width) / 2);
$target_y = 0;
}
$thumb->cropImage($crop_width, $crop_height, $target_x, $target_y);
$thumb->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1);
}
// What sort of image is this? Fall back to PNG if we must.
switch ($thumb->getImageFormat())
{
case 'JPEG':
$ext = 'jpg';
$thumb->setImageCompressionQuality(60);
break;
case 'GIF':
$ext = 'gif';
break;
case 'PNG':
default:
$thumb->setFormat('PNG');
$ext = 'png';
break;
}
// So, how do we name this?
$thumbfilename = substr($this->filename, 0, strrpos($this->filename, '.')) . "_{$width}x{$height}{$suffix}.$ext";
// Ensure the thumbnail subdirectory exists.
if (!is_dir(THUMBSDIR . '/' . $this->subdir))
mkdir(THUMBSDIR . '/' . $this->subdir, 0755, true);
// Save it in a public spot.
$thumb->writeImage(THUMBSDIR . '/' . $this->subdir . '/' . $thumbfilename);
$thumb->clear();
$thumb->destroy();
}
// Blast! Curse your sudden but inevitable betrayal!
catch (ImagickException $e)
{
throw new Exception('ImageMagick error occurred while generating thumbnail. Output: ' . $e->getMessage());
}
// Let's remember this for future reference.
$this->meta[$thumb_selector] = $thumbfilename;
$this->save();
// Ah yes, you wanted a URL, didn't you...
return THUMBSURL . '/' . $this->subdir . '/' . $this->meta[$thumb_selector];
}
private static function applyRotation(Imagick $image)
{
switch ($image->getImageOrientation())
{
// Clockwise rotation
case Imagick::ORIENTATION_RIGHTTOP:
$image->rotateImage("#000", 90);
break;
// Counter-clockwise rotation
case Imagick::ORIENTATION_LEFTBOTTOM:
$image->rotateImage("#000", 270);
break;
// Upside down?
case Imagick::ORIENTATION_BOTTOMRIGHT:
$image->rotateImage("#000", 180);
}
// Having rotated the image, make sure the EXIF data is set properly.
$image->setImageOrientation(Imagick::ORIENTATION_TOPLEFT);
} }
public function bestColor() public function bestColor()
@ -299,6 +120,11 @@ class Image extends Asset
return $this->meta['best_color_label']; return $this->meta['best_color_label'];
} }
public function getId()
{
return $this->id_asset;
}
public function width() public function width()
{ {
return $this->image_width; return $this->image_width;
@ -309,37 +135,45 @@ class Image extends Asset
return $this->image_height; return $this->image_height;
} }
public function ratio()
{
return $this->image_width / $this->image_height;
}
public function isPanorama() public function isPanorama()
{ {
return $this->image_width / $this->image_height > 2; return $this->ratio() >= 2;
} }
public function isPortrait() public function isPortrait()
{ {
return $this->image_width / $this->image_height < 1; return $this->ratio() < 1;
} }
public function isLandscape() public function isLandscape()
{ {
$ratio = $this->image_width / $this->image_height; $ratio = $this->ratio();
return $ratio >= 1 && $ratio <= 2; return $ratio >= 1 && $ratio <= 2;
} }
public function getThumbnails()
{
return $this->thumbnails;
}
public function removeAllThumbnails() public function removeAllThumbnails()
{ {
foreach ($this->meta as $key => $value) foreach ($this->thumbnails as $key => $value)
{ {
if (substr($key, 0, 6) !== 'thumb_')
continue;
$thumb_path = THUMBSDIR . '/' . $this->subdir . '/' . $value; $thumb_path = THUMBSDIR . '/' . $this->subdir . '/' . $value;
if (is_file($thumb_path)) if (is_file($thumb_path))
unlink($thumb_path); unlink($thumb_path);
unset($this->meta[$key]);
} }
$this->saveMetaData(); return Registry::get('db')->query('
DELETE FROM assets_thumbs
WHERE id_asset = {int:id_asset}',
['id_asset' => $this->id_asset]);
} }
public function replaceThumbnail($descriptor, $tmp_file) public function replaceThumbnail($descriptor, $tmp_file)
@ -347,7 +181,7 @@ class Image extends Asset
if (!is_file($tmp_file)) if (!is_file($tmp_file))
return -1; return -1;
if (!isset($this->meta[$descriptor])) if (!isset($this->thumbnails[$descriptor]))
return -2; return -2;
$image = new Imagick($tmp_file); $image = new Imagick($tmp_file);
@ -355,12 +189,12 @@ class Image extends Asset
unset($image); unset($image);
// Check whether dimensions match. // Check whether dimensions match.
$test_descriptor = 'thumb_' . $d['width'] . 'x' . $d['height']; $test_descriptor = $d['width'] . 'x' . $d['height'];
if ($descriptor !== $test_descriptor && strpos($descriptor, $test_descriptor . '_') === false) if ($descriptor !== $test_descriptor && strpos($descriptor, $test_descriptor . '_') === false)
return -3; return -3;
// Save the custom thumbnail in the assets directory. // Save the custom thumbnail in the assets directory.
$destination = ASSETSDIR . '/' . $this->subdir . '/' . $this->meta[$descriptor]; $destination = ASSETSDIR . '/' . $this->subdir . '/' . $this->thumbnails[$descriptor];
if (file_exists($destination) && !is_writable($destination)) if (file_exists($destination) && !is_writable($destination))
return -4; return -4;
@ -368,7 +202,7 @@ class Image extends Asset
return -5; return -5;
// Copy it to the thumbnail directory, overwriting the automatically generated one, too. // Copy it to the thumbnail directory, overwriting the automatically generated one, too.
$destination = THUMBSDIR . '/' . $this->subdir . '/' . $this->meta[$descriptor]; $destination = THUMBSDIR . '/' . $this->subdir . '/' . $this->thumbnails[$descriptor];
if (file_exists($destination) && !is_writable($destination)) if (file_exists($destination) && !is_writable($destination))
return -6; return -6;
@ -376,7 +210,7 @@ class Image extends Asset
return -7; return -7;
// A little bookkeeping // A little bookkeeping
$this->meta['custom_' . $d['width'] . 'x' . $d['height']] = $this->meta[$descriptor]; $this->meta['custom_' . $d['width'] . 'x' . $d['height']] = $this->thumbnails[$descriptor];
$this->saveMetaData(); $this->saveMetaData();
return 0; return 0;
} }

344
models/Thumbnail.php Normal file
View File

@ -0,0 +1,344 @@
<?php
/*****************************************************************************
* Thumbnail.php
* Contains key class Thumbnail.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
*****************************************************************************/
class Thumbnail
{
private $image;
private $thumbnails;
private $properly_initialised;
private $width;
private $height;
private $crop_mode;
const CROP_MODE_NONE = 0;
const CROP_MODE_BOUNDARY = 1;
const CROP_MODE_CUSTOM_FILE = 2;
const CROP_MODE_SLICE_TOP = 3;
const CROP_MODE_SLICE_CENTRE = 4;
const CROP_MODE_SLICE_BOTTOM = 5;
public function __construct($image)
{
$this->image = $image;
$this->image_meta = $image->getMeta();
$this->thumbnails = $image->getThumbnails();
}
/**
* @param width: width of the thumbnail.
* @param height: height of the thumbnail.
* @param crop: whether and how to crop original image to fit. [false|true|'top'|'center'|'bottom']
* @param fit: whether to fit the image to given boundaries [true], or use them merely as an estimate [false].
* @param generate: whether or not to generate a thumbnail if no existing file was found.
*/
public function getUrl($width, $height, $crop = true, $fit = true, $generate = false)
{
$this->init($width, $height, $crop, $fit);
// Check whether we've already resized this earlier.
$thumb_selector = $this->width . 'x' . $this->height . $this->filename_suffix;
if (!empty($this->thumbnails[$thumb_selector]))
{
if (file_exists(THUMBSDIR . '/' . $this->image->getSubdir() . '/' . $this->thumbnails[$thumb_selector]))
return THUMBSURL . '/' . $this->image->getSubdir() . '/' . $this->thumbnails[$thumb_selector];
}
// Do we have a custom thumbnail on file?
$custom_selector = 'custom_' . $this->width . 'x' . $this->height;
if (isset($this->image_meta[$custom_selector]))
{
if (file_exists(ASSETSDIR . '/' . $this->image->getSubdir() . '/' . $this->image_meta[$custom_selector]))
{
// Copy the custom thumbail to the general thumbnail directory.
copy(ASSETSDIR . '/' . $this->image->getSubdir() . '/' . $this->image_meta[$custom_selector],
THUMBSDIR . '/' . $this->image->getSubdir() . '/' . $this->image_meta[$custom_selector]);
// Let's remember this for future reference.
$this->markAsGenerated($this->image_meta[$custom_selector]);
return THUMBSURL . '/' . $this->image->getSubdir() . '/' . $this->image_meta[$custom_selector];
}
else
throw new UnexpectedValueException('Custom thumbnail expected, but missing in file system!');
}
// Is this the right moment to generate a thumbnail, then?
if ($generate && array_key_exists($thumb_selector, $this->thumbnails))
{
return $this->generate();
}
// If not, queue it for generation at another time, and return a URL to generate it with.
elseif (!$generate)
{
$this->markAsQueued();
return BASEURL . '/thumbnail/' . $this->image->getId() . '/' . $this->width . 'x' . $this->height . $this->filename_suffix . '/';
}
// Still here..? What are you up to? ..Sneaking?
else
{
throw new Exception("Trying to generate a thumbnail for values that were not previously initialised by the system?\n" . print_r(func_get_args(), true));
}
}
/**
* @param width: width of the thumbnail.
* @param height: height of the thumbnail.
* @param crop: whether and how to crop original image to fit. [false|true|'top'|'center'|'bottom']
* @param fit: whether to fit the image to given boundaries [true], or use them merely as an estimate [false].
*/
private function init($width, $height, $crop = true, $fit = true)
{
$this->properly_initialised = false;
// First, assert the image's dimensions are properly known in the database.
if ($this->image->width() === null || $this->image->height() === null)
throw new UnexpectedValueException('Image width or height is undefined -- inconsistent database?');
$this->width = $width;
$this->height = $height;
// Inferring width or height?
if (!$this->height)
$this->height = ceil($this->width / $this->image->ratio());
elseif (!$this->width)
$this->width = ceil($this->height / $this->image->ratio());
// Inferring the height from the original image's ratio?
if (!$fit)
$this->height = floor($this->width / $this->image->ratio());
// Assert we have both, now...
if (empty($this->width) || empty($this->height))
throw new InvalidArgumentException('Expecting at least either width or height as argument.');
// If we're cropping, verify we're in the right mode.
if ($crop)
{
// Do we have an exact crop boundary set for these dimensions?
$crop_selector = 'crop_' . $this->width . 'x' . $this->height;
if (isset($this->image_meta[$crop_selector]))
$this->crop_mode = self::CROP_MODE_BOUNDARY;
// If the original image's aspect ratio is much wider, take a slice instead.
if ($this->image->ratio() > $this->ratio())
$this->crop_mode = self::CROP_MODE_SLICE_CENTRE;
// We won't be cropping if the thumbnail is proportional to its original.
elseif (abs($this->ratio() - $this->image->ratio()) <= 0.025)
$this->crop_mode = self::CROP_MODE_NONE;
// Slice from the top?
elseif ($crop === 'top' || $crop === 'ct')
$this->crop_mode = self::CROP_MODE_SLICE_TOP;
// Slice from the bottom?
elseif ($crop === 'bottom' || $crop === 'cb')
$this->crop_mode = self::CROP_MODE_SLICE_BOTTOM;
// Slice from the centre?
elseif ($crop === 'centre' || $crop === 'center' || $crop === 'cs' || $crop === true)
$this->crop_mode = self::CROP_MODE_SLICE_CENTRE;
// Unexpected value? Assume no crop.
else
$this->crop_mode = self::CROP_MODE_NONE;
}
else
$this->crop_mode = self::CROP_MODE_NONE;
// Now, do we need to suffix the filename?
if ($this->crop_mode !== self::CROP_MODE_NONE)
{
$this->filename_suffix = '_c';
if ($this->crop_mode === self::CROP_MODE_SLICE_TOP)
$this->filename_suffix .= 't';
elseif ($this->crop_mode === self::CROP_MODE_SLICE_CENTRE)
$this->filename_suffix .= 's';
elseif ($this->crop_mode === self::CROP_MODE_SLICE_BOTTOM)
$this->filename_suffix .= 'b';
}
else
$this->filename_suffix = '';
$this->properly_initialised = true;
}
private function generate()
{
if (!$this->properly_initialised)
throw new UnexpectedValueException('The thumbnail factory was not intialised before use!');
// Let's try some arcane stuff...
try
{
if (!class_exists('Imagick'))
throw new Exception("The PHP module 'imagick' appears to be disabled. Please enable it to use image resampling functions.");
$thumb = new Imagick(ASSETSDIR . '/' . $this->image->getSubdir() . '/' . $this->image->getFilename());
// The image might have some orientation set through EXIF. Let's apply this first.
self::applyRotation($thumb);
// Just resizing? Easy peasy.
if ($this->crop_mode === self::CROP_MODE_NONE)
$thumb->resizeImage($this->width, $this->height, Imagick::FILTER_LANCZOS, 1);
// // Cropping in the center?
elseif ($this->crop_mode === self::CROP_MODE_SLICE_CENTRE)
$thumb->cropThumbnailImage($this->width, $this->height);
// Exact cropping? We can do that.
elseif ($this->crop_mode === self::CROP_MODE_BOUNDARY)
{
$crop_selector = 'crop_' . $this->width . 'x' . $this->height;
list($crop_width, $crop_height, $crop_x_pos, $crop_y_pos) = explode(',', $this->image_meta[$crop_selector]);
$thumb->cropImage($crop_width, $crop_height, $crop_x_pos, $crop_y_pos);
$thumb->resizeImage($this->width, $this->height, Imagick::FILTER_LANCZOS, 1);
}
// Advanced cropping? Fun!
else
{
$size = $thumb->getImageGeometry();
// Taking a horizontal slice from the top or bottom of the original image?
if ($this->crop_mode === self::CROP_MODE_SLICE_TOP || $this->crop_mode === self::CROP_MODE_SLICE_BOTTOM)
{
$crop_width = $size['width'];
$crop_height = floor($size['width'] / $this->width * $this->height);
$target_x = 0;
$target_y = $this->crop_mode === self::CROP_MODE_SLICE_TOP ? 0 : $size['height'] - $crop_height;
}
// Otherwise, we're taking a vertical slice from the centre.
else
{
$crop_width = floor($size['height'] / $this->height * $this->width);
$crop_height = $size['height'];
$target_x = floor(($size['width'] - $crop_width) / 2);
$target_y = 0;
}
$thumb->cropImage($crop_width, $crop_height, $target_x, $target_y);
$thumb->resizeImage($this->width, $this->height, Imagick::FILTER_LANCZOS, 1);
}
// What sort of image is this? Fall back to PNG if we must.
switch ($thumb->getImageFormat())
{
case 'JPEG':
$ext = 'jpg';
break;
case 'GIF':
$ext = 'gif';
break;
case 'PNG':
default:
$thumb->setFormat('PNG');
$ext = 'png';
break;
}
// So, how do we name this?
$thumb_filename = substr($this->image->getFilename(), 0, strrpos($this->image->getFilename(), '.')) .
'_' . $this->width . 'x' . $this->height . $this->filename_suffix . '.' . $ext;
// Ensure the thumbnail subdirectory exists.
if (!is_dir(THUMBSDIR . '/' . $this->image->getSubdir()))
mkdir(THUMBSDIR . '/' . $this->image->getSubdir(), 0755, true);
// Save it in a public spot.
$thumb->writeImage(THUMBSDIR . '/' . $this->image->getSubdir() . '/' . $thumb_filename);
// Let's remember this for future reference...
$this->markAsGenerated($thumb_filename);
$thumb->clear();
$thumb->destroy();
// Finally, return the URL for the generated thumbnail image.
return THUMBSURL . '/' . $this->image->getSubdir() . '/' . $thumb_filename;
}
// Blast! Curse your sudden but inevitable betrayal!
catch (ImagickException $e)
{
throw new Exception('ImageMagick error occurred while generating thumbnail. Output: ' . $e->getMessage());
}
}
private static function applyRotation(Imagick $image)
{
switch ($image->getImageOrientation())
{
// Clockwise rotation
case Imagick::ORIENTATION_RIGHTTOP:
$image->rotateImage("#000", 90);
break;
// Counter-clockwise rotation
case Imagick::ORIENTATION_LEFTBOTTOM:
$image->rotateImage("#000", 270);
break;
// Upside down?
case Imagick::ORIENTATION_BOTTOMRIGHT:
$image->rotateImage("#000", 180);
}
// Having rotated the image, make sure the EXIF data is set properly.
$image->setImageOrientation(Imagick::ORIENTATION_TOPLEFT);
}
private function ratio()
{
return $this->width / $this->height;
}
private function updateDb($filename)
{
if (!$this->properly_initialised)
throw new UnexpectedValueException('The thumbnail factory was not intialised before use!');
$mode = !empty($this->filename_suffix) ? substr($this->filename_suffix, 1) : '';
$success = Registry::get('db')->insert('replace', 'assets_thumbs', [
'id_asset' => 'int',
'width' => 'int',
'height' => 'int',
'mode' => 'string-3',
'filename' => 'string-255',
], [
'id_asset' => $this->image->getId(),
'width' => $this->width,
'height' => $this->height,
'mode' => $mode,
'filename' => $filename,
]);
if ($success)
{
$thumb_selector = $this->width . 'x' . $this->height . $this->filename_suffix;
$this->thumbnails[$thumb_selector] = $filename !== 'NULL' ? $filename : '';
}
return $success;
}
private function markAsQueued()
{
$this->updateDb('NULL');
}
private function markAsGenerated($filename)
{
$this->updateDb($filename);
}
}

View File

@ -279,7 +279,7 @@ class EditAssetForm extends SubTemplate
echo ' crop'; echo ' crop';
} }
elseif ($thumb['custom_image']) elseif ($thumb['custom_image'])
echo ' (custom)'; echo ', custom';
echo ') echo ')
</option>'; </option>';