From 1def1484cbb84f580a3adc94b9e6fcedabf05898 Mon Sep 17 00:00:00 2001 From: Aaron van Geffen Date: Wed, 20 Dec 2017 14:51:23 +0100 Subject: [PATCH] Backport asynchronous thumbnail generation from Kabuki. --- controllers/EditAsset.php | 28 ++- controllers/GenerateThumbnail.php | 27 +++ migrate_thumbs.php | 53 +++++ models/Asset.php | 48 ++++- models/AssetIterator.php | 42 +++- models/Dispatcher.php | 6 + models/Image.php | 230 +++----------------- models/Thumbnail.php | 344 ++++++++++++++++++++++++++++++ templates/EditAssetForm.php | 2 +- 9 files changed, 566 insertions(+), 214 deletions(-) create mode 100644 controllers/GenerateThumbnail.php create mode 100644 migrate_thumbs.php create mode 100644 models/Thumbnail.php diff --git a/controllers/EditAsset.php b/controllers/EditAsset.php index 177b829..68a7990 100644 --- a/controllers/EditAsset.php +++ b/controllers/EditAsset.php @@ -76,11 +76,11 @@ class EditAsset extends HTMLController $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(); - if (($replace_result = $image->replaceThumbnail($_POST['replacement_target'], $_FILES['replacement']['tmp_name'])) !== 0) - throw new Exception('Could not replace thumbnail \'' . $_POST['replacement_target'] . '\' with the uploaded file. Error code: ' . $replace_result); + if (($replace_result = $image->replaceThumbnail($match[1], $_FILES['replacement']['tmp_name'])) !== 0) + 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) { - $path = $asset->getPath(); + if (!$asset->isImage()) + return []; + + $image = $asset->getImage(); + $subdir = $image->getSubdir(); + $metadata = $image->getMeta(); + $thumb_selectors = $image->getThumbnails(); + $thumbs = []; - $metadata = $asset->getMeta(); - foreach ($metadata as $key => $meta) + foreach ($thumb_selectors as $selector => $filename) { - if (!preg_match('~^thumb_(?\d+)x(?\d+)(?_c(?[best]?))?$~', $key, $thumb)) + if (!preg_match('~^(?\d+)x(?\d+)(?_c(?[best]?))?$~', $selector, $thumb)) continue; $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_region' => $has_crop_boundary ? $metadata['crop_' . $thumb['width'] . 'x' . $thumb['height']] : null, 'custom_image' => $has_custom_image, - 'filename' => $meta, - 'full_path' => THUMBSDIR . '/' . $path . '/' . $meta, - 'url' => THUMBSURL . '/' . $path . '/' . $meta, - 'status' => file_exists(THUMBSDIR . '/' . $path . '/' . $meta), + 'filename' => $filename, + 'full_path' => THUMBSDIR . '/' . $subdir . '/' . $filename, + 'url' => THUMBSURL . '/' . $subdir . '/' . $filename, + 'status' => file_exists(THUMBSDIR . '/' . $subdir . '/' . $filename), ]; } diff --git a/controllers/GenerateThumbnail.php b/controllers/GenerateThumbnail.php new file mode 100644 index 0000000..739a3e8 --- /dev/null +++ b/controllers/GenerateThumbnail.php @@ -0,0 +1,27 @@ +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; + } + } +} diff --git a/migrate_thumbs.php b/migrate_thumbs.php new file mode 100644 index 0000000..b1009b2 --- /dev/null +++ b/migrate_thumbs.php @@ -0,0 +1,53 @@ +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_(?\d+)x(?\d+)(?:_(?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"; + diff --git a/models/Asset.php b/models/Asset.php index 4ad4434..5a6565f 100644 --- a/models/Asset.php +++ b/models/Asset.php @@ -18,8 +18,10 @@ class Asset protected $image_height; protected $date_captured; protected $priority; + protected $meta; protected $tags; + protected $thumbnails; protected function __construct(array $data) { @@ -58,8 +60,10 @@ class Asset public static function byRow(array $row, $return_format = 'object') { + $db = Registry::get('db'); + // Supplement with metadata. - $row['meta'] = Registry::get('db')->queryPair(' + $row['meta'] = $db->queryPair(' SELECT variable, value FROM assets_meta WHERE id_asset = {int:id_asset}', @@ -67,6 +71,24 @@ class 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; } @@ -91,6 +113,7 @@ class Asset { $assets[$asset['id_asset']] = $asset; $assets[$asset['id_asset']]['meta'] = []; + $assets[$asset['id_asset']]['thumbnails'] = []; } $metas = $db->queryRows(' @@ -105,6 +128,27 @@ class Asset foreach ($metas as $meta) $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') return $assets; else @@ -270,7 +314,7 @@ class Asset return ASSETSDIR . '/' . $this->subdir . '/' . $this->filename; } - public function getPath() + public function getSubdir() { return $this->subdir; } diff --git a/models/AssetIterator.php b/models/AssetIterator.php index 0a1d7fd..b05b59d 100644 --- a/models/AssetIterator.php +++ b/models/AssetIterator.php @@ -11,12 +11,14 @@ class AssetIterator extends Asset private $return_format; private $res_assets; 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->res_assets = $res_assets; $this->res_meta = $res_meta; + $this->res_thumbs = $res_thumbs; $this->return_format = $return_format; } @@ -41,6 +43,19 @@ class AssetIterator extends Asset // Reset internal pointer for next asset. $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') return new Asset($row); else @@ -51,6 +66,7 @@ class AssetIterator extends Asset { $this->db->data_seek($this->res_assets, 0); $this->db->data_seek($this->res_meta, 0); + $this->db->data_seek($this->res_thumbs, 0); } public function clean() @@ -135,7 +151,29 @@ class AssetIterator extends Asset ORDER BY id_asset', $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? if ($return_count) diff --git a/models/Dispatcher.php b/models/Dispatcher.php index 0350bd1..f9cc930 100644 --- a/models/Dispatcher.php +++ b/models/Dispatcher.php @@ -44,6 +44,12 @@ class Dispatcher { return new ViewPhotoAlbum(); } + // Asynchronously generating thumbnails? + elseif (preg_match('~^/thumbnail/(?\d+)/(?\d+)x(?\d+)(?:_(?c(t|b|s|)))?/?~', $_SERVER['PATH_INFO'], $path)) + { + $_GET = array_merge($_GET, $path); + return new GenerateThumbnail(); + } // Look for particular actions... elseif (preg_match('~^/(?[a-z]+)(?:/page/(?\d+))?/?~', $_SERVER['PATH_INFO'], $path) && isset($possibleActions[$path['action']])) { diff --git a/models/Image.php b/models/Image.php index d562c2f..981f542 100644 --- a/models/Image.php +++ b/models/Image.php @@ -82,191 +82,12 @@ class Image extends Asset * @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 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. - if (!isset($this->image_height, $this->image_width)) - 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); + $thumbnail = new Thumbnail($this); + return $thumbnail->getUrl($width, $height, $crop, $fit, $generate); } public function bestColor() @@ -299,6 +120,11 @@ class Image extends Asset return $this->meta['best_color_label']; } + public function getId() + { + return $this->id_asset; + } + public function width() { return $this->image_width; @@ -309,37 +135,45 @@ class Image extends Asset return $this->image_height; } + public function ratio() + { + return $this->image_width / $this->image_height; + } + public function isPanorama() { - return $this->image_width / $this->image_height > 2; + return $this->ratio() >= 2; } public function isPortrait() { - return $this->image_width / $this->image_height < 1; + return $this->ratio() < 1; } public function isLandscape() { - $ratio = $this->image_width / $this->image_height; + $ratio = $this->ratio(); return $ratio >= 1 && $ratio <= 2; } + public function getThumbnails() + { + return $this->thumbnails; + } + 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; if (is_file($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) @@ -347,7 +181,7 @@ class Image extends Asset if (!is_file($tmp_file)) return -1; - if (!isset($this->meta[$descriptor])) + if (!isset($this->thumbnails[$descriptor])) return -2; $image = new Imagick($tmp_file); @@ -355,12 +189,12 @@ class Image extends Asset unset($image); // 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) return -3; // 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)) return -4; @@ -368,7 +202,7 @@ class Image extends Asset return -5; // 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)) return -6; @@ -376,7 +210,7 @@ class Image extends Asset return -7; // 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(); return 0; } diff --git a/models/Thumbnail.php b/models/Thumbnail.php new file mode 100644 index 0000000..5dca12c --- /dev/null +++ b/models/Thumbnail.php @@ -0,0 +1,344 @@ +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); + } +} diff --git a/templates/EditAssetForm.php b/templates/EditAssetForm.php index b80fa1a..bca57e5 100644 --- a/templates/EditAssetForm.php +++ b/templates/EditAssetForm.php @@ -279,7 +279,7 @@ class EditAssetForm extends SubTemplate echo ' crop'; } elseif ($thumb['custom_image']) - echo ' (custom)'; + echo ', custom'; echo ') ';