From 22c64f6c6938520f166b487373810a52727a9d57 Mon Sep 17 00:00:00 2001 From: Tam Date: Tue, 18 Feb 2020 12:17:00 +0000 Subject: [PATCH] The entire plugin --- .editorconfig | 16 ++ .gitattributes | 8 + .gitignore | 35 +++- CHANGELOG.md | 2 + README.md | 397 ++++++++++++++++++++++++++++++++++++++++++- composer.json | 31 ++++ src/Service.php | 287 +++++++++++++++++++++++++++++++ src/Settings.php | 43 +++++ src/Thumbro.php | 85 +++++++++ src/ThumbroImage.php | 54 ++++++ src/Variable.php | 175 +++++++++++++++++++ 11 files changed, 1127 insertions(+), 6 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 CHANGELOG.md create mode 100644 composer.json create mode 100644 src/Service.php create mode 100644 src/Settings.php create mode 100644 src/Thumbro.php create mode 100644 src/ThumbroImage.php create mode 100644 src/Variable.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1c595af --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +indent_style = tab +indent_size = 4 +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yml, json}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c6304ad --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +README.md export-ignore +CHANGELOG.md export-ignore +/resources export-ignore +.gitignore export-ignore +.gitattributes export-ignore +/.idea export-ignore +.editorconfig export-ignore +/docs export-ignore diff --git a/.gitignore b/.gitignore index a67d42b..00104a8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,31 @@ -composer.phar -/vendor/ +### OSX +.DS_Store +.AppleDouble +.LSOverride -# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control -# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file -# composer.lock +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +vendor +node_modules +hot +mix-manifest.json +/.idea diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e9dc766 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +### 1.0.0 - 2020-02-18 +- Initial Release diff --git a/README.md b/README.md index b0f2389..6bd9f06 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,397 @@ -# thumbro +# Thumbro Craft CMS image transformations powered by Thumbor + +## Configuration + +Config options should be added to your `thumbro.php` file in your config +folder. + +When setting up Thumbor, we recommend using the +`thumbor.loaders.file_loader_http_fallback` loader to account for files with and +without public urls (see +[Image loader](https://thumbor.readthedocs.io/en/latest/image_loader.html)). + +### domain [string] + +The domain (including port) where Thumbor can be accessed. + +### securityKey [string] + +The security key defined in your Thumbor config file. See +[Security](https://thumbor.readthedocs.io/en/latest/security.html). + +### autoFocalPoint [bool] + +*Default: `true`* + +Will automatically set the focal point of the image to the one defined in Craft. +(See [Position](#position-stringarray)) + +### autoCompress [bool] + +*Default: `true`* + +Will automatically compress the resulting image. + +### imageUrlModifier [callable|null] + +*Default: `null`* + +Gives you a change to modify the public URL of the image, before it is passed to +Thumbor. This is especially useful when working in Docker: + +```php +return [ + 'imageUrlModifier' => function ($url) { + // Replace the site's URL with the internal docker container name (web) + return str_replace(getenv('DEFAULT_SITE_URL'), 'web', $url); + }, +]; +``` + +### thumborUrlModifier [callable|null] + +*Default: `null`* + +Gives you a change to modify the URL thumbor returns when the plugin tries to +access the file contents in the `picture` method. Works the same as +`imageUrlModifier`. + +## Usage + +### `img(Asset $asset, array $transforms, array $config = [])` + +Transform a single image to one or more sizes. The config array will be merged +into every transform. + +```twig +{% set img = craft.assets.one() %} +{% set transformedImage = craft.thumbro.img(img, { + width: 100, + height: 100, +}, { + position: img.getFocalPoint(), +}) %} +``` + +```twig +{% set img = craft.assets.one() %} +{% set transformedImage = craft.thumbro.img(img, [ + { width: 100, height: 100, format: 'webp' }, + { width: 100, height: 100, format: 'jpg' }, +]) %} +``` + +### `picture(Asset $asset, array $transform, array $config = [])` + +Will output the image in a `picture` tag for dynamic lazy loading with a low +quality placeholder. The function signature matches that of the `img` method, +with the exception that `$transform` only accepts a single transform object, not +an array of multiple objects. + +This function will inject the necessary JavaScript to dynamically load the +image. You can disable this by passing the `noJs` option to the `$config` and +setting it to `true`. + +```twig +{{ craft.thumbro.picture(img, { width: 1000, height: 1000 }, { aboveFold: true }) }} +``` + +#### Config Options + +In addition to the regular transform config options, `picture` has two unique +options: + +**`noJs`** [bool] + +Will disable the automatic injection of the lazy loading JavaScript. + +**`aboveFold`** [bool] + +Will convert the lazy loading to eager loading when true, meaning the image will +instantly load in most cases. + +#### CSS + +You will need to include some CSS to ensure the picture tag appears correctly. +Below is what we recommend you use: + +```css +picture { + position: relative; + z-index: 1; + display: block; + width: 100%; + + font-size: 0; +} + +picture > img { + position: absolute; + top: 0; + left: 0; + + width: 100%; + height: auto; + vertical-align: middle; +} + +picture > img:first-child:last-child { + position: relative; +} + +picture > img:first-child { + z-index: -1; + height: 100%; + object-fit: cover; +} + +picture > img:last-child { + z-index: 0; + transition: opacity 0.15s ease; +} +``` + +## Transform Options + +### format [string] + +*Default: `null`* +*Allowed values: `null`, `'jpg'`, `'png'`, `'gif'`, `'webp'`* + +Format of the created image. If unset (default) it will be the same format as +the source image. + +### trim [bool|string] + +*Default: `false`* + +Removing surrounding space in images can be done using the trim option. + +Unless specified trim assumes the top-left pixel color and no tolerance (more +on tolerance below). + +If you need to specify the orientation from where to get the pixel color, just +set the value to `top-left` for the top-left pixel color or `bottom-right` for +the bottom-right pixel color. + +Trim also supports color tolerance. The euclidian distance between the colors of +the reference pixel and the surrounding pixels is used. If the distance is +within the tolerance they’ll get trimmed. For a RGB image the tolerance would +be within the range 0-442. + +### mode [string] + +*Default: `crop`* +*Allowed values: `crop`, `fit`, `stretch`* + +**`crop`:** Crops the image to the given size, scaling the image to fill as much +as possible of the size. +**`fit`:** Scales the image to fit within the given size while maintaining the +aspect ratio of the original image. +**`stretch`:** Scales the image to the given size, stretching it if the aspect +ratio is different from the original. + +### width [int|string] + +Width of the image, in pixels. + +If you omit this value or set it to 0, Thumbor will determine that dimension as +to be proportional to the original image. + +Set to `orig` to use the size of the original image. i.e. If the original image +has a width of 400px, the new image will also have that width. + +A negative value will flip the image. Setting `'-0'` (in quotes) will flip the +image without resizing it. + +### height [int|string] + +Height of the image, in pixels. + +If you omit this value or set it to 0, Thumbor will determine that dimension as +to be proportional to the original image. + +Set to `orig` to use the size of the original image. i.e. If the original image +has a height of 400px, the new image will also have that height. + +A negative value will flip the image. Setting `'-0'` (in quotes) will flip the +image without resizing it. + +### ratio [int|float] + +An aspect ratio (width/height) that is used to calculate the missing size, if +width or height is not provided. + +```twig +{ ratio: 16/9 } +``` + +### effects [array] + +A keyed array of effects to perform on the image, where the key is the name of +the effect and the value contains the arguments for the effect. See +[Effects](#effects) below. + +### position [string|array] + +The position around which to crop. Can be a string containing % locations +`20% 65%`, or named locations `middle-right`, or an array of x/y decimal +locations `['x' => 0.2, 'y' => 0.65]`. + +### smart [bool] + +Will use Thumbor's smart focal point detection when cropping the image. + +### upscale [bool] + +*Default: `false`* + +Will upscale the image to fit the given size if true. + +## Effects + +### autojpg [bool] + +Will convert non-transparent PNG images to JPG when `true`. + +### backgroundColor [string] + +Sets the background layer to the specified color. This is specifically useful +when converting transparent images (PNG) to JPEG. + +The value should be the color name (like in HTML) or hexadecimal rgb expression +without the “#” character (see [Web colors](https://en.wikipedia.org/wiki/Web_colors) +for example). If color is `auto`, a color will be smartly chosen (based on the +image pixels) to be the filling color. + +### blur [int|array] + +Applies a gaussian blur to the image. + +Accepts a single integer as the `radius` of the blur, or an array matching +`[radius, sigma]`. + +- `radius` is used in the gaussian function to generate a matrix, maximum value +is 150. The bigger the radius more blurred will be the image. +- `sigma` is optional and defaults to the same value as the `radius`. Sigma used +in the gaussian function. + +### brightness [int] + +Increases or decreases the image brightness. + +Accepts an integer from -100 to 100. The amount (in %) to change the image +brightness. Positive numbers make the image brighter and negative numbers make +the image darker. + +### contrast [int] + +Increases or decreases the image contrast. + +Accepts an integer from -100 to 100. The amount (in %) to change the image +contrast. Positive numbers increase contrast and negative numbers decrease +contrast. + +### convolution [array] + +Runs a convolution matrix (or kernel) on the image. See +[Kernel (image processing)](https://en.wikipedia.org/wiki/Kernel_(image_processing)) +for details on the process. Edge pixels are always extended outside the image +area. + +Accepts an array of arguments `[matrix_items, number_of_columns, should_normalize]`. + +- `matrix_items` Semicolon separated matrix items +- `number_of_columns` Number of columns in the matrix +- `should_normalize` Whether or not we should divide each matrix item by the sum +of all items + +Example: + +``` +-1 -1 -1 +-1 8 -1 +-1 -1 -1 +``` + +```twig +{{ craft.thumbro.img(img, { + effects: { + convolution: ['-1;-1;-1;-1;8;-1;-1;-1;-1', 3, false], + }, +}) }} +``` + +### equalize [bool] + +Equalizes the color distribution in the image. + +### fill [string|array] + +Will return an image sized exactly as requested wherever is its ratio by filling +with chosen color the missing parts. Only works when `mode` is set to `fit`. + +Accepts a string `color` or an array `[color, fill_transparent]`. + +- `color` Accepts: + - The color name (like in HTML) or hexadecimal RGB expression without + the “#” character (see [Web colors](https://en.wikipedia.org/wiki/Web_colors) + for example). + - If color is “transparent” and the image format, supports transparency the + filling color is transparent. + - If color is “auto”, a color is smartly chosen (based on the image pixels) as + the filling color. + - If color is “blur”, the missing parts are filled with blurred original image. +- `fill_transparent` Specify whether transparent areas of the image should be +filled or not. Accepted values are either true, false, 1 or 0. This argument is +optional and the default value is false. + +### grayscale [bool] + +Changes the image to grayscale. + +### maxBytes [int] + +Automatically degrades the quality of the image until the image is under the +specified amount of bytes. + +### noise [int] + +Adds noise to the image. Accepts a value between 0 - 100 as the amount (in %) of +noise to add to the image. + +### proportion [float] + +Applies proportion to height and width passed for cropping. Accepts a float +between 0 - 1 as the percentage of the proportion (i.e. 0.5 would scale the +image down to 50% after cropping). + +### quality [int] + +Set the overall quality of a JPEG image (does nothing for PNGs or GIFs). Expects +a value between 0 - 100 as the quality level (in %). + +### rgb [array] + +Will change the amount of color in each of the three channels. + +Accepts an array of RGB values `[r, g, b]` ranging from -100 to 100. + +### rotate [int] + +Rotate the given image according to the angle value passed. Should be set to an +angle between 0 - 359. Numbers greater or equal than 360 will be transformed to +a equivalent angle between 0 and 359. + +### sharpen [array] + +Enhances apparent sharpness of the image. It’s heavily based on Marco Rossini’s +excellent Wavelet sharpen GIMP plugin. + +Accepts an array with the following values: +`[sharpen_amount, sharpen_radius, luminance_only]` + +- `sharpen_amount` - Sharpen amount. Typical values are between 0.0 and 10.0. +- `sharpen_radius` - Sharpen radius. Typical values are between 0.0 and 2.0. +- `luminance_only` - Sharpen only luminance channel. Values can be `true` or `false` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..2868ec4 --- /dev/null +++ b/composer.json @@ -0,0 +1,31 @@ +{ + "name": "ether/thumbro", + "description": "Craft CMS image transformations powered by Thumbor", + "version": "1.0.0", + "type": "craft-plugin", + "license": "proprietary", + "minimum-stability": "dev", + "require": { + "craftcms/cms": "^3.2.1" + }, + "autoload": { + "psr-4": { + "ether\\thumbro\\": "src/" + } + }, + "support": { + "email": "help@ethercreative.co.uk", + "docs": "https://docs.ethercreative.co.uk/thumbro", + "source": "https://github.com/ethercreative/thumbro", + "issues": "https://github.com/ethercreative/thumbro/issues" + }, + "extra": { + "handle": "thumbro", + "name": "Thumbro", + "developer": "Ether Creative", + "developerUrl": "https://ethercreative.co.uk", + + "class": "ether\\thumbro\\Thumbro" + } +} + diff --git a/src/Service.php b/src/Service.php new file mode 100644 index 0000000..ba2b01d --- /dev/null +++ b/src/Service.php @@ -0,0 +1,287 @@ +_settings = Thumbro::getInstance()->getSettings(); + $this->_restEndpoint = $this->_join([$this->_settings->domain, 'image']); + } + + /** + * @param Asset $image + * @param array $transforms + * + * @return array|null + */ + public function transform ($image, $transforms) + { + if ($image->getExtension() === 'svg') + { + Craft::error('Thumbor does not support SVG images.'); + return null; + } + + $transformedImages = []; + + foreach ($transforms as $transform) + $transformedImages[] = $this->_getTransformedImage( + $image, + $transform + ); + + return $transformedImages; + } + + // Private + // ========================================================================= + + /** + * @param Asset $image + * @param array $transform + * + * @return ThumbroImage + */ + private function _getTransformedImage ($image, $transform) + { + /** @var Settings $settings */ + $settings = Thumbro::getInstance()->getSettings(); + + $parts = []; + $filters = []; + + // Format + + if ($format = @$transform['format']) + { + if ($format === 'jpg') + $format = 'jpeg'; + + $filters['format'] = $format; + } + + // Trim + + if ($trim = @$transform['trim']) + { + if ($trim === true) $parts[] = 'trim'; + else $parts[] = 'trim:' . $trim; + } + + // Mode + + if ($mode = @$transform['mode']) + { + switch ($mode) + { + default: + case 'crop': + // Do nothing, this is Thumbor's default + break; + case 'fit': + $parts[] = 'fit-in'; + break; + case 'stretch': + $filters['stretch'] = null; + break; + } + } + + // Size + + $size = []; + $ratio = @$transform['ratio']; + $width = @$transform['width']; + $height = @$transform['height']; + + if ($width) + $size[] = $width; + else + $size[] = $ratio && $height ? $height * $ratio : ''; + + if ($height) + $size[] = $height; + else + $size[] = $ratio && $width ? $width * $ratio : ''; + + if (!empty(array_filter($size))) + $parts[] = implode('x', $size); + + // Position + + if ($position = @$transform['position']) + { + if (is_array($position)) + { + $x = $position['x']; + $y = $position['y']; + } + else + { + list($x, $y) = explode(' ', $position); + } + + $x = (int) ($image->getWidth() * (floatval($x) / 100)); + $y = (int) ($image->getHeight() * (floatval($y) / 100)); + + if ($x < 1) $x++; + if ($y < 1) $y++; + + $filters['focal'] = $x . 'x' . $y . ':' . ($x - 1) . 'x' . ($y - 1); + } + + // Smart focal point detection + + if (@$transform['smart']) + $parts[] = 'smart'; + + // Upscale + + if (!@$transform['upscale']) + $filters['no_upscale'] = ''; + + // Effects + + if ($effects = @$transform['effects']) + { + foreach ($effects as $name => $args) + { + $value = $this->_parseFilterArgs($name, $args); + + if ($value === null) + continue; + + $filters[$this->_parseFilterName($name)] = $value; + } + } + + // Filters + + if (!empty($filters)) + { + $f = ['filters']; + + foreach ($filters as $name => $args) + $f[] = $name . '(' . $args . ')'; + + $parts[] = implode(':', $f); + } + + // Saving + + $parts[] = $this->_getImageUrl($image); + + $url = [$settings->domain]; + + if ($settings->securityKey) + $url[] = $this->_generateKey($parts, $settings->securityKey); + else + $url[] = 'unsafe'; + + $url = $this->_join(array_merge($url, $parts)); + + return new ThumbroImage([ + 'url' => $url, + 'width' => $size[0], + 'height' => $size[1], + ]); + } + + // Helpers + // ========================================================================= + + private function _generateKey ($parts, $key) + { + $url = $this->_join($parts); + $hash = hash_hmac('sha1', $url, $key, true); + + return strtr( + base64_encode($hash), + '/+', '_-' + ); + } + + private function _join ($parts = []) + { + return Thumbro::join($parts); + } + + private function _parseFilterName ($name) + { + $name = preg_replace('/(?<=\\w)(?=[A-Z])/','_$1', $name); + $name = strtolower($name); + + return $name; + } + + private function _parseFilterArgs ($name, $args) + { + if (in_array($name, ['equalize', 'grayscale'])) + { + if ($args) return ''; + return null; + } + + return $this->_parseFilterArgsValue($args); + } + + private function _parseFilterArgsValue ($args) + { + if (is_bool($args)) + return $args ? 'True' : 'False'; + + if (is_array($args)) + return implode(',', array_map([$this, '_parseFilterArgsValue'], $args)); + + return $args; + } + + private function _getImageUrl (Asset $asset) + { + $settings = Thumbro::getInstance()->getSettings(); + $url = $asset->getUrl(); + + if (is_callable($settings->imageUrlModifier)) + $url = call_user_func($settings->imageUrlModifier, $url); + + return rawurlencode( + preg_replace( + '#^https?://#', + '', + strtok($url, '#') + ) + ); + } + +} diff --git a/src/Settings.php b/src/Settings.php new file mode 100644 index 0000000..a2716c6 --- /dev/null +++ b/src/Settings.php @@ -0,0 +1,43 @@ +setComponents([ + 'service' => Service::class, + ]); + + Event::on( + CraftVariable::class, + CraftVariable::EVENT_INIT, + [$this, 'onRegisterVariable'] + ); + } + + // Settings + // ========================================================================= + + protected function createSettingsModel () + { + return new Settings(); + } + + /** + * @return bool|Model|Settings + */ + public function getSettings () + { + return parent::getSettings(); + } + + // Events + // ========================================================================= + + /** + * @param Event $event + * + * @throws InvalidConfigException + */ + public function onRegisterVariable (Event $event) + { + /** @var CraftVariable $variable */ + $variable = $event->sender; + $variable->set('thumbro', Variable::class); + } + + // Helpers + // ========================================================================= + + public static function join ($parts) + { + $parts = array_map(function ($part) { + return rtrim($part, '/'); + }, $parts); + + return implode('/', $parts); + } + +} diff --git a/src/ThumbroImage.php b/src/ThumbroImage.php new file mode 100644 index 0000000..ea225dd --- /dev/null +++ b/src/ThumbroImage.php @@ -0,0 +1,54 @@ +url; + } + + public function getUrl () + { + return $this->url; + } + + public function getWidth () + { + return $this->width; + } + + public function getHeight () + { + return $this->height; + } + +} diff --git a/src/Variable.php b/src/Variable.php new file mode 100644 index 0000000..f714ffe --- /dev/null +++ b/src/Variable.php @@ -0,0 +1,175 @@ +getSettings(); + $single = false; + + if (ArrayHelper::isAssociative($transforms, true)) + { + $single = true; + $transforms = [$transforms]; + } + + if ($settings->autoFocalPoint && !array_key_exists('position', $config)) + $config['position'] = $asset->getFocalPoint(); + + if ($settings->autoCompress && !array_key_exists('auto', $config)) + $config['auto'] = 'format,compress'; + + foreach ($transforms as $i => $transform) + $transforms[$i] = array_merge($transform, $config); + + $transformed = $thumbro->service->transform($asset, $transforms); + return $single ? array_first($transformed) : $transformed; + } + + /** + * @param Asset $asset + * @param array $transform + * @param array $config + * + * @return Markup + * @throws Exception + */ + public function picture (Asset $asset, array $transform, array $config = []) + { + if (!ArrayHelper::isAssociative($transform, true)) + throw new Exception('The `picture` method only supports a single transform!'); + + if (!array_key_exists('width', $transform) && !array_key_exists('height', $transform)) + throw new Exception('You must specify width and height in the transform!'); + + $aboveFold = false; + if (array_key_exists('aboveFold', $config)) + { + $aboveFold = $config['aboveFold']; + unset($config['aboveFold']); + } + + $noJS = false; + if (array_key_exists('noJs', $config)) + { + $noJS = (bool) $config['noJs']; + unset($config['noJs']); + } + + $w = $transform['width']; + $h = $transform['height']; + + $transforms = [ + ['width' => 20, 'height' => round($h * 20 / $w)], + $transform, // Base size + [ 'width' => round($w * 1.5), 'height' => round($h * 1.5) ], // 1.5x + [ 'width' => round($w * 2), 'height' => round($h * 2) ], // 2x + ]; + + $imgs = $this->img($asset, $transforms, $config); + + $placeholder = file_get_contents($this->_getThumborUrl($imgs[0]->url)); + $placeholder = 'data:' . $asset->mimeType . ';base64,' . base64_encode($placeholder); + + $padding = $h / $w * 100; + + if ($aboveFold) { + $markup = << + $asset->title +
+ $asset->title + +HTML; + } else { + if (!$noJS) + { + Craft::$app->getView()->registerJs( + '!function(){for(var t=function(t){var e=t.lastElementChild;e.setAttribute("src",e.dataset.src),e.setAttribute("srcset",e.dataset.srcset),e.removeAttribute("data-src"),e.removeAttribute("data-srcset")},e=document.querySelectorAll("picture"),r=new IntersectionObserver(function(e,r){var n=!0,i=!1,o=void 0;try{for(var s,c=e[Symbol.iterator]();!(n=(s=c.next()).done);n=!0){var u=s.value;if(!(u.intersectionRatio<=0)){var a=u.target;r.unobserve(a),t(a)}}}catch(t){i=!0,o=t}finally{try{n||null==c.return||c.return()}finally{if(i)throw o}}},{rootMargin:"20px 0px",threshold:.01}),n=function(n,i){var o=e[n],s=document.createElement("div"),c=o.querySelector("noscript");if(!c)return"continue";s.innerHTML=c.textContent;var u=s.firstElementChild;u.style.opacity=0,u.setAttribute("data-src",u.getAttribute("src")),u.setAttribute("data-srcset",u.getAttribute("srcset")),u.removeAttribute("src"),u.removeAttribute("srcset"),u.addEventListener("load",function(){u.removeAttribute("style")}),o.appendChild(u),!function(t){return t.getBoundingClientRect().top<=(window.innerHeight||document.documentElement.clientHeight)}(o)?r.observe(o):setTimeout(function(){return t(o)},150)},i=0,o=e.length;i + $asset->title +
+ + +HTML; + } + + return new Markup($markup, 'utf8'); + } + + // Helpers + // ========================================================================= + + private function _getThumborUrl ($url) + { + $settings = Thumbro::getInstance()->getSettings(); + + if (is_callable($settings->thumborUrlModifier)) + $url = call_user_func($settings->thumborUrlModifier, $url); + + return $url; + } + +}