-
Notifications
You must be signed in to change notification settings - Fork 11.8k
[13.x] Adds first-party support for image processing
#59276
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
nunomaduro
wants to merge
62
commits into
13.x
Choose a base branch
from
feat/image
base: 13.x
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
62 commits
Select commit
Hold shift + click to select a range
279e574
Adds image `optimization`
nunomaduro a340f25
Apply fixes from StyleCI
StyleCIBot 5a876a8
Adds `cloudflare` driver
nunomaduro 2dc031e
Add scale, orient, blur, and greyscale options
nunomaduro ed6e30e
Adjusts API
nunomaduro 84acf8a
Fixes extension used
nunomaduro df28847
Refactors so image is immutable
nunomaduro 0b92669
fixes
nunomaduro f8f8de3
Apply fixes from StyleCI
StyleCIBot c2563b3
Fixes cloudflare
nunomaduro da6a0d8
Refactors format
nunomaduro 8a1ab59
Removes `avif`
nunomaduro 3415acf
Fixes just asking format on cloudflare
nunomaduro 3d61e0f
Removes `png` and `gif`
nunomaduro efa764f
Fixes cloudflare doing weird transformations
nunomaduro fe1c4d0
Various fixes
nunomaduro 232c436
Apply fixes from StyleCI
StyleCIBot 681ff1c
More fixes
nunomaduro 95468c1
exceptions
nunomaduro 4f49c01
validates input only
nunomaduro aaf7e8a
Adds `flip`, `flop` and `scale`, and fixes queues
nunomaduro 0445ad8
fixes queues
nunomaduro d54addb
More tests
nunomaduro 3579247
fix: GD / Imagick auto orientation
nunomaduro 240af8e
fix
nunomaduro e54be3e
fix types
nunomaduro 3c22b3d
fix: cloudflare name
nunomaduro 8e94ab5
Clean up
nunomaduro 3113d75
adds work in progress regarding purge
nunomaduro c0a909a
purge delete
nunomaduro 001f882
Clear naming
nunomaduro dd1563e
qwd
nunomaduro 6b8c1f6
adjustments
nunomaduro 260230a
qwdqwd
nunomaduro 6b02841
style
nunomaduro 6a021fe
config changes
nunomaduro a994693
More tests
nunomaduro 21cf5da
fix
nunomaduro 24bc78b
message
nunomaduro 1d88347
fix
nunomaduro 79389a9
missing types
nunomaduro fac83ad
Removes non needed comments
nunomaduro d8dda49
Missing tests
nunomaduro bd7f089
add previous
nunomaduro 0de2844
tests
nunomaduro 4bd5bd3
More tests
nunomaduro 0a10411
and more
nunomaduro 0fca464
allow to specify the driver on pruneOrphaned
nunomaduro 0da75d1
missing facade
nunomaduro bfc9810
fix
nunomaduro f35ac15
Apply fixes from StyleCI
StyleCIBot f22438b
to string
nunomaduro 8b7b9fb
Merge branch 'feat/image' of github.com:laravel/framework into feat/i…
nunomaduro 77db75f
missing import
nunomaduro 266b8e9
Apply fixes from StyleCI
StyleCIBot 18ce637
more str changes
nunomaduro f9dd0da
Merge branch 'feat/image' of github.com:laravel/framework into feat/i…
nunomaduro 463719e
memory optimizations
nunomaduro 31d8830
php does not support types const
nunomaduro 6000b92
style
nunomaduro 7c90632
fix tests
nunomaduro 9e0d047
fix
nunomaduro File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| <?php | ||
|
|
||
| return [ | ||
|
|
||
| /* | ||
| |-------------------------------------------------------------------------- | ||
| | Default Image Driver | ||
| |-------------------------------------------------------------------------- | ||
| | | ||
| | This option controls the default image processing driver that will be | ||
| | used when manipulating or converting images. This driver is always | ||
| | utilized unless another driver is explicitly specified instead. | ||
| | | ||
| | Supported: "gd", "imagick", "cloudflare" | ||
| | | ||
| */ | ||
|
|
||
| 'default' => env('IMAGE_DRIVER', 'gd'), | ||
|
|
||
| /* | ||
| |-------------------------------------------------------------------------- | ||
| | Image Drivers | ||
| |-------------------------------------------------------------------------- | ||
| | | ||
| | Here you may configure the Cloudflare Images API credentials for the | ||
| | "cloudflare" image driver. The prefix option is used to namespace | ||
| | temporary uploads so that orphaned images may be pruned safely. | ||
| | | ||
| */ | ||
|
|
||
| 'drivers' => [ | ||
|
|
||
| 'cloudflare' => [ | ||
| 'account_id' => env('IMAGE_CLOUDFLARE_ACCOUNT_ID'), | ||
| 'api_token' => env('IMAGE_CLOUDFLARE_API_TOKEN'), | ||
| 'prefix' => env('IMAGE_CLOUDFLARE_PREFIX', 'laravel-image'), | ||
| ], | ||
|
|
||
| ], | ||
|
|
||
| ]; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| <?php | ||
|
|
||
| namespace Illuminate\Contracts\Image; | ||
|
|
||
| use Illuminate\Foundation\Image\PendingImageOptions; | ||
|
|
||
| interface Driver | ||
| { | ||
| /** | ||
| * Process the given image contents with the specified options. | ||
| */ | ||
| public function process(string $contents, PendingImageOptions $options): string; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
233 changes: 233 additions & 0 deletions
233
src/Illuminate/Foundation/Image/Drivers/CloudflareDriver.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,233 @@ | ||
| <?php | ||
|
|
||
| namespace Illuminate\Foundation\Image\Drivers; | ||
|
|
||
| use DateTimeImmutable; | ||
| use finfo; | ||
| use Illuminate\Contracts\Image\Driver; | ||
| use Illuminate\Foundation\Image\ImageException; | ||
| use Illuminate\Foundation\Image\PendingImageOptions; | ||
| use Illuminate\Http\Client\Factory as HttpFactory; | ||
| use Illuminate\Http\Client\Pool; | ||
| use Illuminate\Support\Str; | ||
|
|
||
| class CloudflareDriver implements Driver | ||
| { | ||
| /** | ||
| * Create a new Cloudflare driver instance. | ||
| */ | ||
| public function __construct( | ||
| protected HttpFactory $http, | ||
| protected string $accountId, | ||
| protected string $apiToken, | ||
| protected string $prefix, | ||
| ) { | ||
| // | ||
| } | ||
|
|
||
| /** | ||
| * Ensure the Cloudflare credentials are configured. | ||
| * | ||
| * @throws ImageException | ||
| */ | ||
| public function ensureRequirementsAreMet(): void | ||
| { | ||
| if (empty($this->accountId) || empty($this->apiToken)) { | ||
| throw new ImageException( | ||
| 'The Cloudflare image driver requires an account ID and API token.', | ||
| ); | ||
| } | ||
|
|
||
| if (empty($this->prefix)) { | ||
| throw new ImageException( | ||
| 'The Cloudflare image driver requires a prefix for temporary uploads.', | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Process the given image contents with the specified options. | ||
| * | ||
| * @throws ImageException | ||
| */ | ||
| public function process(string $contents, PendingImageOptions $options): string | ||
| { | ||
| $sourceMimeType = (new finfo(FILEINFO_MIME_TYPE))->buffer($contents); | ||
|
|
||
| if (! in_array($sourceMimeType, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'])) { | ||
| throw new ImageException("The image format [{$sourceMimeType}] is not supported by the Cloudflare driver."); | ||
| } | ||
|
|
||
| $id = $this->prefix.'/'.Str::random(40).match ($sourceMimeType) { | ||
| 'image/jpeg' => '.jpg', | ||
| 'image/png' => '.png', | ||
| 'image/gif' => '.gif', | ||
| 'image/webp' => '.webp', | ||
| }; | ||
|
|
||
| $response = $this->http | ||
| ->withToken($this->apiToken) | ||
| ->attach('file', $contents, basename($id)) | ||
| ->post("https://api.cloudflare.com/client/v4/accounts/{$this->accountId}/images/v1", [ | ||
| 'id' => $id, | ||
| ]); | ||
|
|
||
| if ($response->failed()) { | ||
| throw new ImageException( | ||
| 'Cloudflare image upload failed: '.$response->json('errors.0.message', 'Unknown error'), | ||
| previous: $response->toException(), | ||
| ); | ||
| } | ||
|
|
||
| return $this->transformAndDelete( | ||
| $response->json('result.id'), | ||
| $response->json('result.variants', []), | ||
| $options, | ||
| $contents, | ||
| $sourceMimeType, | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Fetch the transformed image and delete the original from Cloudflare. | ||
| * | ||
| * @param array<int, string> $variants | ||
| */ | ||
| protected function transformAndDelete(string $imageId, array $variants, PendingImageOptions $options, string $contents, string $sourceMimeType): string | ||
| { | ||
| try { | ||
| if (empty($variants)) { | ||
| throw new ImageException('Cloudflare did not return any image variants.'); | ||
| } | ||
|
|
||
| $acceptMimeType = $options->format !== null | ||
| ? match ($options->format) { | ||
| 'webp' => 'image/webp', | ||
| 'jpg', 'jpeg' => 'image/jpeg', | ||
| } | ||
| : $sourceMimeType; | ||
|
|
||
| $request = $this->http->withHeaders([ | ||
| 'Accept' => $acceptMimeType, | ||
| ]); | ||
|
|
||
| $response = $request->get( | ||
| $this->buildTransformUrl($variants[0], $options, $contents), | ||
| ); | ||
|
|
||
| if ($response->failed()) { | ||
| throw new ImageException( | ||
| 'Failed to fetch transformed image from Cloudflare: '.$response->json('errors.0.message', 'Unknown error'), | ||
| previous: $response->toException(), | ||
| ); | ||
| } | ||
|
|
||
| return $response->body(); | ||
| } finally { | ||
| rescue(fn () => $this->deleteImage($imageId)); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Build the Cloudflare transform URL with the given options. | ||
| */ | ||
| protected function buildTransformUrl(string $baseUrl, PendingImageOptions $options, string $contents): string | ||
| { | ||
| $params = []; | ||
|
|
||
| if ($options->coverWidth !== null && $options->coverHeight !== null) { | ||
| $params[] = "width={$options->coverWidth}"; | ||
| $params[] = "height={$options->coverHeight}"; | ||
| $params[] = 'fit=cover'; | ||
| } elseif ($options->scaleWidth !== null && $options->scaleHeight !== null) { | ||
| $params[] = "width={$options->scaleWidth}"; | ||
| $params[] = "height={$options->scaleHeight}"; | ||
| $params[] = 'fit=scale-down'; | ||
| } else { | ||
| [$width, $height] = getimagesizefromstring($contents); | ||
|
|
||
| $params[] = "width={$width}"; | ||
| $params[] = "height={$height}"; | ||
| $params[] = 'fit=scale-down'; | ||
| } | ||
|
|
||
| if ($options->blur !== null) { | ||
| $params[] = "blur={$options->blur}"; | ||
| } | ||
|
|
||
| if ($options->greyscale) { | ||
| $params[] = 'saturation=0'; | ||
| } | ||
|
|
||
| if ($options->sharpen !== null) { | ||
| $params[] = 'sharpen='.max(0, min(10, round($options->sharpen / 10))); | ||
| } | ||
|
|
||
| if ($options->flip && $options->flop) { | ||
| $params[] = 'flip=hv'; | ||
| } elseif ($options->flip) { | ||
| $params[] = 'flip=v'; | ||
| } elseif ($options->flop) { | ||
| $params[] = 'flip=h'; | ||
| } | ||
|
|
||
| if ($options->format !== null) { | ||
| $params[] = 'format='.match ($options->format) { | ||
| 'jpg' => 'jpeg', | ||
| default => $options->format, | ||
| }; | ||
| } | ||
|
|
||
| $params[] = 'quality='.($options->quality ?? PendingImageOptions::DEFAULT_QUALITY); | ||
|
|
||
| return preg_replace('#/[^/]+$#', '/'.implode(',', $params), $baseUrl); | ||
| } | ||
|
|
||
| /** | ||
| * Delete orphaned images from Cloudflare that match the configured prefix. | ||
| */ | ||
| public function pruneOrphaned(): void | ||
| { | ||
| $page = 1; | ||
|
|
||
| do { | ||
| $response = $this->http | ||
| ->withToken($this->apiToken) | ||
| ->get("https://api.cloudflare.com/client/v4/accounts/{$this->accountId}/images/v1", [ | ||
| 'per_page' => 100, | ||
| 'page' => $page, | ||
| ]); | ||
|
|
||
| if ($response->failed()) { | ||
| throw new ImageException( | ||
| 'Failed to list images from Cloudflare: '.$response->json('errors.0.message', 'Unknown error'), | ||
| previous: $response->toException(), | ||
| ); | ||
| } | ||
|
|
||
| $images = $response->json('result.images', []); | ||
|
|
||
| collect($images) | ||
| ->filter(fn (array $image) => str_starts_with($image['id'], $this->prefix.'/')) | ||
| ->reject(fn (array $image) => (new DateTimeImmutable($image['uploaded']))->getTimestamp() > time() - 300) | ||
| ->pluck('id') | ||
| ->chunk(10) | ||
| ->each(fn ($chunk) => $this->http->pool(fn (Pool $pool) => $chunk->map( | ||
| fn (string $id) => $pool->withToken($this->apiToken) | ||
| ->delete("https://api.cloudflare.com/client/v4/accounts/{$this->accountId}/images/v1/{$id}"), | ||
| )->all())); | ||
|
|
||
| $page++; | ||
| } while (count($images) === 100); | ||
| } | ||
|
|
||
| /** | ||
| * Delete the temporary image from Cloudflare. | ||
| */ | ||
| protected function deleteImage(string $imageId): void | ||
| { | ||
| $this->http | ||
| ->withToken($this->apiToken) | ||
| ->delete("https://api.cloudflare.com/client/v4/accounts/{$this->accountId}/images/v1/{$imageId}"); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| <?php | ||
|
|
||
| namespace Illuminate\Foundation\Image\Drivers; | ||
|
|
||
| use Intervention\Image\ImageManager; | ||
|
|
||
| class GdDriver extends InterventionDriver | ||
| { | ||
| /** | ||
| * Create the underlying Intervention image manager. | ||
| */ | ||
| protected function createManager(): ImageManager | ||
| { | ||
| return ImageManager::gd(); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| <?php | ||
|
|
||
| namespace Illuminate\Foundation\Image\Drivers; | ||
|
|
||
| use Intervention\Image\ImageManager; | ||
|
|
||
| class ImagickDriver extends InterventionDriver | ||
| { | ||
| /** | ||
| * Create the underlying Intervention image manager. | ||
| */ | ||
| protected function createManager(): ImageManager | ||
| { | ||
| return ImageManager::imagick(); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.