From 3c4bc2d2457bb03ab8b0a2af32f9ff6f4c34bdd2 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Mon, 19 Oct 2026 14:07:52 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20media=20image=20sizes=20=E2=80=94?= =?UTF-8?q?=20ImageSize=20facade,=20registry=20signed=20with=20the=20appli?= =?UTF-8?q?cation=20key,=20thumbs=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../Console/MediaThumbsPruneCommand.php | 38 +++++++++++++ .../Console/MediaThumbsWarmCommand.php | 39 ++++++++++++++ .../Infrastructure/Laravel/MediaCheck.php | 45 ++++++++++++++++ .../Laravel/MediaServiceProvider.php | 12 ++++- src/Support/Facades/ImageSize.php | 37 +++++++++++++ .../Feature/Extraction/MediaContractTest.php | 53 ++++++++++++++++++- 7 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 src/Media/Infrastructure/Laravel/Console/MediaThumbsPruneCommand.php create mode 100644 src/Media/Infrastructure/Laravel/Console/MediaThumbsWarmCommand.php create mode 100644 src/Support/Facades/ImageSize.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7369f46e..32a56dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.36.1...develop) ### Added +- Media, image sizes: an `ImageSize` facade declares the sizes your design uses, `ImageSize::add('card')->cover(600, 400)->webp(75)`, with `cover()`, `scale()`, `contain()`, `webp()`, `avif()`, `original()`, `widths()`, `eager()` and `upscale()`. The file of a size is named `photo-card-600x400-{signature}.webp` (an HMAC of the original and of the size, made with the application key), so changing a size changes its name, and the URL, width, height and `srcset` of a size are worked out from the metadata of the original, never from the disk. The sizes WordPress registers itself, including a theme's `config/images.php`, are left as they are. `pollora:media:thumbs:warm` makes the missing files for the images already uploaded, `pollora:media:thumbs:prune` deletes the files of a size that was changed or removed, and `pollora:doctor` reports a declaration that cannot make a size or a format the server cannot write - Media, private files: a media flagged private (the `_pollora_media_private` meta, or the `pollora/media/is_private` filter) is served through a signed URL that expires (`media.private.expiry`) on its URL, its sizes and its `srcset`; a public media keeps its plain URL and the disk is never asked to tell them apart. `pollora:media:visibility {id} --private|--public` changes every file of a media on the disk, `pollora:media:files {id}` lists them, and the module's actions reach Laravel as `MediaStored`, `MediaDeleted` and `MediaVisibilityChanged` events. The URL is signed by the Laravel disk (`temporaryUrl()`), and `pollora:doctor` warns of private media on a disk that cannot sign - Media, images: the sizes a media does not need at once can be generated after the upload (`media.async_thumbnails`): `thumbnail` and `medium` come with the upload, the rest in an asynchronous action that runs on the queue, Action Scheduler or WP-Cron, and WordPress serves the nearest size meanwhile. An upload of an image with nine sizes no longer reads the original back, nor asks the disk for the size of each file, and the personal data export works on any disk (its archive is built locally, copied to the disk and deleted from it when it expires). Both WordPress image editors, GD and Imagick, work as they are - The media library can live on any Laravel disk (local, S3, R2, Scaleway…) behind two settings, `media.disk` and `media.url` (`config/media.php`, `MEDIA_DISK`, `MEDIA_URL`). The storage is [`pollora/media`](https://github.com/Pollora/media); the framework keeps the connector: `MediaServiceProvider` (the Flysystem driver of the disk, the URL of the disk when `media.url` is empty), `pollora:media:status`, `pollora:media:migrate` (copy between disks, `--replace-urls` for the database, dry run unless `--execute`) and a check of `pollora:doctor` (the disk takes a file; no media left in the local uploads folder). WordPress's own file functions follow through a `pollora://` stream wrapper and the `upload_dir` filter, so Gutenberg, ACF, Meta Box, WooCommerce and imports need no adaptation; a media URL is `media.url` and a relative path, never asked to the disk. With no `media.disk`, nothing changes: WordPress keeps `public/content/uploads` diff --git a/src/Media/Infrastructure/Laravel/Console/MediaThumbsPruneCommand.php b/src/Media/Infrastructure/Laravel/Console/MediaThumbsPruneCommand.php new file mode 100644 index 00000000..1825d47b --- /dev/null +++ b/src/Media/Infrastructure/Laravel/Console/MediaThumbsPruneCommand.php @@ -0,0 +1,38 @@ +option('execute'); + $paths = (new SizePruner($storage, $sizes))->prune(! $execute); + + foreach ($paths as $path) { + $this->line(($execute ? 'Deleted ' : 'Would delete ').$path); + } + + $this->components->info(sprintf('%s %d file(s).', $execute ? 'Deleted' : 'Would delete', count($paths))); + + if (! $execute) { + $this->components->info('This was a dry run. Add --execute to apply it.'); + } + + return self::SUCCESS; + } +} diff --git a/src/Media/Infrastructure/Laravel/Console/MediaThumbsWarmCommand.php b/src/Media/Infrastructure/Laravel/Console/MediaThumbsWarmCommand.php new file mode 100644 index 00000000..34b40871 --- /dev/null +++ b/src/Media/Infrastructure/Laravel/Console/MediaThumbsWarmCommand.php @@ -0,0 +1,39 @@ +declaredSizes(), $storage))->warm( + $this->option('size') ?: null, + $this->option('since') ?: null, + ); + } catch (InvalidArgumentException $invalidArgumentException) { + $this->components->error($invalidArgumentException->getMessage()); + + return self::FAILURE; + } + + $this->components->info(sprintf('%d image(s) looked at; %d file(s) made; %d failed.', $report['checked'], $report['made'], $report['failed'])); + + return $report['failed'] === 0 ? self::SUCCESS : self::FAILURE; + } +} diff --git a/src/Media/Infrastructure/Laravel/MediaCheck.php b/src/Media/Infrastructure/Laravel/MediaCheck.php index d17b0aef..09e304c0 100644 --- a/src/Media/Infrastructure/Laravel/MediaCheck.php +++ b/src/Media/Infrastructure/Laravel/MediaCheck.php @@ -8,8 +8,10 @@ use Pollora\Doctor\Domain\Contracts\CheckInterface; use Pollora\Doctor\Domain\Enums\RunContext; use Pollora\Doctor\Domain\Models\CheckResult; +use Pollora\Media\Application\Services\ImageSizes; use Pollora\Media\Application\Services\MediaStatus; use Pollora\Media\Domain\Contracts\MediaStorage; +use Pollora\Media\Domain\Enums\OutputFormat; use Pollora\Media\Domain\Models\MediaConfig; use Throwable; @@ -29,12 +31,14 @@ /** * @param Closure(): MediaStorage $storage Resolved only when a disk is set * @param Closure(): MediaConfig $config + * @param (Closure(): ImageSizes)|null $sizes The declared image sizes */ public function __construct( private ?string $disk, private Closure $storage, private Closure $config, private string $localUploads, + private ?Closure $sizes = null, ) {} public function id(): string @@ -80,6 +84,16 @@ public function run(RunContext $context): CheckResult ); } + $sizeProblems = $this->sizeProblems(); + + if ($sizeProblems !== []) { + return CheckResult::warning( + sprintf('%d problem(s) with the declared image sizes.', count($sizeProblems)), + $sizeProblems, + 'Fix the declaration (cover(), scale() or contain() with the dimensions it needs), or choose a format the image editor of this server can write (WebP: GD and Imagick; AVIF depends on the server)', + ); + } + $left = $this->leftBehind($storage); if ($left !== []) { @@ -93,6 +107,37 @@ public function run(RunContext $context): CheckResult return CheckResult::ok(sprintf('The disk "%s" takes a file; media are served from %s.', $this->disk, $config->url)); } + /** + * Declarations that could not make a size, and sizes in a format this server's image editor cannot write. + * + * @return list + */ + private function sizeProblems(): array + { + if (! $this->sizes instanceof Closure) { + return []; + } + + $sizes = ($this->sizes)(); + $problems = $sizes->refusals(); + + if (function_exists('wp_image_editor_supports')) { + foreach ($sizes->all() as $spec) { + $mime = match ($spec->format) { + OutputFormat::Webp => 'image/webp', + OutputFormat::Avif => 'image/avif', + OutputFormat::Original => null, + }; + + if ($mime !== null && ! \wp_image_editor_supports(['mime_type' => $mime])) { + $problems[] = sprintf('The size "%s" is written in %s, which the image editor of this server cannot write.', $spec->name, $mime); + } + } + } + + return $problems; + } + /** * How many media are private, when the disk cannot sign a URL for them; null when it can, or when none is. */ diff --git a/src/Media/Infrastructure/Laravel/MediaServiceProvider.php b/src/Media/Infrastructure/Laravel/MediaServiceProvider.php index aeb1fd61..4e340852 100644 --- a/src/Media/Infrastructure/Laravel/MediaServiceProvider.php +++ b/src/Media/Infrastructure/Laravel/MediaServiceProvider.php @@ -12,6 +12,7 @@ use League\Flysystem\FilesystemOperator; use Pollora\Doctor\Infrastructure\Providers\DoctorServiceProvider; use Pollora\Hook\Domain\Contract\Action; +use Pollora\Media\Application\Services\ImageSizes; use Pollora\Media\Domain\Contracts\MediaStorage; use Pollora\Media\Domain\Events\MediaDeleted; use Pollora\Media\Domain\Events\MediaStored; @@ -21,6 +22,8 @@ use Pollora\Media\Infrastructure\Laravel\Console\MediaFilesCommand; use Pollora\Media\Infrastructure\Laravel\Console\MediaMigrateCommand; use Pollora\Media\Infrastructure\Laravel\Console\MediaStatusCommand; +use Pollora\Media\Infrastructure\Laravel\Console\MediaThumbsPruneCommand; +use Pollora\Media\Infrastructure\Laravel\Console\MediaThumbsWarmCommand; use Pollora\Media\Infrastructure\Laravel\Console\MediaVisibilityCommand; use Pollora\Media\Infrastructure\WordPress\WordPressMedia; use RuntimeException; @@ -51,12 +54,19 @@ public function register(): void fn (): MediaStorage => $app->make(MediaStorage::class), fn (): MediaConfig => $app->make(MediaConfig::class), rtrim((string) (defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR : public_path('content')), '/').'/uploads', + static fn (): ImageSizes => $app->make(ImageSizes::class), + )); + + // The file names of the sizes are signed with the application key. + $this->app->singleton(ImageSizes::class, fn (Application $app): ImageSizes => new ImageSizes( + static fn (): string => (string) $app->make('config')->get('app.key'), )); $this->app->singleton(WordPressMedia::class, fn (Application $app): WordPressMedia => new WordPressMedia( $app->make(MediaStorage::class), $app->make(MediaConfig::class), $app->make(Action::class), + $app->make(ImageSizes::class), )); } @@ -68,7 +78,7 @@ public function boot(): void $this->app->tag([MediaCheck::class], DoctorServiceProvider::CHECKS_TAG); if ($this->app->runningInConsole()) { - $this->commands([MediaStatusCommand::class, MediaMigrateCommand::class, MediaFilesCommand::class, MediaVisibilityCommand::class]); + $this->commands([MediaStatusCommand::class, MediaMigrateCommand::class, MediaFilesCommand::class, MediaVisibilityCommand::class, MediaThumbsWarmCommand::class, MediaThumbsPruneCommand::class]); } if ($this->diskName() === null) { diff --git a/src/Support/Facades/ImageSize.php b/src/Support/Facades/ImageSize.php new file mode 100644 index 00000000..3e01be47 --- /dev/null +++ b/src/Support/Facades/ImageSize.php @@ -0,0 +1,37 @@ +cover(600, 400)->webp(75); + * ImageSize::add('hero')->scale(width: 1920)->webp(70)->widths([640, 1024, 1440]); + * ``` + * + * @method static ImageSizeDeclaration add(string $name) Declare a size + * + * @see ImageSizes + */ +class ImageSize extends Facade +{ + /** + * Get the registered name of the component. + */ + protected static function getFacadeAccessor(): string + { + return ImageSizes::class; + } +} diff --git a/tests/Feature/Extraction/MediaContractTest.php b/tests/Feature/Extraction/MediaContractTest.php index d8d332d9..1d28fa2d 100644 --- a/tests/Feature/Extraction/MediaContractTest.php +++ b/tests/Feature/Extraction/MediaContractTest.php @@ -9,10 +9,12 @@ use Illuminate\Contracts\Filesystem\Factory; use Illuminate\Filesystem\FilesystemManager; use Illuminate\Foundation\Application; +use Illuminate\Support\Facades\Facade; use Pollora\Doctor\Domain\Enums\RunContext; use Pollora\Hook\Async\PendingAsync; use Pollora\Hook\Domain\Contract\Action; use Pollora\Hook\Domain\Contract\HookInterface; +use Pollora\Media\Application\Services\ImageSizes; use Pollora\Media\Domain\Contracts\MediaStorage; use Pollora\Media\Domain\Events\MediaDeleted; use Pollora\Media\Domain\Events\MediaStored; @@ -23,6 +25,7 @@ use Pollora\Media\Infrastructure\WordPress\AsyncThumbnails; use Pollora\Media\Infrastructure\WordPress\StreamWrapper; use Pollora\Media\Infrastructure\WordPress\WordPressMedia; +use Pollora\Support\Facades\ImageSize; /** * The media library on a Laravel disk, with the real provider, the real filesystem manager and a real local @@ -154,8 +157,8 @@ it('hands the framework hook service to the module, so the thumbnails of an upload can be deferred', function (): void { [$app, $provider] = ($this->makeApp)(['disk' => 'uploads', 'async_thumbnails' => true]); - Filters\expectAdded('intermediate_image_sizes_advanced')->once(); - Filters\expectAdded('wp_generate_attachment_metadata')->twice(); // the thumbnails, and private media + Filters\expectAdded('intermediate_image_sizes_advanced')->twice(); // the thumbnails, and the declared sizes + Filters\expectAdded('wp_generate_attachment_metadata')->times(3); // the thumbnails, private media, the declared sizes $this->action->shouldReceive('add')->with(AsyncThumbnails::HOOK, Mockery::type('array'))->once()->andReturn(Mockery::mock(HookInterface::class)); $this->action->shouldReceive('async')->once()->andReturn(new PendingAsync(static function (): void {})); @@ -209,3 +212,49 @@ expect($result->status->name)->toBe('Warning') ->and($result->summary)->toContain('cannot sign URLs')->toContain('2 media'); }); + +it('declares image sizes through the facade, on the registry the module uses, signed with the application key', function (): void { + [$app] = ($this->makeApp)(['disk' => 'uploads']); + $app['config']->set('app.key', 'base64:test-application-key'); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($app); + + try { + ImageSize::add('card')->cover(600, 400)->webp(75); + ImageSize::add('hero')->scale(width: 1920)->widths([640]); + + $sizes = $app->make(ImageSizes::class); + $file = $sizes->names()->for('2026/10/photo.jpg', $sizes->get('card'), 3000, 2000); + + expect(array_keys($sizes->all()))->toBe(['card', 'hero']) + ->and($sizes)->toBe($app->make(ImageSizes::class)) + ->and($app->make(WordPressMedia::class)->imageSizes())->toBe($sizes) + ->and($file)->toMatch('#^2026/10/photo-card-600x400-[0-9a-f]{12}\.webp$#'); + + $app['config']->set('app.key', 'base64:another-key'); + expect($sizes->names()->for('2026/10/photo.jpg', $sizes->get('card'), 3000, 2000))->not->toBe($file); + } finally { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + } +}); + +it('has a doctor check that reports declarations it could not make into a size', function (): void { + [$app] = ($this->makeApp)(['disk' => 'uploads']); + $sizes = new ImageSizes(fn (): string => 'k'); + $sizes->add('broken')->cover(600); + $check = new MediaCheck( + 'uploads', + fn () => $app->make(MediaStorage::class), + fn () => $app->make(MediaConfig::class), + sys_get_temp_dir().'/pollora-media-no-local-'.bin2hex(random_bytes(3)), + fn (): ImageSizes => $sizes, + ); + Monkey\Functions\when('get_posts')->justReturn([]); + + $result = $check->run(RunContext::Console); + + expect($result->status->name)->toBe('Warning') + ->and($result->summary)->toContain('declared image sizes') + ->and($result->details[0])->toContain('broken'); +}); From f883743ee2bcdf9bf32bf518538a0eabb8423ecc Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Mon, 19 Oct 2026 14:19:06 +0200 Subject: [PATCH 2/2] chore: require pollora/media ^0.4 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 837a97df..de588a12 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "pollora/block-bindings": "^0.1", "pollora/discovery": "^0.1 || ^1.0", "pollora/entity": "^1.2", - "pollora/media": "^0.3", + "pollora/media": "^0.4", "pollora/meta": "^0.1", "pollora/hook": "^2.0.1", "pollora/option": "^1.1",