├── LICENSE ├── composer.json ├── config └── thumbnail.php └── src ├── Console └── Commands │ └── Purge.php ├── Facades └── Thumbnail.php ├── Filter ├── Blur.php ├── FilterInterface.php ├── Greyscale.php └── Resize.php ├── Http ├── Controller │ └── ImageController.php └── routes.php ├── Smartcrop.php ├── Source.php ├── Thumbnail.php └── ThumbnailServiceProvider.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Roland Starke 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rolandstarke/laravel-thumbnail", 3 | "description": "Laravel Thumbnail generator", 4 | "keywords": ["laravel", "image", "thumbnail"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Roland Starke" 9 | } 10 | ], 11 | "require": { 12 | "php": ">=7.1.3", 13 | "intervention/image": "^2.0" 14 | }, 15 | "require-dev": { 16 | "orchestra/testbench": "^8.0" 17 | }, 18 | "extra": { 19 | "laravel": { 20 | "providers": [ 21 | "Rolandstarke\\Thumbnail\\ThumbnailServiceProvider" 22 | ], 23 | "aliases": { 24 | "Thumbnail": "Rolandstarke\\Thumbnail\\Facades\\Thumbnail" 25 | } 26 | } 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Rolandstarke\\Thumbnail\\": "src/" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Rolandstarke\\Thumbnail\\Tests\\": "tests/" 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /config/thumbnail.php: -------------------------------------------------------------------------------- 1 | sha1(env('APP_KEY', '')), 10 | 11 | 12 | /** 13 | * Memory limit for generating the thumbnails. 14 | * e.g. 256M, 512M 1024M 2048M 15 | */ 16 | 'memory_limit' => '1024M', 17 | 18 | 19 | /** 20 | * Load the original images from the following sources. 21 | * 22 | * Hint: When using `Thumbnail::src(...)->url()` You will get shorter urls 23 | * if you add the subdir you are loading the image from. 24 | * E.g. add `storage_path('useruploads')` instead of `storage_path()`. 25 | */ 26 | 'allowedSources' => [ 27 | 'a' => app_path(), 28 | 'r' => resource_path(), 29 | 'p' => public_path(), 30 | 's' => storage_path(), 31 | 'http' => 'http://', //allow images to be loaded from http 32 | 'https' => 'https://', 33 | 'ld' => ['disk' => 'local', 'path' => ''], //allow images to be loaded from `Storage::disk('local')` 34 | 'pd' => ['disk' => 'public', 'path' => ''], 35 | ], 36 | 37 | 38 | /** 39 | * Thumbnail settings are grouped in presets. 40 | * So that you can have different settings for e.g. profile and album pictures. 41 | */ 42 | 'presets' => [ 43 | 'default' => [ 44 | /** 45 | * Store the generated images here. 46 | * 47 | * Note: Every preset needs an unique path. 48 | */ 49 | 'destination' => ['disk' => 'public', 'path' => 'thumbnails/default/'], 50 | ], 51 | 52 | //add more presets e.g. "avatar". 53 | 'avatar' => [ 54 | 'destination' => ['disk' => 'public', 'path' => 'thumbnails/avatar/'], 55 | /** 56 | * add default params for this preset 57 | */ 58 | 'smartcrop' => '64x64', 59 | ], 60 | ], 61 | 62 | 63 | /** 64 | * Available filters to modify the images. 65 | */ 66 | 'filters' => [ 67 | Rolandstarke\Thumbnail\Filter\Resize::class, 68 | Rolandstarke\Thumbnail\Filter\Blur::class, 69 | Rolandstarke\Thumbnail\Filter\Greyscale::class, 70 | ], 71 | ]; 72 | -------------------------------------------------------------------------------- /src/Console/Commands/Purge.php: -------------------------------------------------------------------------------- 1 | $preset) { 42 | if ( 43 | is_array($preset) 44 | && isset($preset['destination']) 45 | && is_string($preset['destination']['path']) 46 | && is_string($preset['destination']['disk']) 47 | ) { 48 | $disk = $preset['destination']['disk']; 49 | $path = $preset['destination']['path']; 50 | 51 | if (in_array($path, ['', '/'], true) || empty($disk)) { 52 | if (!$this->confirm('Do you want to delete ' . $path . ' on disk ' . $disk . '?', false)) { 53 | continue; 54 | } else { 55 | $this->info('You can skip this confirmation if you do not use "/" inside the config `thumbnail.presets.' . $presetName . '.path`' ); 56 | } 57 | } 58 | 59 | $this->info('cleaning ' . $path . ' on disk ' . $disk); 60 | sleep(1); 61 | 62 | $directories = Storage::disk($disk)->directories($path); 63 | foreach ($directories as $directory) { 64 | Storage::disk($disk)->deleteDirectory($directory); 65 | $this->output->write('.', false); 66 | } 67 | $this->output->write('done' . PHP_EOL); 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Facades/Thumbnail.php: -------------------------------------------------------------------------------- 1 | 0) { 12 | $image->blur($params['blur']); 13 | } 14 | return $image; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Filter/FilterInterface.php: -------------------------------------------------------------------------------- 1 | greyscale(); 13 | } 14 | return $image; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Filter/Resize.php: -------------------------------------------------------------------------------- 1 | $width, 17 | 'height' => $height, 18 | ]); 19 | $res = $smartcrop->analyse(); 20 | $topCrop = $res['topCrop']; 21 | if ($topCrop) { 22 | $image->crop(min($topCrop['width'], $width), min($topCrop['height'], $height), $topCrop['x'], $topCrop['y']); 23 | } else { 24 | $image->crop($width, $height); 25 | } 26 | } 27 | 28 | if (isset($params['crop'])) { 29 | list($width, $height) = array_map('intval', explode('x', $params['crop'])); 30 | $image->fit($width, $height); 31 | } 32 | 33 | if (isset($params['widen'])) { 34 | $image->widen((int)$params['widen'], function ($constraint) { 35 | $constraint->upsize(); 36 | }); 37 | } 38 | 39 | if (isset($params['heighten'])) { 40 | $image->heighten((int)$params['heighten'], function ($constraint) { 41 | $constraint->upsize(); 42 | }); 43 | } 44 | 45 | return $image; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Http/Controller/ImageController.php: -------------------------------------------------------------------------------- 1 | setParamsFromUrl($request->query()); 17 | if (!$thumbnail->isValidRequest($file)) { 18 | throw new \Exception('Invalid Request'); 19 | }; 20 | } catch (\Exception $err) { 21 | return response('Invalid Request', 400); 22 | } 23 | 24 | return $thumbnail->save()->response(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Http/routes.php: -------------------------------------------------------------------------------- 1 | $preset) { 9 | if (is_array($preset) && isset($preset['destination'])) { 10 | $url = Storage::disk($preset['destination']['disk'])->url($preset['destination']['path'] . '{file}'); 11 | $route = parse_url($url, PHP_URL_PATH); 12 | 13 | Route::get($route, ImageController::class . '@index') 14 | ->where('file', '.+') 15 | ->defaults('preset', $presetName); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Smartcrop.php: -------------------------------------------------------------------------------- 1 | 0, 38 | 'height' => 0, 39 | 'aspect' => 0, 40 | 'cropWidth' => 0, 41 | 'cropHeight' => 0, 42 | 'detailWeight' => 0.2, 43 | 'skinColor' => [ 44 | 0.78, 45 | 0.57, 46 | 0.44 47 | ], 48 | 'skinBias' => 0.01, 49 | 'skinBrightnessMin' => 0.2, 50 | 'skinBrightnessMax' => 1.0, 51 | 'skinThreshold' => 0.8, 52 | 'skinWeight' => 1.8, 53 | 'saturationBrightnessMin' => 0.05, 54 | 'saturationBrightnessMax' => 0.9, 55 | 'saturationThreshold' => 0.4, 56 | 'saturationBias' => 0.2, 57 | 'saturationWeight' => 0.3, 58 | 'scoreDownSample' => 8, 59 | 'step' => 8, 60 | 'scaleStep' => 0.1, 61 | 'minScale' => 1.0, 62 | 'maxScale' => 1.0, 63 | 'edgeRadius' => 0.4, 64 | 'edgeWeight' => -20.0, 65 | 'outsideImportance' => -0.5, 66 | 'boostWeight' => 100.0, 67 | 'ruleOfThirds' => true, 68 | 'preScale' => true, 69 | 'imageOperations' => null, 70 | 'canvasFactory' => 'defaultCanvasFactory', 71 | 'debug' => false 72 | ]; 73 | public $options = []; 74 | public $scale; 75 | public $preScale; 76 | public $image; 77 | public $od = []; 78 | public $sample = []; 79 | public $h = 0; 80 | public $w = 0; 81 | public function __construct(Image $image, array $options = []) 82 | { 83 | $this->options = array_merge($this->defaultOptions, $options); 84 | 85 | if ($this->options['aspect']) { 86 | $this->options['width'] = $this->options['aspect']; 87 | $this->options['height'] = 1; 88 | } 89 | 90 | $this->scale = 1; 91 | $this->preScale = 1; 92 | $this->image = $image; 93 | $this->canvasImageScale(); 94 | } 95 | /** 96 | * Scale the image before smartcrop analyse 97 | */ 98 | protected function canvasImageScale() 99 | { 100 | $imageOriginalWidth = $this->image->getWidth(); 101 | $imageOriginalHeight = $this->image->getHeight(); 102 | $scale = min($imageOriginalWidth / $this->options['width'], $imageOriginalHeight / $this->options['height']); 103 | 104 | $this->options['cropWidth'] = ceil($this->options['width'] * $scale); 105 | $this->options['cropHeight'] = ceil($this->options['height'] * $scale); 106 | 107 | 108 | $this->options['minScale'] = min($this->options['maxScale'], max(1 / $scale, $this->options['minScale'])); 109 | 110 | if ($this->options['preScale'] !== false) { 111 | $this->preScale = 1 / $scale / $this->options['minScale']; 112 | if ($this->preScale < 1) { 113 | $this->image->resize(ceil($imageOriginalWidth * $this->preScale), ceil($imageOriginalHeight * $this->preScale)); 114 | $this->options['cropWidth'] = ceil($this->options['cropWidth'] * $this->preScale); 115 | $this->options['cropHeight'] = ceil($this->options['cropHeight'] * $this->preScale); 116 | } else { 117 | $this->preScale = 1; 118 | } 119 | } 120 | } 121 | /** 122 | * Analyse the image, find out the optimal crop scheme 123 | * 124 | * @return array 125 | */ 126 | public function analyse() 127 | { 128 | $result = []; 129 | $w = $this->w = $this->image->getWidth(); 130 | $h = $this->h =$this->image->getHeight(); 131 | 132 | $this->od = new \SplFixedArray($h * $w * 3); 133 | $this->sample = new \SplFixedArray($h * $w); 134 | for ($y = 0; $y < $h; $y++) { 135 | for ($x = 0; $x < $w; $x++) { 136 | $p = ($y) * $this->w * 3 + ($x) * 3; 137 | $rgb = $this->image->pickColor($x, $y); 138 | $this->od[$p + 1] = $this->edgeDetect($x, $y, $w, $h); 139 | $this->od[$p] = $this->skinDetect($rgb[0], $rgb[1], $rgb[2], $this->sample($x, $y)); 140 | $this->od[$p + 2] = $this->saturationDetect($rgb[0], $rgb[1], $rgb[2], $this->sample($x, $y)); 141 | } 142 | } 143 | 144 | $scoreOutput = $this->downSample($this->options['scoreDownSample']); 145 | $topScore = -INF; 146 | $topCrop = null; 147 | $crops = $this->generateCrops(); 148 | 149 | foreach ($crops as &$crop) { 150 | $crop['score'] = $this->score($scoreOutput, $crop); 151 | if ($crop['score']['total'] > $topScore) { 152 | $topCrop = $crop; 153 | $topScore = $crop['score']['total']; 154 | } 155 | } 156 | 157 | $result['topCrop'] = $topCrop; 158 | 159 | if ($this->options['debug'] && $topCrop) { 160 | $result['crops'] = $crops; 161 | $result['debugOutput'] = $scoreOutput; 162 | $result['debugOptions'] = $this->options; 163 | $result['debugTopCrop'] = array_merge([], $result['topCrop']); 164 | } 165 | 166 | return $result; 167 | } 168 | /** 169 | * @param int $factor 170 | * @return \SplFixedArray 171 | */ 172 | protected function downSample($factor) 173 | { 174 | $width = floor($this->w / $factor); 175 | $height = floor($this->h / $factor); 176 | 177 | $ifactor2 = 1 / ($factor * $factor); 178 | 179 | $data = new \SplFixedArray($height * $width * 4); 180 | for ($y = 0; $y < $height; $y++) { 181 | for ($x = 0; $x < $width; $x++) { 182 | $r = 0; 183 | $g = 0; 184 | $b = 0; 185 | $a = 0; 186 | 187 | $mr = 0; 188 | $mg = 0; 189 | $mb = 0; 190 | 191 | for ($v = 0; $v < $factor; $v++) { 192 | for ($u = 0; $u < $factor; $u++) { 193 | $p = ($y * $factor + $v) * $this->w * 3 + ($x * $factor + $u) * 3; 194 | $pR = $this->od[$p]; 195 | $pG = $this->od[$p + 1]; 196 | $pB = $this->od[$p + 2]; 197 | $pA = 0; 198 | $r += $pR; 199 | $g += $pG; 200 | $b += $pB; 201 | $a += $pA; 202 | $mr = max($mr, $pR); 203 | $mg = max($mg, $pG); 204 | $mb = max($mb, $pB); 205 | } 206 | } 207 | 208 | $p = ($y) * $width * 4 + ($x) * 4; 209 | $data[$p] = round($r * $ifactor2 * 0.5 + $mr * 0.5, 0, PHP_ROUND_HALF_EVEN); 210 | $data[$p + 1] = round($g * $ifactor2 * 0.7 + $mg * 0.3, 0, PHP_ROUND_HALF_EVEN); 211 | $data[$p + 2] = round($b * $ifactor2, 0, PHP_ROUND_HALF_EVEN); 212 | $data[$p + 3] = round($a * $ifactor2, 0, PHP_ROUND_HALF_EVEN); 213 | } 214 | } 215 | 216 | return $data; 217 | } 218 | /** 219 | * @param integer $x 220 | * @param integer $y 221 | * @param integer $w 222 | * @param integer $h 223 | * @return integer 224 | */ 225 | protected function edgeDetect($x, $y, $w, $h) 226 | { 227 | if ($x === 0 || $x >= $w - 1 || $y === 0 || $y >= $h - 1) { 228 | $lightness = $this->sample($x, $y); 229 | } else { 230 | $leftLightness = $this->sample($x - 1, $y); 231 | $centerLightness = $this->sample($x, $y); 232 | $rightLightness = $this->sample($x + 1, $y); 233 | $topLightness = $this->sample($x, $y - 1); 234 | $bottomLightness = $this->sample($x, $y + 1); 235 | $lightness = $centerLightness * 4 - $leftLightness - $rightLightness - $topLightness - $bottomLightness; 236 | } 237 | return round($lightness, 0, PHP_ROUND_HALF_EVEN); 238 | } 239 | /** 240 | * @param integer $r 241 | * @param integer $g 242 | * @param integer $b 243 | * @param float $lightness 244 | * @return integer 245 | */ 246 | protected function skinDetect($r, $g, $b, $lightness) 247 | { 248 | $lightness = $lightness / 255; 249 | $skin = $this->skinColor($r, $g, $b); 250 | $isSkinColor = $skin > $this->options['skinThreshold']; 251 | $isSkinBrightness = $lightness > $this->options['skinBrightnessMin'] && $lightness <= $this->options['skinBrightnessMax']; 252 | if ($isSkinColor && $isSkinBrightness) { 253 | return round(($skin - $this->options['skinThreshold']) * (255 / (1 - $this->options['skinThreshold'])), 0, PHP_ROUND_HALF_EVEN); 254 | } else { 255 | return 0; 256 | } 257 | } 258 | /** 259 | * @param integer $r 260 | * @param integer $g 261 | * @param integer $b 262 | * @param integer $lightness 263 | * @return integer 264 | */ 265 | protected function saturationDetect($r, $g, $b, $lightness) 266 | { 267 | $lightness = $lightness / 255; 268 | $sat = $this->saturation($r, $g, $b); 269 | $acceptableSaturation = $sat > $this->options['saturationThreshold']; 270 | $acceptableLightness = $lightness >= $this->options['saturationBrightnessMin'] && $lightness <= $this->options['saturationBrightnessMax']; 271 | if ($acceptableLightness && $acceptableSaturation) { 272 | return round(($sat - $this->options['saturationThreshold']) * (255 / (1 - $this->options['saturationThreshold'])), 0, PHP_ROUND_HALF_EVEN); 273 | } else { 274 | return 0; 275 | } 276 | } 277 | /** 278 | * Generate crop schemes 279 | * 280 | * @return array 281 | */ 282 | protected function generateCrops() 283 | { 284 | $w = $this->image->getWidth(); 285 | $h = $this->image->getHeight(); 286 | $results = []; 287 | $minDimension = min($w, $h); 288 | $cropWidth = empty($this->options['cropWidth']) ? $minDimension : $this->options['cropWidth']; 289 | $cropHeight = empty($this->options['cropHeight']) ? $minDimension : $this->options['cropHeight']; 290 | for ($scale = $this->options['maxScale']; $scale >= $this->options['minScale']; $scale -= $this->options['scaleStep']) { 291 | for ($y = 0; $y + $cropHeight * $scale <= $h; $y += $this->options['step']) { 292 | for ($x = 0; $x + $cropWidth * $scale <= $w; $x += $this->options['step']) { 293 | $results[] = [ 294 | 'x' => $x, 295 | 'y' => $y, 296 | 'width' => ceil($cropWidth * $scale), 297 | 'height' => ceil($cropHeight * $scale), 298 | ]; 299 | } 300 | } 301 | } 302 | 303 | return $results; 304 | } 305 | /** 306 | * Score a crop scheme 307 | * 308 | * @param array $output 309 | * @param array $crop 310 | * @return array 311 | */ 312 | protected function score($output, $crop) 313 | { 314 | $result = [ 315 | 'detail' => 0, 316 | 'saturation' => 0, 317 | 'skin' => 0, 318 | 'boost' => 0, 319 | 'total' => 0 320 | ]; 321 | 322 | $downSample = $this->options['scoreDownSample']; 323 | $invDownSample = 1 / $downSample; 324 | $outputHeightDownSample = floor($this->h / $downSample) * $downSample; 325 | $outputWidthDownSample = floor($this->w / $downSample) * $downSample; 326 | $outputWidth = floor($this->w / $downSample); 327 | 328 | for ($y = 0; $y < $outputHeightDownSample; $y += $downSample) { 329 | for ($x = 0; $x < $outputWidthDownSample; $x += $downSample) { 330 | $i = $this->importance($crop, $x, $y); 331 | $p = floor($y / $downSample) * $outputWidth * 4 + floor($x / $downSample) * 4; 332 | $detail = $output[$p + 1] / 255; 333 | 334 | $result['skin'] += $output[$p] / 255 * ($detail + $this->options['skinBias']) * $i; 335 | $result['saturation'] += $output[$p + 2] / 255 * ($detail + $this->options['saturationBias']) * $i; 336 | $result['detail'] = $p; 337 | } 338 | } 339 | 340 | $result['total'] = ($result['detail'] * $this->options['detailWeight'] + $result['skin'] * $this->options['skinWeight'] + $result['saturation'] * $this->options['saturationWeight'] + $result['boost'] * $this->options['boostWeight']) / ($crop['width'] * $crop['height']); 341 | 342 | return $result; 343 | } 344 | /** 345 | * @param array $crop 346 | * @param integer $x 347 | * @param integer $y 348 | * @return float|number 349 | */ 350 | protected function importance($crop, $x, $y) 351 | { 352 | if ($crop['x'] > $x || $x >= $crop['x'] + $crop['width'] || $crop['y'] > $y || $y > $crop['y'] + $crop['height']) { 353 | return $this->options['outsideImportance']; 354 | } 355 | $x = ($x - $crop['x']) / $crop['width']; 356 | $y = ($y - $crop['y']) / $crop['height']; 357 | $px = abs(0.5 - $x) * 2; 358 | $py = abs(0.5 - $y) * 2; 359 | $dx = max($px - 1.0 + $this->options['edgeRadius'], 0); 360 | $dy = max($py - 1.0 + $this->options['edgeRadius'], 0); 361 | $d = ($dx * $dx + $dy * $dy) * $this->options['edgeWeight']; 362 | $s = 1.41 - sqrt($px * $px + $py * $py); 363 | if ($this->options['ruleOfThirds']) { 364 | $s += (max(0, $s + $d + 0.5) * 1.2) * ($this->thirds($px) + $this->thirds($py)); 365 | } 366 | return $s + $d; 367 | } 368 | /** 369 | * @param integer $x 370 | * @return float 371 | */ 372 | protected function thirds($x) 373 | { 374 | // Use fmod for floating-point modulus to avoid implicit float->int conversion 375 | $x = fmod(($x - (1 / 3) + 1.0), 2.0); 376 | $x = ($x * 0.5 - 0.5) * 16; 377 | return max(1.0 - $x * $x, 0.0); 378 | } 379 | /** 380 | * @param integer $x 381 | * @param integer $y 382 | * @return float 383 | */ 384 | protected function sample($x, $y) 385 | { 386 | $p = $y * $this->w + $x; 387 | if (isset($this->sample[$p])) { 388 | return $this->sample[$p]; 389 | } else { 390 | $rgb = $this->image->pickColor($x, $y); 391 | $this->sample[$p] = $this->cie($rgb[0], $rgb[1], $rgb[2]); 392 | return $this->sample[$p]; 393 | } 394 | } 395 | 396 | /** 397 | * @param integer $r 398 | * @param integer $g 399 | * @param integer $b 400 | * @return float 401 | */ 402 | protected function cie($r, $g, $b) 403 | { 404 | return 0.5126 * $b + 0.7152 * $g + 0.0722 * $r; 405 | } 406 | /** 407 | * @param integer $r 408 | * @param integer $g 409 | * @param integer $b 410 | * @return float 411 | */ 412 | protected function skinColor($r, $g, $b) 413 | { 414 | $mag = sqrt($r * $r + $g * $g + $b * $b); 415 | $mag = $mag > 0 ? $mag : 1; 416 | $rd = ($r / $mag - $this->options['skinColor'][0]); 417 | $gd = ($g / $mag - $this->options['skinColor'][1]); 418 | $bd = ($b / $mag - $this->options['skinColor'][2]); 419 | $d = sqrt($rd * $rd + $gd * $gd + $bd * $bd); 420 | return 1 - $d; 421 | } 422 | /** 423 | * @param integer $r 424 | * @param integer $g 425 | * @param integer $b 426 | * @return float 427 | */ 428 | protected function saturation($r, $g, $b) 429 | { 430 | $maximum = max($r / 255, $g / 255, $b / 255); 431 | $minumum = min($r / 255, $g / 255, $b / 255); 432 | 433 | if ($maximum === $minumum) { 434 | return 0; 435 | } 436 | 437 | $l = ($maximum + $minumum) / 2; 438 | $d = ($maximum - $minumum); 439 | 440 | return $l > 0.5 ? $d / (2 - $maximum - $minumum) : $d / ($maximum + $minumum); 441 | } 442 | } 443 | -------------------------------------------------------------------------------- /src/Source.php: -------------------------------------------------------------------------------- 1 | allowedSources = $allowedSources; 21 | } 22 | 23 | /** 24 | * @throws Exception 25 | */ 26 | public function src(string $path, string $disk = null): self 27 | { 28 | $this->urlParams = $this->constructUrlParams($path, $disk); 29 | if (!$this->urlParams) { 30 | throw new Exception('Source is not allowed. Given path "' . $path . '"' . ($disk ? ' on disk "' . $disk . '"' : '')); 31 | } 32 | 33 | if (Str::contains($this->urlParams['p'], '..')) { 34 | throw new Exception('Source is not allowed. The Path can not contain "..". Given path "' . $path . '"'); 35 | } 36 | 37 | $this->path = $path; 38 | $this->disk = $disk; 39 | 40 | return $this; 41 | } 42 | 43 | protected function constructUrlParams(string $path, string $disk = null): ?array 44 | { 45 | $params = null; 46 | 47 | foreach ($this->allowedSources as $sourceKey => $allowedSource) { 48 | if ($disk) { 49 | if (Arr::get($allowedSource, 'disk') !== $disk) { 50 | continue; 51 | } 52 | $allowedPath = $allowedSource['path']; 53 | } else { 54 | if (is_array($allowedSource)) { 55 | continue; 56 | } 57 | $allowedPath = $allowedSource; 58 | } 59 | 60 | if (Str::startsWith($path, $allowedPath) || empty($allowedPath)) { 61 | $relativePath = substr($path, strlen($allowedPath)); 62 | if (!$params || strlen($params['p']) > strlen($relativePath)) { 63 | $params = ['p' => $relativePath, 's' => $sourceKey]; 64 | } 65 | } 66 | } 67 | 68 | return $params; 69 | } 70 | 71 | /** 72 | * @throws Exception 73 | */ 74 | public function setSrcFromUrlParams(array $urlParams): self 75 | { 76 | $path = ''; 77 | $disk = null; 78 | 79 | if (isset($this->allowedSources[$urlParams['s']])) { 80 | $source = $this->allowedSources[$urlParams['s']]; 81 | if (is_array($source)) { 82 | $disk = $source['disk']; 83 | $path = $source['path']; 84 | } else { 85 | $path = $source; 86 | } 87 | $path .= Arr::get($urlParams, 'p', ''); 88 | } 89 | 90 | $this->src($path, $disk); 91 | 92 | return $this; 93 | } 94 | 95 | 96 | public function getFormat(): string 97 | { 98 | if (preg_match('/\\.(\\w+)($|[?#])/i', $this->path, $matches)) { 99 | return strtolower($matches[1]); 100 | } 101 | return 'jpg'; 102 | } 103 | 104 | /** 105 | * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException|Exception 106 | */ 107 | public function getImage(): string 108 | { 109 | if ($this->disk) { 110 | return Storage::disk($this->disk)->get($this->path); 111 | } else { 112 | $path = $this->path; 113 | 114 | //if we got an url lets encode it 115 | if (Str::startsWith($this->path, ['http://', 'https://'])) { 116 | $path = preg_replace_callback('#://([^/]+)/([^?]+)#', function ($match) { 117 | return '://' . $match[1] . '/' . implode('/', array_map('rawurlencode', explode('/', $match[2]))); 118 | }, $path); 119 | } 120 | 121 | $content = file_get_contents($path); 122 | if ($content === false) { 123 | throw new Exception('Could not get file content for path "' . $path . '"'); 124 | } 125 | return $content; 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Thumbnail.php: -------------------------------------------------------------------------------- 1 | config = $config; 31 | } 32 | 33 | /** 34 | * @throws Exception 35 | */ 36 | public function preset(string $preset): self 37 | { 38 | if (!isset($this->config['presets'][$preset])) { 39 | throw new Exception('Preset "' . $preset . '" does not exist.'); 40 | } 41 | $this->preset = $preset; 42 | return $this; 43 | } 44 | 45 | /** 46 | * @throws Exception 47 | */ 48 | public function src(string $path, string $disk = null): self 49 | { 50 | $this->source = null; 51 | $source = new Source($this->config['allowedSources']); 52 | $source->src($path, $disk); 53 | $this->source = $source; 54 | return $this; 55 | } 56 | 57 | public function greyscale(): self 58 | { 59 | return $this->param('greyscale', '1'); 60 | } 61 | 62 | /** 63 | * coution: blur with large images takes long 64 | */ 65 | public function blur(int $amount = 1): self 66 | { 67 | return $this->param('blur', $amount); 68 | } 69 | 70 | public function smartcrop(int $width, int $height): self 71 | { 72 | return $this->param('smartcrop', $width . 'x' . $height); 73 | } 74 | 75 | public function crop(int $width, int $height): self 76 | { 77 | return $this->param('crop', $width . 'x' . $height); 78 | } 79 | 80 | public function widen(int $width): self 81 | { 82 | return $this->param('widen', $width); 83 | } 84 | 85 | public function heighten(int $height): self 86 | { 87 | return $this->param('heighten', $height); 88 | } 89 | 90 | /** 91 | * 92 | * @param string $format one of jpg, png, gif, webp 93 | * @param integer $quality 100-0 where 100 is best quality and 0 worst 94 | */ 95 | public function format(string $format, int $quality = null): self 96 | { 97 | return $this->param('format', $format)->param('quality', $quality); 98 | } 99 | 100 | public function param(string $name, $value): self 101 | { 102 | $this->params[$name] = $value; 103 | return $this; 104 | } 105 | 106 | public function url(bool $ensurePresence = false): string 107 | { 108 | if ($this->source === null) { 109 | throw new Exception('can not get thumbnail url, set source image first '); 110 | } 111 | 112 | $destination = $this->config['presets'][$this->preset]['destination']; 113 | $outputPath = $destination['path'] . $this->getOutputFilename(); 114 | 115 | if ($ensurePresence && !Storage::disk($destination['disk'])->exists($outputPath)) { 116 | $this->save(); 117 | } 118 | 119 | return Storage::disk($destination['disk']) 120 | ->url($outputPath . '?' . http_build_query($this->getUrlParams())); 121 | } 122 | 123 | public function response(bool $useExisting = true): \Symfony\Component\HttpFoundation\Response 124 | { 125 | return response($this->getRenderedImage($useExisting), 200, ['content-type' => $this->getContentType()]); 126 | } 127 | 128 | public function string(bool $useExisting = true): string 129 | { 130 | return $this->getRenderedImage($useExisting); 131 | } 132 | 133 | public function save(): self 134 | { 135 | $destination = $this->config['presets'][$this->preset]['destination']; 136 | 137 | Storage::disk($destination['disk'])->put( 138 | $destination['path'] . $this->getOutputFilename(), 139 | $this->getRenderedImage() 140 | ); 141 | 142 | return $this; 143 | } 144 | 145 | public function delete(): self 146 | { 147 | $destination = $this->config['presets'][$this->preset]['destination']; 148 | 149 | Storage::disk($destination['disk'])->delete( 150 | $destination['path'] . $this->getOutputFilename() 151 | ); 152 | 153 | return $this; 154 | } 155 | 156 | 157 | /** 158 | * @internal 159 | */ 160 | public function isValidRequest($filename): bool 161 | { 162 | return $this->source && $filename === $this->getOutputFilename(); 163 | } 164 | 165 | /** 166 | * @throws Exception 167 | * @internal 168 | */ 169 | public function setParamsFromUrl(array $urlParams): self 170 | { 171 | $this->source = null; 172 | $this->params = []; 173 | $source = new Source($this->config['allowedSources']); 174 | $source->setSrcFromUrlParams($urlParams); 175 | 176 | foreach ($source->urlParams as $key => $param) { 177 | unset($urlParams[$key]); 178 | } 179 | 180 | $this->params = $urlParams; 181 | $this->source = $source; 182 | return $this; 183 | } 184 | 185 | 186 | protected function getRenderedImage(bool $useExisting = false): string 187 | { 188 | if ($this->renderedImage === null) { 189 | 190 | if ($useExisting) { 191 | $destination = $this->config['presets'][$this->preset]['destination']; 192 | $outputPath = $destination['path'] . $this->getOutputFilename(); 193 | 194 | try { 195 | $this->renderedImage = Storage::disk($destination['disk'])->get($outputPath); 196 | } catch (Exception $exception) { 197 | $this->renderedImage = null; 198 | } 199 | 200 | if (!$this->renderedImage) { 201 | $this->save(); 202 | } 203 | } else { 204 | $this->render(); 205 | } 206 | } 207 | return $this->renderedImage; 208 | } 209 | 210 | protected function render(): self 211 | { 212 | if (!empty($this->config['memory_limit'])) { 213 | ini_set('memory_limit', $this->config['memory_limit']); 214 | } 215 | 216 | $params = array_merge( 217 | $this->config['presets'][$this->preset], 218 | $this->params 219 | ); 220 | 221 | $image = Image::make($this->source->getImage()); 222 | 223 | foreach (Arr::get($this->config, 'filters', []) as $filterClassName) { 224 | $filter = \Illuminate\Support\Facades\App::make($filterClassName); 225 | if ($filter instanceof FilterInterface) { 226 | $image = $filter->handle($image, $params); 227 | } else { 228 | throw new Exception('filter must be instanceof FilterInterface, given filter: "' . $filterClassName . '"'); 229 | } 230 | } 231 | 232 | $this->renderedImage = (string)$image->encode($this->getFormat(), Arr::get($params, 'quality')); 233 | return $this; 234 | } 235 | 236 | protected function getUrlParams(): array 237 | { 238 | $params = array_merge( 239 | $this->source->urlParams, 240 | $this->params 241 | ); 242 | 243 | //remove params from url that are the same as the preset to shorten it 244 | foreach ($this->config['presets'][$this->preset] as $param => $setting) { 245 | if (isset($params[$param]) && $params[$param] === $setting) { 246 | unset($params[$param]); 247 | } 248 | } 249 | 250 | //sort for better caching 251 | ksort($params); 252 | 253 | return $params; 254 | } 255 | 256 | protected function getOutputFilename(): string 257 | { 258 | $params = array_merge( 259 | $this->config['presets'][$this->preset], 260 | $this->source->urlParams, 261 | $this->params 262 | ); 263 | 264 | $params['format'] = $this->getFormat(); 265 | ksort($params); 266 | $salt = Arr::get($this->config, 'signing_key', ''); 267 | 268 | $filename = base_convert(md5(http_build_query($params) . $salt), 16, 36) . '.' . $this->getFormat(); 269 | $filename = substr_replace($filename, '/', 4, 0); 270 | $filename = substr_replace($filename, '/', 2, 0); 271 | 272 | return $filename; 273 | } 274 | 275 | protected function getFormat(): string 276 | { 277 | if (!empty($this->params['format'])) { 278 | return $this->params['format']; 279 | } else if (!empty($this->config['presets'][$this->preset]['format'])) { 280 | return $this->config['presets'][$this->preset]['format']; 281 | } else { 282 | return $this->source->getFormat(); 283 | } 284 | } 285 | 286 | protected function getContentType(): string 287 | { 288 | return Arr::get([ 289 | 'jpg' => 'image/jpeg', 290 | 'jpeg' => 'image/jpeg', 291 | 'png' => 'image/png', 292 | 'webp' => 'image/webp', 293 | 'ico' => 'image/x-icon', 294 | 'gif' => 'image/gif', 295 | 'tiff' => 'image/tiff', 296 | 'tif' => 'image/tiff', 297 | 'bmp' => 'image/bmp', 298 | 'psd' => 'image/vnd.adobe.photoshop', 299 | ], $this->getFormat()); 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /src/ThumbnailServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom( 19 | __DIR__ . '/../config/thumbnail.php', 'thumbnail' 20 | ); 21 | 22 | $this->app->bind(Thumbnail::class, function () { 23 | return new Thumbnail(config('thumbnail')); 24 | }); 25 | } 26 | 27 | /** 28 | * Bootstrap services. 29 | * 30 | * @return void 31 | */ 32 | public function boot() 33 | { 34 | if ($this->app->runningInConsole()) { 35 | $this->publishes([ 36 | __DIR__ . '/../config/thumbnail.php' => config_path('thumbnail.php'), 37 | ], 'thumbnail-config'); 38 | 39 | $this->commands([ 40 | Purge::class, 41 | ]); 42 | } 43 | 44 | $this->loadRoutesFrom(__DIR__ . '/Http/routes.php'); 45 | } 46 | } 47 | --------------------------------------------------------------------------------