diff --git a/composer.json b/composer.json
index c1009e48642..43d641ba474 100644
--- a/composer.json
+++ b/composer.json
@@ -171,6 +171,7 @@
"Addresses": "CraftCms\\Cms\\Support\\Facades\\Addresses",
"Announcements": "CraftCms\\Cms\\Support\\Facades\\Announcements",
"AssetIndexer": "CraftCms\\Cms\\Support\\Facades\\AssetIndexer",
+ "AssetTransforms": "CraftCms\\Cms\\Support\\Facades\\AssetTransforms",
"Assets": "CraftCms\\Cms\\Support\\Facades\\Assets",
"AuthMethods": "CraftCms\\Cms\\Support\\Facades\\AuthMethods",
"BulkOps": "CraftCms\\Cms\\Support\\Facades\\BulkOps",
diff --git a/docs/asset-transformers.md b/docs/asset-transformers.md
new file mode 100644
index 00000000000..89fa7cb2a02
--- /dev/null
+++ b/docs/asset-transformers.md
@@ -0,0 +1,242 @@
+# Asset Transformers
+
+Craft 6 includes an asset transformer registry that lets plugins add new ways to transform assets.
+
+Craft registers its built-in image transformer with the `craft` handle. Plugins can register additional transformer handles for remote image services, video processors, PDF preview generators, text renderers, or any other asset transformation workflow.
+
+The registry is available through `CraftCms\Cms\Asset\AssetTransforms` or the `CraftCms\Cms\Support\Facades\AssetTransforms` facade.
+
+## How Transformers Are Selected
+
+When an asset transform is requested, Craft resolves the transformer in this order:
+
+1. the `transformer` value in the transform array
+2. the transformer selected by the named image transform
+3. the asset filesystem's default transformer
+4. Craft's built-in `craft` transformer
+
+If a configured transformer handle is missing, Craft logs a warning and falls back to `craft`.
+
+```php
+getUrl([
+ 'transformer' => 'my-service',
+ 'width' => 1200,
+ 'height' => 800,
+ 'blur' => 12,
+]);
+```
+
+Unknown transform options are preserved on the transform's `settings` array. Transformers can use the options they support and ignore the rest.
+
+## Registering a Transformer
+
+Register transformers by listening for `AssetTransformersResolving`.
+
+```php
+types[MyServiceTransformer::handle()] = MyServiceTransformer::class;
+ });
+ }
+}
+```
+
+Handles should be stable, unique, and safe to store in project config.
+
+## Implementing a Transformer
+
+Transformers implement `CraftCms\Cms\Image\Contracts\AssetTransformerInterface`.
+
+```php
+ [
+ 'type' => Type::int(),
+ 'description' => 'Blur amount for the transformed asset.',
+ ],
+ ];
+ }
+
+ public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bool $immediately): string
+ {
+ if (! str_starts_with((string) $asset->getMimeType(), 'image/')) {
+ throw new NotSupportedException('Only images are supported.');
+ }
+
+ $settings = $imageTransform->settings;
+
+ $sourceUrl = $asset->getUrl();
+
+ if ($sourceUrl === null) {
+ // Use $asset->getStream() or $asset->getCopyOfFile() and upload the
+ // source somewhere the transformer can read from.
+ throw new NotSupportedException('A public source URL is required.');
+ }
+
+ return 'https://img.example.test/'.http_build_query([
+ 'src' => $sourceUrl,
+ 'w' => $imageTransform->width,
+ 'h' => $imageTransform->height,
+ 'blur' => $settings['blur'] ?? null,
+ ]);
+ }
+
+ public function invalidateAssetTransforms(Asset $asset): void
+ {
+ // Purge remote transforms, delete generated files, or clear internal caches.
+ }
+
+ public function getTransformString(ImageTransform $imageTransform, bool $ignoreHandle = false): string
+ {
+ $parts = [
+ 'w' => $imageTransform->width,
+ 'h' => $imageTransform->height,
+ 'format' => $imageTransform->format,
+ 'blur' => $imageTransform->settings['blur'] ?? null,
+ ];
+
+ return http_build_query(array_filter($parts, fn (mixed $value): bool => $value !== null));
+ }
+
+ public function getImageTransformSettingsHtml(ImageTransform $imageTransform, bool $readOnly = false): ?string
+ {
+ return null;
+ }
+
+ public function getFilesystemSettingsHtml(FsInterface $filesystem, bool $readOnly = false): ?string
+ {
+ return null;
+ }
+}
+```
+
+## Runtime Behavior
+
+`getTransformUrl()` should return the final URL for the transformed asset.
+
+Use `NotSupportedException` when the transformer cannot handle the asset or transform options. Craft catches that exception when rendering asset URLs, logs it, and returns `null`.
+
+The `$immediately` argument tells the transformer whether Craft wants the transform generated before returning the URL. Transformers that delegate to remote services may ignore it when the returned URL is enough to trigger generation externally.
+
+## Cache Keys
+
+`getTransformString()` defines the transformer's canonical cache key. Include only options that affect the generated result.
+
+This is what lets one transformer support an option like `blur` while another transformer can safely ignore it. Ignored options stay in `ImageTransform::$settings`, but they should not be included in the transform string unless they affect output.
+
+## Settings UI
+
+Named image transforms can store transformer-specific settings. Return CP HTML from `getImageTransformSettingsHtml()` when your transformer needs fields on the named transform edit screen.
+
+Filesystem defaults can also store transformer-specific settings. Return CP HTML from `getFilesystemSettingsHtml()` when your transformer needs fields on the filesystem edit screen. Craft's built-in `craft` transformer uses those filesystem-scoped settings for its transform filesystem and transform subpath.
+
+Fields should post under `settings`.
+
+```php
+public function getImageTransformSettingsHtml(ImageTransform $imageTransform, bool $readOnly = false): ?string
+{
+ $value = htmlspecialchars((string) ($imageTransform->settings['blur'] ?? ''), ENT_QUOTES);
+ $disabled = $readOnly ? ' disabled' : '';
+
+ return <<
+
+
+
+
+
+
+
+HTML;
+}
+```
+
+Craft preserves unsupported settings when the runtime transformer differs from the transformer that originally supplied the settings UI.
+
+## GraphQL Arguments
+
+Transformers may add transform arguments for GraphQL by returning definitions from `gqlArguments()`.
+
+```php
+public static function gqlArguments(): array
+{
+ return [
+ 'blur' => [
+ 'type' => Type::int(),
+ 'description' => 'Blur amount for the transformed asset.',
+ ],
+ ];
+}
+```
+
+Argument names must not collide with Craft's built-in transform arguments or arguments registered by another transformer. Craft throws an `ImageTransformException` if a collision is detected.
+
+## Filesystem Defaults and Named Transforms
+
+Filesystems have a default transformer setting. Use that for environment-specific behavior, such as a cloud image service in staging and production but Craft's local transformer in development. The setting can be an environment variable, such as `$ASSET_TRANSFORMER`, whose value is a registered transformer handle.
+
+Named image transforms can optionally select a transformer. Leave the named transform set to the filesystem default when the transform should follow the asset's filesystem.
+
+Per-call `transformer` values still take precedence:
+
+```twig
+{{ asset.url({
+ transformer: 'my-service',
+ transform: 'hero',
+ blur: 8,
+}) }}
+```
+
+## Filesystems With and Without URLs
+
+Transformers should work with filesystems that have public URLs and filesystems that do not.
+
+Craft's built-in `craft` transformer stores its transform output target in filesystem-scoped transformer settings. Its settings include the transform filesystem and transform subpath. When no transform filesystem is configured, Craft writes generated transforms to the asset's source filesystem.
+
+Craft's built-in `craft` transformer returns direct filesystem URLs when possible and signed Craft action URLs for private filesystems. Custom transformers should follow the same principle: return a usable URL regardless of whether the source or configured transform filesystem has public URLs.
+
+If your transformer needs to read the original asset and `Asset::getUrl()` returns `null`, use `Asset::getStream()` or `Asset::getCopyOfFile()` instead of requiring a public source URL.
+
+## Legacy Image Transformers
+
+The legacy image transformer API remains available through the Yii adapter layer for backwards compatibility. Legacy transformers registered through `craft\services\ImageTransforms::EVENT_REGISTER_IMAGE_TRANSFORMERS` are bridged into the asset transformer registry by their class name.
+
+New code should implement `AssetTransformerInterface` and register a stable handle instead of registering a legacy image transformer class.
diff --git a/resources/templates/_components/asset-transformers/ImageTransformer/filesystem-settings.twig b/resources/templates/_components/asset-transformers/ImageTransformer/filesystem-settings.twig
new file mode 100644
index 00000000000..a82291b2a5c
--- /dev/null
+++ b/resources/templates/_components/asset-transformers/ImageTransformer/filesystem-settings.twig
@@ -0,0 +1,30 @@
+{% import '_includes/forms.twig' as forms %}
+
+{% namespace 'transformerSettings[craft]' %}
+ {{ forms.fsField({
+ label: 'Transform Filesystem'|t('app'),
+ instructions: 'Choose which filesystem image transforms should be stored in.'|t('app'),
+ id: 'transform-fs-handle',
+ name: 'transformFsHandle',
+ includeEnvVars: true,
+ value: old('transformerSettings.craft.transformFsHandle', settings.transformFsHandle ?? null),
+ options: [{label: 'Same as asset filesystem'|t('app'), value: null, data: {hint: ''}}]|merge(craft.cp.getFsOptions()),
+ disabled: readOnly,
+ showSelectedHint: true,
+ selectizeOptions: {
+ extraPlugins: false,
+ allowEmptyOption: true,
+ placeholder: 'Same as asset filesystem'|t('app'),
+ },
+ }) }}
+
+ {{ forms.autosuggestField({
+ label: 'Transform Subpath'|t('app'),
+ instructions: 'Where transforms should be stored on the filesystem.'|t('app'),
+ id: 'transform-subpath',
+ name: 'transformSubpath',
+ suggestEnvVars: true,
+ value: old('transformerSettings.craft.transformSubpath', settings.transformSubpath ?? null),
+ disabled: readOnly,
+ }) }}
+{% endnamespace %}
diff --git a/resources/templates/settings/assets/transforms/_settings.twig b/resources/templates/settings/assets/transforms/_settings.twig
index 1a95fdb257b..88302087011 100644
--- a/resources/templates/settings/assets/transforms/_settings.twig
+++ b/resources/templates/settings/assets/transforms/_settings.twig
@@ -50,7 +50,7 @@
id: 'name',
name: 'name',
value: old('name', transform.name),
- errors: transform.errors.get('name'),
+ errors: errors.get('name'),
autofocus: true,
required: true,
disabled: readOnly,
@@ -64,11 +64,25 @@
autocorrect: false,
autocapitalize: false,
value: old('handle', transform.handle),
- errors: transform.errors.get('handle'),
+ errors: errors.get('handle'),
required: true,
disabled: readOnly,
}) }}
+ {{ forms.selectField({
+ label: 'Transformer'|t('app'),
+ instructions: 'Which transformer should be used when this named transform is applied.'|t('app'),
+ id: 'transformer',
+ name: 'transformer',
+ options: transformerOptions,
+ value: old('transformer', transform.getTransformer() ?? ''),
+ disabled: readOnly,
+ }) }}
+
+ {% if transformerSettingsHtml %}
+ {{ transformerSettingsHtml|raw }}
+ {% endif %}
+
{% embed '_includes/forms/field.twig' with {
label: 'Mode'|t('app')
} %}
@@ -115,7 +129,7 @@
label: 'Fill Color',
name: 'fill',
value: mode == 'letterbox' and fill != 'transparent' ? fill,
- errors: transform.errors.get('fill'),
+ errors: errors.get('fill'),
disabled: readOnly,
}) }}
@@ -148,7 +162,7 @@
name: "width",
size: 5,
value: old('width', transform.width),
- errors: transform.errors.get('width'),
+ errors: errors.get('width'),
disabled: readOnly,
}) }}
@@ -158,7 +172,7 @@
name: "height",
size: 5,
value: old('height', transform.height),
- errors: transform.errors.get('height'),
+ errors: errors.get('height'),
disabled: readOnly,
}) }}
@@ -167,13 +181,13 @@
id: 'upscale',
name: 'upscale',
on: old('upscale', transform.upscale ?? config('craft.general.upscaleImages')),
- errors: transform.errors.get('upscale'),
+ errors: errors.get('upscale'),
disabled: readOnly,
}) }}
{% embed '_includes/forms/field.twig' with {
label: 'Quality'|t('app'),
- errors: transform.errors.get('quality'),
+ errors: errors.get('quality'),
} %}
{% block input %}
{% import '_includes/forms.twig' as forms %}
@@ -221,7 +235,7 @@
{label: 'Partition'|t('app'), value: 'partition'},
],
value: old('interlace', transform.interlace ?? 'none'),
- errors: transform.errors.get('interlace'),
+ errors: errors.get('interlace'),
disabled: readOnly,
}) }}
@@ -246,7 +260,7 @@
name: "format",
instructions: "The image format that transformed images should use."|t('app'),
value: format,
- errors: transform.errors.get('format'),
+ errors: errors.get('format'),
options: formatOptions,
disabled: readOnly,
}) }}
diff --git a/resources/templates/settings/assets/volumes/_edit.twig b/resources/templates/settings/assets/volumes/_edit.twig
index d789fd7c267..f0aece1b3d7 100644
--- a/resources/templates/settings/assets/volumes/_edit.twig
+++ b/resources/templates/settings/assets/volumes/_edit.twig
@@ -58,37 +58,6 @@
disabled: readOnly,
}) }}
-{{ forms.fsField({
- label: 'Transform Filesystem'|t('app'),
- instructions: 'Choose which filesystem image transforms should be stored in.'|t('app'),
- id: 'transform-fs-handle',
- name: 'transformFsHandle',
- includeEnvVars: true,
- value: old('transformFsHandle', volume.transformFsHandle(false)),
- options: [{label: 'Same as asset filesystem'|t('app'), value: null, data: {hint: ''}}]|merge(groupedFsOptions),
- errors: volume.errors.get('transformFsHandle'),
- data: {'error-key': 'transformFsHandle'},
- disabled: readOnly,
- showSelectedHint: true,
- selectizeOptions: {
- extraPlugins: false,
- allowEmptyOption: true,
- placeholder: 'Same as asset filesystem'|t('app'),
- },
-}) }}
-
-{{ forms.autosuggestField({
- label: 'Transform Subpath'|t('app'),
- instructions: 'Where transforms should be stored on the filesystem.'|t('app'),
- id: 'transformSubpath',
- name: 'transformSubpath',
- suggestEnvVars: true,
- value: old('transformSubpath', volume.getTransformSubpath(false, false)),
- errors: volume.errors.get('transformSubpath'),
- data: {'error-key': 'transformSubpath'},
- disabled: readOnly,
-}) }}
-
{% if Sites.isMultiSite() %}
diff --git a/resources/templates/settings/filesystems/_edit.twig b/resources/templates/settings/filesystems/_edit.twig
index e561144bc17..977274c82c7 100644
--- a/resources/templates/settings/filesystems/_edit.twig
+++ b/resources/templates/settings/filesystems/_edit.twig
@@ -70,6 +70,24 @@
{% endtag %}
{% endfor %}
+
+
+{{ forms.selectField({
+ label: 'Default Transformer'|t('app'),
+ instructions: 'Which transformer should be used by default for assets stored on this filesystem.'|t('app'),
+ id: 'default-transformer',
+ name: 'defaultTransformer',
+ options: transformerOptions,
+ value: old('defaultTransformer', filesystem.getDefaultTransformer(false) ?? 'craft'),
+ errors: (filesystem is defined ? errors.get('defaultTransformer') : null),
+ data: {'error-key': 'defaultTransformer'},
+ disabled: readOnly,
+}) }}
+
+{% if transformerSettingsHtml %}
+ {{ transformerSettingsHtml|raw }}
+{% endif %}
+
{% if filesystem is not defined or not filesystem.handle %}
{% js %}
diff --git a/resources/templates/settings/users/_settings.twig b/resources/templates/settings/users/_settings.twig
index 636790f3ba4..f73f17f7a42 100644
--- a/resources/templates/settings/users/_settings.twig
+++ b/resources/templates/settings/users/_settings.twig
@@ -23,7 +23,7 @@
{% set validVolumeUids = [] %}
{% for volume in allVolumes %}
- {% if volume.getTransformFs().hasUrls %}
+ {% if imageTransformer.getTransformFsForVolume(volume).hasUrls %}
{% set volumeList = volumeList|push({label: volume.name, value: volume.uid}) %}
{% set validVolumeUids = validVolumeUids|push(volume.uid) %}
{% endif %}
diff --git a/src/Asset/AssetTransforms.php b/src/Asset/AssetTransforms.php
new file mode 100644
index 00000000000..ffff9f248c1
--- /dev/null
+++ b/src/Asset/AssetTransforms.php
@@ -0,0 +1,269 @@
+ */
+ private array $assetTransformers = [];
+
+ /**
+ * Eager-loads transform indexes for the given list of assets.
+ *
+ * You can include `srcset`-style sizes (e.g. `100w` or `2x`) following a normal transform definition, for example:
+ *
+ * ```php
+ * [['width' => 1000, 'height' => 600], '1.5x', '2x', '3x']
+ * ```
+ *
+ * When a `srcset`-style size is encountered, the preceding normal transform definition will be used as a
+ * reference when determining the resulting transform dimensions.
+ *
+ * @param array $transforms The transform definitions to eager-load
+ * @param Asset[] $assets The assets to eager-load transforms for
+ */
+ public function eagerLoadTransforms(array $assets, array $transforms): void
+ {
+ if (empty($assets) || empty($transforms)) {
+ return;
+ }
+
+ $normalizedTransforms = [];
+
+ /** @var ImageTransform|null $refTransform */
+ $refTransform = null;
+
+ foreach ($transforms as $transform) {
+ try {
+ [$sizeValue, $sizeUnit] = AssetsHelper::parseSrcsetSize($transform);
+ } catch (InvalidArgumentException) {
+ $sizeValue = $sizeUnit = null;
+ }
+
+ if (isset($sizeValue, $sizeUnit)) {
+ if ($refTransform === null || ! $refTransform->width) {
+ throw new InvalidArgumentException("Can’t eager-load transform “{$transform}” without a prior transform that specifies the base width");
+ }
+
+ $transform = new ImageTransform(
+ $refTransform->toArray(),
+ );
+
+ unset($transform->name, $transform->handle);
+
+ if ($sizeUnit === 'w') {
+ $transform->width = (int) $sizeValue;
+ } else {
+ $transform->width = (int) ceil($refTransform->width * $sizeValue);
+ }
+
+ if ($refTransform->height) {
+ if ($sizeUnit === 'w') {
+ $transform->height = (int) ceil($refTransform->height * $transform->width / $refTransform->width);
+ } else {
+ $transform->height = (int) ceil($refTransform->height * $sizeValue);
+ }
+ }
+ }
+
+ $transform = ImageTransformHelper::normalizeTransform($transform);
+ if ($transform === null) {
+ continue;
+ }
+
+ $normalizedTransforms[] = $transform;
+
+ if (! isset($sizeValue)) {
+ $refTransform = $transform;
+ }
+ }
+
+ $transformsByTransformer = [];
+
+ foreach ($normalizedTransforms as $transformKey => $transform) {
+ $transformerHandle = $transform->getTransformer();
+
+ if ($transformerHandle !== null) {
+ $transformerHandle = $this->resolveTransformerHandle($transformerHandle);
+ $groupTransform = clone $transform;
+ $groupTransform->setTransformer($transformerHandle);
+ $transformsByTransformer[$transformerHandle]['transforms'][$transformKey] = $groupTransform;
+ $transformsByTransformer[$transformerHandle]['assets'] = $assets;
+
+ continue;
+ }
+
+ foreach ($assets as $assetKey => $asset) {
+ $transformerHandle = $this->resolveTransformerHandle($asset->getVolume()->getFs()->getDefaultTransformer());
+
+ if (! isset($transformsByTransformer[$transformerHandle]['transforms'][$transformKey])) {
+ $groupTransform = clone $transform;
+ $groupTransform->setTransformer($transformerHandle);
+ $transformsByTransformer[$transformerHandle]['transforms'][$transformKey] = $groupTransform;
+ }
+
+ $transformsByTransformer[$transformerHandle]['assets'][$assetKey] = $asset;
+ }
+ }
+
+ foreach ($transformsByTransformer as $type => $group) {
+ $transformer = $this->getAssetTransformer($type);
+
+ if ($transformer instanceof EagerImageTransformerInterface) {
+ $transformer->eagerLoadTransforms(
+ array_values($group['transforms']),
+ array_values($group['assets']),
+ );
+ }
+ }
+ }
+
+ public function getAssetTransformer(?string $handle = null): AssetTransformerInterface
+ {
+ $handle = $this->resolveTransformerHandle($handle);
+
+ if (array_key_exists($handle, $this->assetTransformers)) {
+ return $this->assetTransformers[$handle];
+ }
+
+ $types = $this->getAllAssetTransformers();
+
+ if (array_key_exists($handle, $types)) {
+ $type = $types[$handle];
+
+ return $this->assetTransformers[$handle] = is_string($type)
+ ? app()->make($type)
+ : $type;
+ }
+
+ if (is_a($handle, AssetTransformerInterface::class, true)) {
+ return $this->assetTransformers[$handle] = app()->make($handle);
+ }
+
+ Log::warning("Invalid asset transformer: $handle. Falling back to craft.", [__METHOD__]);
+
+ return $this->assetTransformers[ImageTransform::DEFAULT_TRANSFORMER]
+ ??= app()->make(ImageTransformer::class);
+ }
+
+ public function resolveTransformerHandle(?string $handle): string
+ {
+ $handle = Env::parse($handle);
+
+ if ($handle === null || $handle === '' || $handle === ImageTransformer::class) {
+ return ImageTransform::DEFAULT_TRANSFORMER;
+ }
+
+ $types = $this->getAllAssetTransformers();
+ if (array_key_exists($handle, $types)) {
+ return $handle;
+ }
+
+ if (is_a($handle, AssetTransformerInterface::class, true)) {
+ $transformerHandle = $handle::handle();
+
+ return $transformerHandle !== '' ? $transformerHandle : $handle;
+ }
+
+ return $handle;
+ }
+
+ /**
+ * @return array|AssetTransformerInterface>
+ */
+ public function getAllAssetTransformers(): array
+ {
+ $transformers = [
+ ImageTransform::DEFAULT_TRANSFORMER => ImageTransformer::class,
+ ];
+
+ event($event = new AssetTransformersResolving(types: $transformers));
+
+ foreach ($event->types as $handle => $class) {
+ if (
+ ! is_string($handle) ||
+ $handle === '' ||
+ (! is_string($class) && ! $class instanceof AssetTransformerInterface) ||
+ (is_string($class) && ! is_subclass_of($class, AssetTransformerInterface::class))
+ ) {
+ $type = is_object($class) ? $class::class : (string) $class;
+
+ throw new ImageTransformException("Invalid asset transformer: $type");
+ }
+ }
+
+ return $event->types;
+ }
+
+ /**
+ * Deletes ALL transform data (including thumbs and sources) associated with the asset.
+ */
+ public function deleteAllTransformData(Asset $asset): void
+ {
+ $this->deleteResizedAssetVersion($asset);
+ $this->deleteCreatedTransformsForAsset($asset);
+
+ $file = Path::assetSources($asset->id.'.'.pathinfo($asset->getFilename(), PATHINFO_EXTENSION));
+
+ File::delete($file);
+ }
+
+ public function deleteResizedAssetVersion(Asset $asset): void
+ {
+ $dirs = [
+ Path::imageEditorSources((string) $asset->id),
+ ];
+
+ foreach ($dirs as $dir) {
+ if (file_exists($dir)) {
+ $files = glob($dir.'/[0-9]*/'.$asset->id.'.[a-z]*');
+
+ if (! is_array($files)) {
+ Log::info('Could not list files in '.$dir.' when deleting resized asset versions.');
+
+ continue;
+ }
+
+ foreach ($files as $path) {
+ if (! File::delete($path)) {
+ Log::warning("Unable to delete the asset thumbnail \"$path\".", [__METHOD__]);
+ }
+ }
+ }
+ }
+ }
+
+ public function deleteCreatedTransformsForAsset(Asset $asset): void
+ {
+ event(new AssetTransformsInvalidating(asset: $asset));
+
+ foreach ($this->getAllAssetTransformers() as $handle => $type) {
+ $transformer = $this->getAssetTransformer($handle);
+ $transformer->invalidateAssetTransforms($asset);
+ }
+ }
+
+ public function reset(): void
+ {
+ $this->assetTransformers = [];
+ }
+}
diff --git a/src/Asset/Assets.php b/src/Asset/Assets.php
index deb444813a9..63d8a48a2ed 100644
--- a/src/Asset/Assets.php
+++ b/src/Asset/Assets.php
@@ -27,7 +27,6 @@
use CraftCms\Cms\Filesystem\Contracts\FsInterface;
use CraftCms\Cms\Filesystem\Filesystems\Temp;
use CraftCms\Cms\Image\Data\ImageTransform;
-use CraftCms\Cms\Image\FallbackTransformer;
use CraftCms\Cms\Image\ImageHelper;
use CraftCms\Cms\Support\DateTimeHelper;
use CraftCms\Cms\Support\Env;
@@ -158,11 +157,6 @@ public function getThumbUrl(Asset $asset, int $width, ?int $height = null, bool
$url = $asset->getUrl($transform);
- if (! $url) {
- $transform->setTransformer(FallbackTransformer::class);
- $url = $asset->getUrl($transform);
- }
-
if ($url === null) {
return $iconFallback ? Url::actionUrl('assets/icon', [
'extension' => $extension,
diff --git a/src/Asset/AssetsHelper.php b/src/Asset/AssetsHelper.php
index b21ea858548..85d36ac18d0 100644
--- a/src/Asset/AssetsHelper.php
+++ b/src/Asset/AssetsHelper.php
@@ -18,6 +18,7 @@
use CraftCms\Cms\Filesystem\Exceptions\FilesystemException;
use CraftCms\Cms\Filesystem\Exceptions\InvalidSubpathException;
use CraftCms\Cms\Filesystem\Filesystems\Temp;
+use CraftCms\Cms\Image\ImageTransformer;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Env;
use CraftCms\Cms\Support\Facades\Filesystems;
@@ -145,8 +146,9 @@ public static function revUrl(string $url, Asset $asset, ?DateTime $dateUpdated
$baseUrls->push(self::diskBaseUrl($volume->sourceDisk()));
}
- if ($volume->getTransformFs()->hasUrls) {
- $baseUrls->push(self::diskBaseUrl($volume->transformDisk()));
+ $imageTransformer = app(ImageTransformer::class);
+ if ($imageTransformer->getTransformFs($asset)->hasUrls) {
+ $baseUrls->push(self::diskBaseUrl($imageTransformer->transformDisk($asset)));
}
$baseUrls = $baseUrls
diff --git a/src/Asset/Commands/Concerns/IndexesAssets.php b/src/Asset/Commands/Concerns/IndexesAssets.php
index 9db1e9b9f52..07e1652bd19 100644
--- a/src/Asset/Commands/Concerns/IndexesAssets.php
+++ b/src/Asset/Commands/Concerns/IndexesAssets.php
@@ -4,6 +4,7 @@
namespace CraftCms\Cms\Asset\Commands\Concerns;
+use CraftCms\Cms\Asset\AssetTransforms;
use CraftCms\Cms\Asset\Data\IndexingSession;
use CraftCms\Cms\Asset\Data\Volume;
use CraftCms\Cms\Asset\Elements\Asset;
@@ -14,7 +15,6 @@
use CraftCms\Cms\Database\Table;
use CraftCms\Cms\Element\ElementCollection;
use CraftCms\Cms\Filesystem\Data\FsListing;
-use CraftCms\Cms\Image\ImageTransforms;
use CraftCms\Cms\Support\Facades\AssetIndexer;
use CraftCms\Cms\Support\Facades\Elements;
use CraftCms\Cms\Support\Facades\Folders;
@@ -113,7 +113,7 @@ function () use ($assetIds) {
$assets = Asset::find()->id($assetIds)->get();
foreach ($assets as $asset) {
- app(ImageTransforms::class)->deleteCreatedTransformsForAsset($asset);
+ app(AssetTransforms::class)->deleteCreatedTransformsForAsset($asset);
$asset->keepFileOnDelete = true;
Elements::deleteElement($asset);
}
diff --git a/src/Asset/Data/Volume.php b/src/Asset/Data/Volume.php
index 1352bb71e74..6224a53e1dc 100644
--- a/src/Asset/Data/Volume.php
+++ b/src/Asset/Data/Volume.php
@@ -33,7 +33,6 @@
* @property FsInterface $fs
* @property string $fsHandle
* @property string $subpath
- * @property string $transformSubpath
*/
#[Ruleset(VolumeRules::class)]
class Volume extends Component implements CpEditable, FieldLayoutProviderInterface
@@ -71,13 +70,6 @@ class Volume extends Component implements CpEditable, FieldLayoutProviderInterfa
}
}
- public ?string $transformFsHandle {
- get => $this->getTransformFsHandle();
- set {
- $this->setTransformFsHandle($value);
- }
- }
-
public ?string $subpath {
get => $this->getSubpath();
set {
@@ -85,25 +77,12 @@ class Volume extends Component implements CpEditable, FieldLayoutProviderInterfa
}
}
- public ?string $transformSubpath {
- get => $this->getTransformSubpath();
- set {
- $this->setTransformSubpath($value);
- }
- }
-
private string $_subpath = '';
- private string $_transformSubpath = '';
-
private ?FsInterface $_fs = null;
private ?string $_fsHandle = null;
- private ?FsInterface $_transformFs = null;
-
- private ?string $_transformFsHandle = null;
-
public function __construct(array|object $config = [])
{
if (is_object($config)) {
@@ -114,10 +93,6 @@ public function __construct(array|object $config = [])
$config['fsHandle'] = Arr::pull($config, 'fs');
}
- if (isset($config['transformFs']) && is_string($config['transformFs'])) {
- $config['transformFsHandle'] = Arr::pull($config, 'transformFs');
- }
-
parent::__construct($config);
}
@@ -142,8 +117,6 @@ public function validationData(): array
'fieldLayout' => $fieldLayout,
'fsHandle' => $this->getFsHandle(false),
'subpath' => $this->getSubpath(ensureTrailing: false, parse: false),
- 'transformFsHandle' => $this->getTransformFsHandle(false),
- 'transformSubpath' => $this->getTransformSubpath(ensureTrailing: false, parse: false),
]);
}
@@ -156,8 +129,6 @@ public function attributeLabels(): array
'url' => t('URL'),
'fsHandle' => t('Asset Filesystem'),
'subpath' => t('Subpath'),
- 'transformFsHandle' => t('Transform Filesystem'),
- 'transformSubpath' => t('Transform Subpath'),
];
}
@@ -368,58 +339,11 @@ public function setFsHandle(?string $handle): void
$this->_fs = null;
}
- public function getTransformFs(): FsInterface
- {
- if (! isset($this->_transformFs)) {
- if (! $this->getTransformFsHandle()) {
- return $this->getFs();
- }
-
- $target = $this->resolveStorageTargetKey($this->_transformFsHandle);
- $fs = $target !== null ? $this->filesystemFromTargetKey($target) : null;
- if (! $fs) {
- Log::error("Invalid transform filesystem handle: $this->_transformFsHandle for the $this->name volume.");
-
- return new MissingFs(['handle' => $this->_transformFsHandle]);
- }
-
- $this->_transformFs = $fs;
- }
-
- return $this->_transformFs;
- }
-
- public function setTransformFs(?FsInterface $fs): void
- {
- if ($fs) {
- $this->_transformFs = $fs;
- $this->_transformFsHandle = $fs->handle ?? null;
- } else {
- $this->_transformFsHandle = $this->_transformFs = null;
- }
- }
-
- public function getTransformFsHandle(bool $parse = true): ?string
- {
- return $this->parseStorageHandle($this->_transformFsHandle, $parse);
- }
-
- public function setTransformFsHandle(?string $handle): void
- {
- $this->_transformFsHandle = $this->normalizeStorageHandle($handle);
- $this->_transformFs = null;
- }
-
public function getResolvedFsTarget(bool $parse = true): ?string
{
return $this->resolveStorageTargetKey($this->_fsHandle, $parse);
}
- public function getResolvedTransformFsTarget(bool $parse = true): ?string
- {
- return $this->resolveStorageTargetKey($this->_transformFsHandle, $parse);
- }
-
public function getConfig(): array
{
$config = [
@@ -427,8 +351,6 @@ public function getConfig(): array
'handle' => $this->handle,
'fs' => $this->_fsHandle,
'subpath' => $this->_subpath,
- 'transformFs' => $this->_transformFsHandle,
- 'transformSubpath' => $this->_transformSubpath,
'titleTranslationMethod' => $this->titleTranslationMethod->value,
'titleTranslationKeyFormat' => $this->titleTranslationKeyFormat ?: null,
'altTranslationMethod' => $this->altTranslationMethod->value,
@@ -463,22 +385,6 @@ public function setSubpath(?string $subpath): void
$this->_subpath = $subpath ?? '';
}
- public function getTransformSubpath(bool $ensureTrailing = true, bool $parse = true): string
- {
- $subpath = $parse ? (Env::parse($this->_transformSubpath) ?? '') : $this->_transformSubpath;
-
- if ($ensureTrailing && $subpath !== '' && ! str_ends_with($subpath, '/')) {
- $subpath .= '/';
- }
-
- return $subpath;
- }
-
- public function setTransformSubpath(?string $subpath): void
- {
- $this->_transformSubpath = $subpath ?? '';
- }
-
public function sourceDisk(): FilesystemAdapter
{
return $this->storageDiskFor(
@@ -487,16 +393,6 @@ public function sourceDisk(): FilesystemAdapter
);
}
- public function transformDisk(): FilesystemAdapter
- {
- $hasTransformFs = (bool) $this->getTransformFsHandle(false);
-
- return $this->storageDiskFor(
- $this->diskNameForOperations($hasTransformFs ? $this->_transformFsHandle : $this->_fsHandle),
- $this->diskPrefix($this->_transformSubpath),
- );
- }
-
private function diskPrefix(?string $subpath = null): ?string
{
$subpath = Env::parse($subpath ?? $this->_subpath) ?? '';
diff --git a/src/Asset/Elements/Asset.php b/src/Asset/Elements/Asset.php
index 229d4fb7c0d..3e50787d141 100644
--- a/src/Asset/Elements/Asset.php
+++ b/src/Asset/Elements/Asset.php
@@ -16,6 +16,7 @@
use CraftCms\Cms\Asset\Actions\ReplaceFile;
use CraftCms\Cms\Asset\Actions\ShowInFolder;
use CraftCms\Cms\Asset\AssetsHelper;
+use CraftCms\Cms\Asset\AssetTransforms;
use CraftCms\Cms\Asset\Concerns\LegacyConstants;
use CraftCms\Cms\Asset\Conditions\AssetCondition;
use CraftCms\Cms\Asset\Data\Volume;
@@ -1934,11 +1935,7 @@ private function _url(mixed $transform = null, ?bool $immediately = null): ?stri
if (
$transform && (
- // if it's a site request - check the mime type and general settings and decide whether to nullify the transform
- // otherwise - we can proceed and rely on the FallbackTransformer (e.g. for thumbs in the CP)
- // see https://github.com/craftcms/cms/issues/13306 and https://github.com/craftcms/cms/issues/13624 for more info
- (request()->isSiteRequest() && ! $this->allowTransforms()) ||
- ! ImageHelper::canManipulateAsImage(pathinfo($this->getFilename(), PATHINFO_EXTENSION))
+ request()->isSiteRequest() && ! $this->allowTransforms()
)
) {
$transform = null;
@@ -1967,11 +1964,15 @@ private function _url(mixed $transform = null, ?bool $immediately = null): ?stri
return Html::encodeSpaces($event->url);
}
- $imageTransformer = $transform->getImageTransformer();
+ $transformerHandle = $transform->getTransformer() ?? $volume->getFs()->getDefaultTransformer();
+ $transformer = app(AssetTransforms::class)->getAssetTransformer($transformerHandle);
+ $transform->setTransformer(app(AssetTransforms::class)->resolveTransformerHandle($transformerHandle));
try {
- $url = Html::encodeSpaces($imageTransformer->getTransformUrl($this, $transform, $immediately));
+ $url = Html::encodeSpaces($transformer->getTransformUrl($this, $transform, $immediately));
} catch (NotSupportedException) {
+ Log::warning('Asset transformer doesn’t support this asset or transform.', [__METHOD__]);
+
return null;
} catch (ImageTransformException $e) {
Log::warning("Couldn’t get image transform URL: {$e->getMessage()}", [__METHOD__]);
@@ -3033,7 +3034,7 @@ public function afterDelete(): void
}
}
- app(ImageTransforms::class)->deleteAllTransformData($this);
+ app(AssetTransforms::class)->deleteAllTransformData($this);
parent::afterDelete();
}
@@ -3256,7 +3257,7 @@ private function _relocateFile(): void
if ($this->folderId) {
// Nuke the transforms
- app(ImageTransforms::class)->deleteAllTransformData($this);
+ app(AssetTransforms::class)->deleteAllTransformData($this);
}
// Update file properties
diff --git a/src/Asset/Validation/VolumeRules.php b/src/Asset/Validation/VolumeRules.php
index e0d28660154..050ee486a64 100644
--- a/src/Asset/Validation/VolumeRules.php
+++ b/src/Asset/Validation/VolumeRules.php
@@ -47,7 +47,6 @@ public function rules(): array
],
'fieldLayout' => [fn (string $attribute, mixed $value, Closure $fail) => $this->subject->validateFieldLayout()],
'fsHandle' => [fn (string $attribute, mixed $value, Closure $fail) => $this->validateFilesystemHandle($attribute, $fail)],
- 'transformFsHandle' => ['nullable', fn (string $attribute, mixed $value, Closure $fail) => $this->validateFilesystemHandle($attribute, $fail)],
'subpath' => [
Rule::requiredIf(fn () => $this->subpathRequired()),
fn (string $attribute, mixed $value, Closure $fail) => $this->validateUniqueSubpath($attribute, $fail),
@@ -58,7 +57,6 @@ public function rules(): array
if ($tempAssetUploadTarget !== null) {
$rules['fsHandle'][] = fn (string $attribute, mixed $value, Closure $fail) => $this->validateReservedTempUploadFilesystem($attribute, $tempAssetUploadTarget, $fail);
- $rules['transformFsHandle'][] = fn (string $attribute, mixed $value, Closure $fail) => $this->validateReservedTempUploadFilesystem($attribute, $tempAssetUploadTarget, $fail);
}
return $rules;
@@ -171,7 +169,6 @@ private function storageHandleForAttribute(string $attribute): ?string
{
return match ($attribute) {
'fsHandle' => $this->subject->getFsHandle(false),
- 'transformFsHandle' => $this->subject->getTransformFsHandle(false),
default => null,
};
}
diff --git a/src/Asset/Volumes.php b/src/Asset/Volumes.php
index aefd9f9f21e..48147bec683 100644
--- a/src/Asset/Volumes.php
+++ b/src/Asset/Volumes.php
@@ -169,8 +169,6 @@ public function handleChangedVolume(ConfigEvent $event): void
$volumeModel->handle = $data['handle'];
$volumeModel->fs = $data['fs'] ?? null;
$volumeModel->subpath = $data['subpath'] ?? null;
- $volumeModel->transformFs = $data['transformFs'] ?? null;
- $volumeModel->transformSubpath = $data['transformSubpath'] ?? null;
$volumeModel->sortOrder = $data['sortOrder'];
$volumeModel->titleTranslationMethod = $data['titleTranslationMethod'] ?? TranslationMethod::Site->value;
$volumeModel->titleTranslationKeyFormat = $data['titleTranslationKeyFormat'] ?? null;
@@ -339,7 +337,14 @@ private function volumes(): Collection
->orderBy('sortOrder')
->get()
->map(fn ($result) => new Volume(
- Arr::except((array) $result, ['dateCreated', 'dateUpdated', 'dateDeleted'])
+ Arr::except((array) $result, [
+ 'dateCreated',
+ 'dateUpdated',
+ 'dateDeleted',
+ 'defaultTransformer',
+ 'transformFs',
+ 'transformSubpath',
+ ])
))
->values();
}
diff --git a/src/Cms.php b/src/Cms.php
index 79a8cbc70bc..b9a4dedaf69 100644
--- a/src/Cms.php
+++ b/src/Cms.php
@@ -30,7 +30,7 @@
public const string VERSION = '6.0.0-alpha.3';
- public const string SCHEMA_VERSION = '6.0.0.2';
+ public const string SCHEMA_VERSION = '6.0.0.3';
public const string MIN_VERSION_REQUIRED = '5.9.0';
diff --git a/src/Cp/Navigation.php b/src/Cp/Navigation.php
index 008dcc76186..f0524089912 100644
--- a/src/Cp/Navigation.php
+++ b/src/Cp/Navigation.php
@@ -11,6 +11,7 @@
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Plugin\Plugins;
use CraftCms\Cms\Support\Facades\Sections;
+use CraftCms\Cms\Support\Facades\Updates;
use CraftCms\Cms\Support\Facades\Volumes;
use CraftCms\Cms\Support\Str;
use CraftCms\Cms\Support\Url;
@@ -34,6 +35,10 @@ public function __construct(
public function getItems(): array
{
+ if (Updates::isUpdatePending()) {
+ return [];
+ }
+
$user = Auth::user();
$isAdmin = $user?->isAdmin();
diff --git a/src/Database/Migrations/2026_05_14_000000_add_asset_transformer_settings.php b/src/Database/Migrations/2026_05_14_000000_add_asset_transformer_settings.php
new file mode 100644
index 00000000000..cb31522e50f
--- /dev/null
+++ b/src/Database/Migrations/2026_05_14_000000_add_asset_transformer_settings.php
@@ -0,0 +1,85 @@
+removeVolumeTransformerProjectConfig();
+
+ if (! Schema::hasColumn(Table::IMAGETRANSFORMS, 'transformer')) {
+ Schema::table(Table::IMAGETRANSFORMS, function (Blueprint $table) {
+ $table->string('transformer')->nullable()->after('fill');
+ });
+ }
+
+ if (! Schema::hasColumn(Table::IMAGETRANSFORMS, 'settings')) {
+ Schema::table(Table::IMAGETRANSFORMS, function (Blueprint $table) {
+ $table->json('settings')->nullable()->after('transformer');
+ });
+ }
+
+ foreach (['transformSubpath', 'transformFs'] as $column) {
+ if (Schema::hasColumn(Table::VOLUMES, $column)) {
+ Schema::dropColumns(Table::VOLUMES, $column);
+ }
+ }
+ }
+
+ public function down(): void
+ {
+ if (Schema::hasColumn(Table::IMAGETRANSFORMS, 'settings')) {
+ Schema::dropColumns(Table::IMAGETRANSFORMS, 'settings');
+ }
+
+ if (Schema::hasColumn(Table::IMAGETRANSFORMS, 'transformer')) {
+ Schema::dropColumns(Table::IMAGETRANSFORMS, 'transformer');
+ }
+
+ if (! Schema::hasColumn(Table::VOLUMES, 'transformFs')) {
+ Schema::table(Table::VOLUMES, function (Blueprint $table) {
+ $table->string('transformFs')->nullable()->after('subpath');
+ });
+ }
+
+ if (! Schema::hasColumn(Table::VOLUMES, 'transformSubpath')) {
+ Schema::table(Table::VOLUMES, function (Blueprint $table) {
+ $table->string('transformSubpath')->nullable()->after('transformFs');
+ });
+ }
+ }
+
+ private function removeVolumeTransformerProjectConfig(): void
+ {
+ $projectConfig = app(ProjectConfig::class);
+ $muteEvents = $projectConfig->muteEvents;
+ $projectConfig->muteEvents = true;
+
+ try {
+ $volumeConfigs = $projectConfig->get(ProjectConfig::PATH_VOLUMES) ?? [];
+
+ if (! is_array($volumeConfigs)) {
+ return;
+ }
+
+ foreach ($volumeConfigs as &$volumeConfig) {
+ if (is_array($volumeConfig)) {
+ unset($volumeConfig['transformFs'], $volumeConfig['transformSubpath']);
+ }
+ }
+
+ unset($volumeConfig);
+
+ $projectConfig->set(ProjectConfig::PATH_VOLUMES, $volumeConfigs, 'Remove volume asset transformer settings');
+ } finally {
+ $projectConfig->muteEvents = $muteEvents;
+ }
+ }
+};
diff --git a/src/Database/Migrations/Install.php b/src/Database/Migrations/Install.php
index 2e0d53c5c59..1a93025aec4 100644
--- a/src/Database/Migrations/Install.php
+++ b/src/Database/Migrations/Install.php
@@ -337,6 +337,8 @@ public function createTables(?Logger $logger = null): void
$table->integer('quality')->nullable();
$table->enum('interlace', ['none', 'line', 'plane', 'partition'])->default('none');
$table->string('fill', 11)->nullable()->default(null);
+ $table->string('transformer')->nullable();
+ $table->json('settings')->nullable();
$table->boolean('upscale')->default(true);
$table->dateTime('parameterChangeTime')->nullable();
$table->dateTime('dateCreated');
@@ -968,8 +970,6 @@ public function createTables(?Logger $logger = null): void
$table->string('handle');
$table->string('fs');
$table->string('subpath')->nullable();
- $table->string('transformFs')->nullable();
- $table->string('transformSubpath')->nullable();
$table->string('titleTranslationMethod')->default(Field::TRANSLATION_METHOD_SITE);
$table->text('titleTranslationKeyFormat')->nullable();
$table->string('altTranslationMethod')->default(Field::TRANSLATION_METHOD_SITE);
diff --git a/src/Element/Queries/Concerns/Asset/EagerloadsTransforms.php b/src/Element/Queries/Concerns/Asset/EagerloadsTransforms.php
index 5786549d3ad..b7cd7b40580 100644
--- a/src/Element/Queries/Concerns/Asset/EagerloadsTransforms.php
+++ b/src/Element/Queries/Concerns/Asset/EagerloadsTransforms.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Queries\Concerns\Asset;
-use CraftCms\Cms\Support\Facades\ImageTransforms;
+use CraftCms\Cms\Support\Facades\AssetTransforms;
use Illuminate\Support\Collection;
/**
@@ -57,7 +57,7 @@ protected function initEagerloadsTransforms(): void
: [$transforms];
}
- ImageTransforms::eagerLoadTransforms($result->all(), $transforms);
+ AssetTransforms::eagerLoadTransforms($result->all(), $transforms);
return $result;
});
diff --git a/src/Filesystem/Contracts/FsInterface.php b/src/Filesystem/Contracts/FsInterface.php
index 1422d38e372..461b23ccd5b 100644
--- a/src/Filesystem/Contracts/FsInterface.php
+++ b/src/Filesystem/Contracts/FsInterface.php
@@ -18,6 +18,8 @@
* @property bool $hasUrls
* @property string|null $url
* @property string|null $uid
+ * @property string|null $defaultTransformer
+ * @property array> $transformerSettings
*
* @phpstan-require-extends Filesystem
*/
@@ -39,4 +41,23 @@ public function getShowHasUrlSetting(): bool;
* Returns whether the “Base URL” setting should be shown.
*/
public function getShowUrlSetting(): bool;
+
+ public function getDefaultTransformer(bool $resolve = true): ?string;
+
+ public function setDefaultTransformer(?string $handle): void;
+
+ /**
+ * @return array
+ */
+ public function getTransformerSettings(?string $handle = null): array;
+
+ /**
+ * @param array $settings
+ */
+ public function setTransformerSettings(string $handle, array $settings): void;
+
+ /**
+ * @return array>
+ */
+ public function getAllTransformerSettings(): array;
}
diff --git a/src/Filesystem/Filesystems.php b/src/Filesystem/Filesystems.php
index 22e2c7be0d7..9d643ae1a3a 100644
--- a/src/Filesystem/Filesystems.php
+++ b/src/Filesystem/Filesystems.php
@@ -54,6 +54,14 @@ public function createFilesystemConfig(FsInterface $fs): array
'settings' => ProjectConfigHelper::packAssociativeArrays($fs->getSettings()),
];
+ if ($fs->getDefaultTransformer(false) !== null) {
+ $config['defaultTransformer'] = $fs->getDefaultTransformer(false);
+ }
+
+ if ($fs->getAllTransformerSettings() !== []) {
+ $config['transformerSettings'] = ProjectConfigHelper::packAssociativeArrays($fs->getAllTransformerSettings());
+ }
+
if ($fs->getShowHasUrlSetting()) {
$config['hasUrls'] = $fs->hasUrls;
}
@@ -92,8 +100,10 @@ private function filesystems(): Collection
$filesystems = collect($this->projectConfig->get(ProjectConfig::PATH_FS) ?? [])
->mapWithKeys(function (array $config, string $handle): array {
+ $config = ProjectConfigHelper::unpackAssociativeArray($config);
$config['handle'] = $handle;
$config['settings'] = ProjectConfigHelper::unpackAssociativeArrays($config['settings'] ?? []);
+ $config['transformerSettings'] = ProjectConfigHelper::unpackAssociativeArrays($config['transformerSettings'] ?? []);
return [$handle => $this->createFilesystem($config)];
});
@@ -224,16 +234,31 @@ public function saveFilesystem(FsInterface $fs, bool $runValidation = true): boo
$changed = true;
}
- if ($volume->getTransformFsHandle(false) === $fs->oldHandle) {
- $volume->setTransformFsHandle($fs->handle);
- $changed = true;
- }
-
if ($changed) {
Volumes::saveVolume($volume);
}
}
+ $fsConfigs = $this->projectConfig->get(ProjectConfig::PATH_FS) ?? [];
+ if (is_array($fsConfigs)) {
+ foreach ($fsConfigs as $handle => $fsConfig) {
+ if (! is_string($handle)) {
+ continue;
+ }
+ if (! is_array($fsConfig)) {
+ continue;
+ }
+ $transformFsHandle = $fsConfig['transformerSettings']['craft']['transformFsHandle'] ?? null;
+ if ($transformFsHandle === $fs->oldHandle) {
+ $this->projectConfig->set(
+ sprintf('%s.%s.transformerSettings.craft.transformFsHandle', ProjectConfig::PATH_FS, $handle),
+ $fs->handle,
+ "Update transform filesystem references to “{$fs->handle}”",
+ );
+ }
+ }
+ }
+
event(new FilesystemRenamed($fs));
}
}
@@ -332,7 +357,7 @@ public function resolve(string $handle): ?FsInterface
if (str_starts_with($handle, 'disk:')) {
$diskName = substr($handle, strlen('disk:'));
if ($diskName !== '' && $this->diskExists($diskName)) {
- return new DiskFilesystem(['disk' => $diskName]);
+ return $this->diskFilesystem($diskName);
}
return null;
@@ -344,7 +369,7 @@ public function resolve(string $handle): ?FsInterface
}
if ($this->diskExists($handle)) {
- return new DiskFilesystem(['disk' => $handle]);
+ return $this->diskFilesystem($handle);
}
return null;
@@ -372,6 +397,24 @@ public function resolveDiskName(string $handle): ?string
return null;
}
+ private function diskFilesystem(string $diskName): DiskFilesystem
+ {
+ $url = config("filesystems.disks.$diskName.url");
+ if (is_string($url) && $url !== '') {
+ $url = rtrim($url, '/');
+ } else {
+ $url = null;
+ }
+
+ return new DiskFilesystem([
+ 'disk' => $diskName,
+ 'name' => $diskName,
+ 'handle' => "disk:$diskName",
+ 'hasUrls' => $url !== null,
+ 'url' => $url,
+ ]);
+ }
+
public function reset(): void
{
$this->filesystems = null;
@@ -463,8 +506,10 @@ private function resolveDiskConfig(string $handle): ?array
*/
private function resolveDiskConfigFromArray(string $handle, array $filesystemConfig): ?array
{
+ $filesystemConfig = ProjectConfigHelper::unpackAssociativeArray($filesystemConfig);
$filesystemConfig['handle'] = $handle;
$filesystemConfig['settings'] = ProjectConfigHelper::unpackAssociativeArrays($filesystemConfig['settings'] ?? []);
+ $filesystemConfig['transformerSettings'] = ProjectConfigHelper::unpackAssociativeArrays($filesystemConfig['transformerSettings'] ?? []);
try {
return $this->createFilesystem($filesystemConfig)->getDiskConfig();
diff --git a/src/Filesystem/Filesystems/Filesystem.php b/src/Filesystem/Filesystems/Filesystem.php
index 027784e6f5a..9068d835e71 100644
--- a/src/Filesystem/Filesystems/Filesystem.php
+++ b/src/Filesystem/Filesystems/Filesystem.php
@@ -4,10 +4,12 @@
namespace CraftCms\Cms\Filesystem\Filesystems;
+use CraftCms\Cms\Asset\AssetTransforms;
use CraftCms\Cms\Component\Component;
use CraftCms\Cms\Component\Concerns\ConfigurableComponent;
use CraftCms\Cms\Component\Concerns\SavableComponent;
use CraftCms\Cms\Filesystem\Contracts\FsInterface;
+use CraftCms\Cms\Image\Data\ImageTransform;
use CraftCms\Cms\Support\Env;
use CraftCms\Cms\Validation\Rules\HandleRule;
use Illuminate\Validation\Rule;
@@ -52,6 +54,30 @@ abstract class Filesystem extends Component implements FsInterface
public ?string $uid = null;
+ public ?string $defaultTransformer {
+ get => $this->getDefaultTransformer();
+ set {
+ $this->setDefaultTransformer($value);
+ }
+ }
+
+ /**
+ * @var array>
+ */
+ public array $transformerSettings {
+ get => $this->getAllTransformerSettings();
+ set {
+ $this->_transformerSettings = $value;
+ }
+ }
+
+ private ?string $_defaultTransformer = null;
+
+ /**
+ * @var array>
+ */
+ private array $_transformerSettings = [];
+
public ?string $rootUrl {
get => $this->getRootUrl();
set {
@@ -85,6 +111,7 @@ public function attributeLabels(): array
'handle' => t('Handle'),
'name' => t('Name'),
'url' => t('Base URL'),
+ 'defaultTransformer' => t('Default Transformer'),
];
}
@@ -123,6 +150,38 @@ public function getRules(): array
'max:255',
Rule::requiredIf(fn () => $this->hasUrls && $this->getShowUrlSetting()),
],
+ 'defaultTransformer' => ['nullable', 'string'],
];
}
+
+ public function getDefaultTransformer(bool $resolve = true): ?string
+ {
+ if (! $resolve) {
+ return $this->_defaultTransformer;
+ }
+
+ return app(AssetTransforms::class)->resolveTransformerHandle($this->_defaultTransformer);
+ }
+
+ public function setDefaultTransformer(?string $handle): void
+ {
+ $this->_defaultTransformer = $handle ?: null;
+ }
+
+ public function getTransformerSettings(?string $handle = null): array
+ {
+ $handle ??= ImageTransform::DEFAULT_TRANSFORMER;
+
+ return $this->_transformerSettings[$handle] ?? [];
+ }
+
+ public function setTransformerSettings(string $handle, array $settings): void
+ {
+ $this->_transformerSettings[$handle] = $settings;
+ }
+
+ public function getAllTransformerSettings(): array
+ {
+ return $this->_transformerSettings;
+ }
}
diff --git a/src/Filesystem/Filesystems/Local.php b/src/Filesystem/Filesystems/Local.php
index f1d71e84fdb..bd73fbcde37 100644
--- a/src/Filesystem/Filesystems/Local.php
+++ b/src/Filesystem/Filesystems/Local.php
@@ -37,18 +37,6 @@ class Local extends Filesystem
],
];
- public ?string $settingsHtml {
- get => $this->getSettingsHtml();
- set {
- }
- }
-
- public string $rootPath {
- get => $this->getRootPath();
- set {
- }
- }
-
#[Override]
public static function displayName(): string
{
@@ -65,6 +53,8 @@ public static function displayName(): string
*/
public function __construct($config = [])
{
+ unset($config['rootPath'], $config['settingsHtml']);
+
// Config normalization
if (isset($config['path'])) {
$config['path'] = rtrim(str_replace('\\', '/', $config['path']), '/');
diff --git a/src/Gql/Arguments/Transform.php b/src/Gql/Arguments/Transform.php
index 05ce2e1f34a..b2d2d8bb6d8 100644
--- a/src/Gql/Arguments/Transform.php
+++ b/src/Gql/Arguments/Transform.php
@@ -4,6 +4,8 @@
namespace CraftCms\Cms\Gql\Arguments;
+use CraftCms\Cms\Asset\Exceptions\ImageTransformException;
+use CraftCms\Cms\Support\Facades\AssetTransforms;
use GraphQL\Type\Definition\Type;
class Transform extends Arguments
@@ -11,7 +13,7 @@ class Transform extends Arguments
#[\Override]
public static function getArguments(): array
{
- return [
+ $arguments = [
'handle' => [
'name' => 'handle',
'type' => Type::string(),
@@ -57,11 +59,31 @@ public static function getArguments(): array
'type' => Type::string(),
'description' => 'The format to use for the transform',
],
+ 'transformer' => [
+ 'name' => 'transformer',
+ 'type' => Type::string(),
+ 'description' => 'The transformer handle to use for the transform',
+ ],
'immediately' => [
'name' => 'immediately',
'type' => Type::boolean(),
'description' => 'Whether the transform should be generated immediately or only when the image is requested used the generated URL',
],
];
+
+ foreach (AssetTransforms::getAllAssetTransformers() as $transformerHandle => $transformerClass) {
+ foreach ($transformerClass::gqlArguments() as $name => $argument) {
+ if (isset($arguments[$name])) {
+ throw new ImageTransformException("The `$transformerHandle` asset transformer defines a GraphQL transform argument that already exists: $name");
+ }
+
+ $arguments[$name] = [
+ 'name' => $name,
+ ...$argument,
+ ];
+ }
+ }
+
+ return $arguments;
}
}
diff --git a/src/Http/Controllers/Assets/ImageEditorController.php b/src/Http/Controllers/Assets/ImageEditorController.php
index d82ec409af0..66ab0f2b72f 100644
--- a/src/Http/Controllers/Assets/ImageEditorController.php
+++ b/src/Http/Controllers/Assets/ImageEditorController.php
@@ -5,6 +5,7 @@
namespace CraftCms\Cms\Http\Controllers\Assets;
use CraftCms\Cms\Asset\Assets;
+use CraftCms\Cms\Asset\AssetTransforms;
use CraftCms\Cms\Asset\Concerns\EnforcesVolumePermissions;
use CraftCms\Cms\Asset\Elements\Asset;
use CraftCms\Cms\Asset\Validation\AssetRules;
@@ -13,7 +14,6 @@
use CraftCms\Cms\Http\RespondsWithFlash;
use CraftCms\Cms\Image\ImageTransformer;
use CraftCms\Cms\Image\ImageTransformHelper;
-use CraftCms\Cms\Image\ImageTransforms;
use CraftCms\Cms\Shared\Exceptions\NotSupportedException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -206,7 +206,7 @@ public function save(Request $request, Elements $elements): Response
$asset->setFocalPoint($focal);
if ($focalChanged) {
- app(ImageTransforms::class)->deleteCreatedTransformsForAsset($asset);
+ app(AssetTransforms::class)->deleteCreatedTransformsForAsset($asset);
}
// Only replace file if it changed, otherwise just save changed focal points
@@ -237,7 +237,7 @@ public function save(Request $request, Elements $elements): Response
return $this->asSuccess(data: $output);
}
- public function updateFocalPoint(Request $request, Elements $elements, ImageTransforms $imageTransforms): Response
+ public function updateFocalPoint(Request $request, Elements $elements, AssetTransforms $assetTransforms): Response
{
$request->validate([
'assetUid' => ['required', 'string'],
@@ -264,7 +264,7 @@ public function updateFocalPoint(Request $request, Elements $elements, ImageTran
$asset->setFocalPoint($focalData);
$elements->saveElement($asset);
- $imageTransforms->deleteCreatedTransformsForAsset($asset);
+ $assetTransforms->deleteCreatedTransformsForAsset($asset);
return $this->asSuccess();
}
diff --git a/src/Http/Controllers/Assets/TransformController.php b/src/Http/Controllers/Assets/TransformController.php
index 0b039bd748f..6d1b24e0037 100644
--- a/src/Http/Controllers/Assets/TransformController.php
+++ b/src/Http/Controllers/Assets/TransformController.php
@@ -5,6 +5,7 @@
namespace CraftCms\Cms\Http\Controllers\Assets;
use CraftCms\Aliases\Aliases;
+use CraftCms\Cms\Asset\AssetTransforms;
use CraftCms\Cms\Asset\Elements\Asset;
use CraftCms\Cms\Auth\Concerns\EnforcesPermissions;
use CraftCms\Cms\Http\RespondsWithFlash;
@@ -29,15 +30,38 @@
use EnforcesPermissions;
use RespondsWithFlash;
- public function generate(Request $request): Response
+ public function generate(Request $request, AssetTransforms $assetTransforms): Response
{
- if ($transformId = $request->integer('transformId')) {
+ $transformIndexModel = null;
+ $transformer = null;
+ $hasValidTransformToken = false;
+
+ $transformId = $request->integer('transformId');
+ if (! $transformId && $request->filled('transformToken')) {
+ $token = (string) $request->input('transformToken');
+ $signature = (string) $request->input('transformSignature');
+ $expectedSignature = hash_hmac('sha256', $token, (string) config('app.key'));
+
+ if (! hash_equals($expectedSignature, $signature)) {
+ abort(400, 'Invalid transform signature.');
+ }
+
+ try {
+ $transformId = (int) Crypt::decryptString($token);
+ $hasValidTransformToken = true;
+ } catch (Throwable) {
+ abort(400, 'Invalid transform token.');
+ }
+ }
+
+ if ($transformId) {
$transformer = new ImageTransformer;
$transformIndexModel = $transformer->getTransformIndexModelById($transformId);
abort_if(! $transformIndexModel, 400, "Invalid transform ID: $transformId");
$assetId = $transformIndexModel->assetId;
try {
$transform = $transformIndexModel->getTransform();
+ $transformer = $assetTransforms->getAssetTransformer($transformIndexModel->transformer);
} catch (Throwable $e) {
abort(500, 'Image transform cannot be created.', ['exception' => $e]);
}
@@ -53,13 +77,32 @@ public function generate(Request $request): Response
abort(500, 'Image transform cannot be created.', ['exception' => $e]);
}
abort_if(! $transform, 400, "Invalid transform handle: $handle");
- $transformer = $transform->getImageTransformer();
}
$asset = Asset::findOne(['id' => $assetId]);
abort_if(! $asset, 400, "Invalid asset ID: $assetId");
+ $transformer ??= $assetTransforms->getAssetTransformer(
+ $transform->getTransformer() ?? $asset->getVolume()->getFs()->getDefaultTransformer(),
+ );
+
+ if (
+ $transformIndexModel !== null &&
+ $transformer instanceof ImageTransformer &&
+ ! $transformer->getTransformFs($asset)->hasUrls
+ ) {
+ if (! $hasValidTransformToken) {
+ $this->requirePermission('accessCp');
+ }
+
+ try {
+ return $transformer->getTransformResponse($asset, $transformIndexModel);
+ } catch (Throwable $e) {
+ return $this->asBrokenImage($e);
+ }
+ }
+
try {
$url = $transformer->getTransformUrl($asset, $transform, true);
} catch (Throwable $e) {
diff --git a/src/Http/Controllers/Settings/FilesystemsController.php b/src/Http/Controllers/Settings/FilesystemsController.php
index 9c86152b1c0..5cb5307db15 100644
--- a/src/Http/Controllers/Settings/FilesystemsController.php
+++ b/src/Http/Controllers/Settings/FilesystemsController.php
@@ -4,8 +4,10 @@
namespace CraftCms\Cms\Http\Controllers\Settings;
+use CraftCms\Cms\Asset\AssetTransforms;
use CraftCms\Cms\Config\GeneralConfig;
use CraftCms\Cms\Cp\Html\ContentHtml;
+use CraftCms\Cms\Cp\SelectOptions;
use CraftCms\Cms\Filesystem\Contracts\FsInterface;
use CraftCms\Cms\Filesystem\Filesystems;
use CraftCms\Cms\Http\RespondsWithFlash;
@@ -27,6 +29,7 @@ class FilesystemsController
public function __construct(
GeneralConfig $generalConfig,
private readonly Filesystems $filesystems,
+ private readonly AssetTransforms $assetTransforms,
) {
$this->readOnly = ! $generalConfig->allowAdminChanges;
}
@@ -97,6 +100,10 @@ public function edit(?string $handle = null): CpScreenResponse
'fsOptions' => $fsOptions,
'fsInstances' => $fsInstances,
'fsTypes' => $allFsTypes,
+ 'transformerOptions' => $this->transformerOptions(),
+ 'transformerSettingsHtml' => $this->assetTransforms
+ ->getAssetTransformer(request()->input('defaultTransformer', $filesystem->getDefaultTransformer(false) ?? 'craft'))
+ ->getFilesystemSettingsHtml($filesystem, $this->readOnly),
'readOnly' => $this->readOnly,
])
->unless(
@@ -121,6 +128,21 @@ public function save(Request $request): Response
{
$type = $request->input('type');
$settings = Arr::whereNotNull($request->array('types.'.Html::id($type)));
+ $defaultTransformer = $request->input('defaultTransformer') ?: null;
+ $selectedTransformer = $this->assetTransforms->resolveTransformerHandle($defaultTransformer);
+ $oldHandle = $request->input('oldHandle');
+ $existingFilesystem = is_string($oldHandle) && $oldHandle !== ''
+ ? $this->filesystems->getFilesystemByHandle($oldHandle)
+ : null;
+ $transformerSettings = $existingFilesystem?->getAllTransformerSettings() ?? [];
+ $postedTransformerSettings = $request->array('transformerSettings')[$selectedTransformer] ?? [];
+
+ if ($postedTransformerSettings !== []) {
+ $transformerSettings[$selectedTransformer] = array_merge(
+ $transformerSettings[$selectedTransformer] ?? [],
+ $postedTransformerSettings,
+ );
+ }
$fs = $this->filesystems->createFilesystem([
'type' => $type,
@@ -128,6 +150,8 @@ public function save(Request $request): Response
'handle' => $request->input('handle'),
'oldHandle' => $request->input('oldHandle'),
'settings' => $settings,
+ 'defaultTransformer' => $defaultTransformer,
+ 'transformerSettings' => $transformerSettings,
]);
if (! $this->filesystems->saveFilesystem($fs)) {
@@ -149,4 +173,19 @@ public function delete(Request $request): Response
return $this->asSuccess();
}
+
+ private function transformerOptions(): array
+ {
+ $transformers = $this->assetTransforms->getAllAssetTransformers();
+ $options = collect($transformers)
+ ->map(fn (mixed $class, string $handle): array => [
+ 'label' => $class::displayName(),
+ 'value' => $handle,
+ ])
+ ->sortBy('label')
+ ->values()
+ ->all();
+
+ return array_merge($options, SelectOptions::getEnvOptions(array_keys($transformers)));
+ }
}
diff --git a/src/Http/Controllers/Settings/ImageTransformsController.php b/src/Http/Controllers/Settings/ImageTransformsController.php
index f737f029f12..4c268c2db12 100644
--- a/src/Http/Controllers/Settings/ImageTransformsController.php
+++ b/src/Http/Controllers/Settings/ImageTransformsController.php
@@ -4,6 +4,7 @@
namespace CraftCms\Cms\Http\Controllers\Settings;
+use CraftCms\Cms\Asset\AssetTransforms;
use CraftCms\Cms\Config\GeneralConfig;
use CraftCms\Cms\Http\RespondsWithFlash;
use CraftCms\Cms\Image\Data\ImageTransform;
@@ -73,6 +74,8 @@ public function save(Request $request, ImageTransforms $imageTransforms): Respon
: null;
$transform->interlace = (string) $request->input('interlace', $transform->interlace);
$transform->format = $request->input('format');
+ $transform->setTransformer($request->input('transformer'));
+ $transform->settings = $request->input('settings', []);
$transform->fill = ($fill = $request->input('fill')) !== '' && ! is_null($fill)
? (string) $fill
: null;
@@ -126,6 +129,10 @@ private function editView(?string $transformHandle = null, ?ImageTransform $tran
'handle' => $transformHandle,
'transform' => $transform,
'title' => $title,
+ 'transformerOptions' => $this->transformerOptions(app(AssetTransforms::class)),
+ 'transformerSettingsHtml' => app(AssetTransforms::class)
+ ->getAssetTransformer($transform->getTransformer())
+ ->getImageTransformSettingsHtml($transform, $this->readOnly),
'qualityPickerOptions' => $qualityPickerOptions,
'qualityPickerValue' => $qualityPickerValue,
'readOnly' => $this->readOnly,
@@ -133,6 +140,23 @@ private function editView(?string $transformHandle = null, ?ImageTransform $tran
]);
}
+ private function transformerOptions(AssetTransforms $assetTransforms): array
+ {
+ $options = [[
+ 'label' => t('Volume default'),
+ 'value' => '',
+ ]];
+
+ foreach ($assetTransforms->getAllAssetTransformers() as $handle => $class) {
+ $options[] = [
+ 'label' => $class::displayName(),
+ 'value' => $handle,
+ ];
+ }
+
+ return $options;
+ }
+
/**
* @return array{0: array, 1: int}
*/
diff --git a/src/Http/Controllers/Settings/UserSettingsController.php b/src/Http/Controllers/Settings/UserSettingsController.php
index ddd68a837be..c23d70ad4b5 100644
--- a/src/Http/Controllers/Settings/UserSettingsController.php
+++ b/src/Http/Controllers/Settings/UserSettingsController.php
@@ -6,6 +6,7 @@
use CraftCms\Cms\Config\GeneralConfig;
use CraftCms\Cms\Edition;
+use CraftCms\Cms\Image\ImageTransformer;
use CraftCms\Cms\ProjectConfig\ProjectConfig;
use CraftCms\Cms\Support\Facades\Volumes;
use CraftCms\Cms\Support\Flash;
@@ -20,6 +21,7 @@
public function __construct(
private ProjectConfig $projectConfig,
private GeneralConfig $generalConfig,
+ private ImageTransformer $imageTransformer,
) {}
public function index(): View
@@ -27,6 +29,7 @@ public function index(): View
return view('settings/users/_settings', [
'readOnly' => ! $this->generalConfig->allowAdminChanges,
'settings' => $this->projectConfig->get('users') ?? [],
+ 'imageTransformer' => $this->imageTransformer,
]);
}
diff --git a/src/Http/Controllers/Settings/VolumesController.php b/src/Http/Controllers/Settings/VolumesController.php
index 7e29d460ec0..6a1595c560c 100644
--- a/src/Http/Controllers/Settings/VolumesController.php
+++ b/src/Http/Controllers/Settings/VolumesController.php
@@ -153,8 +153,6 @@ public function save(Request $request, Volumes $volumes, Fields $fields): Respon
'handle' => $request->input('handle'),
'fsHandle' => $request->input('fsHandle'),
'subpath' => $subpath ?? null,
- 'transformFsHandle' => $request->input('transformFsHandle'),
- 'transformSubpath' => $request->input('transformSubpath', ''),
'titleTranslationMethod' => $request->enum('titleTranslationMethod', TranslationMethod::class, TranslationMethod::Site),
'titleTranslationKeyFormat' => $request->input('titleTranslationKeyFormat'),
'altTranslationMethod' => $request->enum('altTranslationMethod', TranslationMethod::class, TranslationMethod::None),
diff --git a/src/Http/Controllers/Utilities/AssetIndexesController.php b/src/Http/Controllers/Utilities/AssetIndexesController.php
index 3fde674335f..57308e45697 100644
--- a/src/Http/Controllers/Utilities/AssetIndexesController.php
+++ b/src/Http/Controllers/Utilities/AssetIndexesController.php
@@ -5,10 +5,10 @@
namespace CraftCms\Cms\Http\Controllers\Utilities;
use CraftCms\Cms\Asset\AssetIndexer;
+use CraftCms\Cms\Asset\AssetTransforms;
use CraftCms\Cms\Asset\Elements\Asset;
use CraftCms\Cms\Element\Elements;
use CraftCms\Cms\Http\RespondsWithFlash;
-use CraftCms\Cms\Image\ImageTransforms;
use CraftCms\Cms\Support\Facades\Folders;
use CraftCms\Cms\Utility\Utilities;
use CraftCms\Cms\Utility\Utilities\AssetIndexes;
@@ -170,7 +170,7 @@ public function indexingSessionOverview(Request $request): Response
return $this->asSuccess(null, ['session' => $indexingSession]);
}
- public function finishIndexingSession(Request $request, Elements $elements, ImageTransforms $imageTransforms): Response
+ public function finishIndexingSession(Request $request, Elements $elements, AssetTransforms $assetTransforms): Response
{
$validated = $request->validate([
'sessionId' => ['required', 'integer'],
@@ -206,7 +206,7 @@ public function finishIndexingSession(Request $request, Elements $elements, Imag
->all();
foreach ($assets as $asset) {
- $imageTransforms->deleteCreatedTransformsForAsset($asset);
+ $assetTransforms->deleteCreatedTransformsForAsset($asset);
$asset->keepFileOnDelete = true;
$elements->deleteElement($asset);
}
diff --git a/src/Image/Contracts/AssetTransformerInterface.php b/src/Image/Contracts/AssetTransformerInterface.php
new file mode 100644
index 00000000000..91157f91063
--- /dev/null
+++ b/src/Image/Contracts/AssetTransformerInterface.php
@@ -0,0 +1,31 @@
+>
+ */
+ public static function gqlArguments(): array;
+
+ public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bool $immediately): string;
+
+ public function invalidateAssetTransforms(Asset $asset): void;
+
+ public function getTransformString(ImageTransform $imageTransform, bool $ignoreHandle = false): string;
+
+ public function getImageTransformSettingsHtml(ImageTransform $imageTransform, bool $readOnly = false): ?string;
+
+ public function getFilesystemSettingsHtml(FsInterface $filesystem, bool $readOnly = false): ?string;
+}
diff --git a/src/Image/Contracts/ImageTransformerInterface.php b/src/Image/Contracts/ImageTransformerInterface.php
index 6294c4cea45..1e81f8db206 100644
--- a/src/Image/Contracts/ImageTransformerInterface.php
+++ b/src/Image/Contracts/ImageTransformerInterface.php
@@ -7,6 +7,9 @@
use CraftCms\Cms\Asset\Elements\Asset;
use CraftCms\Cms\Image\Data\ImageTransform;
+/**
+ * @deprecated 6.0.0 use AssetTransformerInterface instead.
+ */
interface ImageTransformerInterface
{
public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bool $immediately): string;
diff --git a/src/Image/Data/ImageTransform.php b/src/Image/Data/ImageTransform.php
index 0d888a0fcce..771d3449a43 100644
--- a/src/Image/Data/ImageTransform.php
+++ b/src/Image/Data/ImageTransform.php
@@ -5,7 +5,7 @@
namespace CraftCms\Cms\Image\Data;
use CraftCms\Cms\Component\Component;
-use CraftCms\Cms\Image\Contracts\ImageTransformerInterface;
+use CraftCms\Cms\Image\Contracts\AssetTransformerInterface;
use CraftCms\Cms\Image\ImageTransformer;
use DateTime;
use Illuminate\Validation\Rule;
@@ -15,7 +15,7 @@
class ImageTransform extends Component
{
- public const string DEFAULT_TRANSFORMER = ImageTransformer::class;
+ public const string DEFAULT_TRANSFORMER = 'craft';
private const array POSITIONS = [
'top-left',
@@ -68,12 +68,14 @@ class ImageTransform extends Component
public ?DateTime $parameterChangeTime = null;
- /** @var class-string */
- protected string $transformer = self::DEFAULT_TRANSFORMER;
+ /** @var array */
+ public array $settings = [];
+
+ protected ?string $transformer = null;
public function getIsNamedTransform(): bool
{
- return $this->id && $this->getTransformer() === self::DEFAULT_TRANSFORMER;
+ return $this->id && in_array($this->getTransformer(), [null, self::DEFAULT_TRANSFORMER, ImageTransformer::class], true);
}
/**
@@ -93,10 +95,8 @@ public static function modes(): array
/**
* Returns the transformer class.
- *
- * @return class-string
*/
- public function getTransformer(): string
+ public function getTransformer(): ?string
{
return $this->transformer;
}
@@ -104,16 +104,13 @@ public function getTransformer(): string
/**
* Sets the transformer class.
*
- * @param class-string|null $transformer
+ * @param class-string|string|null $transformer
*/
public function setTransformer(?string $transformer): void
{
- $this->transformer = $transformer ?? self::DEFAULT_TRANSFORMER;
- }
-
- public function getImageTransformer(): ImageTransformerInterface
- {
- return app()->make($this->getTransformer());
+ $this->transformer = $transformer === ImageTransformer::class || $transformer === null || $transformer === ''
+ ? null
+ : $transformer;
}
public function getConfig(): array
@@ -128,6 +125,8 @@ public function getConfig(): array
'name' => $this->name,
'position' => $this->position,
'quality' => $this->quality,
+ 'settings' => ! empty($this->settings) ? $this->settings : null,
+ 'transformer' => $this->getTransformer(),
'upscale' => $this->upscale,
'width' => $this->width ?: null,
];
diff --git a/src/Image/Data/ImageTransformIndex.php b/src/Image/Data/ImageTransformIndex.php
index cf959c640c1..6555243e7dd 100644
--- a/src/Image/Data/ImageTransformIndex.php
+++ b/src/Image/Data/ImageTransformIndex.php
@@ -61,7 +61,12 @@ public function __construct(array|object $config = [])
public function getTransform(): ImageTransform
{
- return $this->_transform ??= ImageTransformHelper::normalizeTransform($this->transformString);
+ if (! isset($this->_transform)) {
+ $this->_transform = ImageTransformHelper::normalizeTransform($this->transformString);
+ $this->_transform->setTransformer($this->transformer);
+ }
+
+ return $this->_transform;
}
public function setTransform(ImageTransform $transform): void
diff --git a/src/Image/Events/AssetTransformersResolving.php b/src/Image/Events/AssetTransformersResolving.php
new file mode 100644
index 00000000000..98b95305809
--- /dev/null
+++ b/src/Image/Events/AssetTransformersResolving.php
@@ -0,0 +1,17 @@
+|AssetTransformerInterface> $types
+ */
+ public function __construct(
+ public array $types = [],
+ ) {}
+}
diff --git a/src/Image/Events/ImageTransformersResolving.php b/src/Image/Events/ImageTransformersResolving.php
deleted file mode 100644
index 9aa567c28c3..00000000000
--- a/src/Image/Events/ImageTransformersResolving.php
+++ /dev/null
@@ -1,15 +0,0 @@
-getMimeType()) {
- 'image/gif' => Cms::config()->transformGifs,
- 'image/svg+xml' => Cms::config()->transformSvgs,
- default => true,
- }) {
- $transformString = ltrim(ImageTransformHelper::getTransformString($imageTransform, true), '_');
- } else {
- $transformString = 'original';
- }
-
- return Url::actionUrl('assets/generate-fallback-transform', [
- 'transform' => Crypt::encrypt(sprintf('%s,%s', $asset->id, $transformString)),
- ] + AssetsHelper::revParams($asset));
- }
-
- public function invalidateAssetTransforms(Asset $asset): void
- {
- // No reliable way to do this, so not worth trying
- }
-}
diff --git a/src/Image/ImageTransformHelper.php b/src/Image/ImageTransformHelper.php
index d54c53c1ebb..b79311a080b 100644
--- a/src/Image/ImageTransformHelper.php
+++ b/src/Image/ImageTransformHelper.php
@@ -19,7 +19,6 @@
use CraftCms\Cms\Support\Facades\Path;
use CraftCms\Cms\Support\File;
use CraftCms\Cms\Support\Str;
-use CraftCms\Cms\Support\Utils;
use CraftCms\Cms\Validation\Rules\ColorRule;
use Illuminate\Filesystem\LocalFilesystemAdapter;
use Illuminate\Support\Facades\Log;
@@ -67,7 +66,6 @@ public static function createTransformFromString(string $transformString): Image
'interlace' => $matches['interlace'] ?? 'none',
'fill' => $fill ?? null,
'upscale' => ($matches['upscale'] ?? null) !== 'ns',
- 'transformer' => ImageTransform::DEFAULT_TRANSFORMER,
]);
}
@@ -100,8 +98,6 @@ public static function extendTransform(ImageTransform $transform, array $paramet
// Don't change the same transform
$transform = clone $transform;
- $attributes = Utils::getPublicAttributes($transform);
-
$nullables = [
'id',
'name',
@@ -111,8 +107,10 @@ public static function extendTransform(ImageTransform $transform, array $paramet
];
foreach ($parameters as $name => $value) {
- if (in_array($name, $attributes, true)) {
+ if ($transform->canSetProperty($name)) {
$transform->$name = $value;
+ } else {
+ $transform->settings[$name] = $value;
}
}
@@ -305,7 +303,7 @@ public static function normalizeTransform(mixed $transform): ?ImageTransform
return self::extendTransform($baseTransform, $transform);
}
- return new ImageTransform($transform);
+ return self::createTransformFromArray($transform);
}
if (is_string($transform)) {
@@ -324,6 +322,28 @@ public static function normalizeTransform(mixed $transform): ?ImageTransform
return null;
}
+ /**
+ * @param array $config
+ */
+ private static function createTransformFromArray(array $config): ImageTransform
+ {
+ $transform = new ImageTransform;
+ $settings = [];
+
+ foreach ($config as $name => $value) {
+ if (! $transform->canSetProperty($name)) {
+ $settings[$name] = $value;
+ unset($config[$name]);
+ }
+ }
+
+ if (! empty($settings)) {
+ $config['settings'] = array_merge($config['settings'] ?? [], $settings);
+ }
+
+ return new ImageTransform($config);
+ }
+
/**
* Store a local image copy to a destination path.
*
diff --git a/src/Image/ImageTransformer.php b/src/Image/ImageTransformer.php
index 8714910f169..1122e2f6931 100644
--- a/src/Image/ImageTransformer.php
+++ b/src/Image/ImageTransformer.php
@@ -6,15 +6,18 @@
use CraftCms\Cms\Asset\Assets;
use CraftCms\Cms\Asset\AssetsHelper;
+use CraftCms\Cms\Asset\Data\Volume;
use CraftCms\Cms\Asset\Elements\Asset;
use CraftCms\Cms\Asset\Exceptions\ImageTransformException;
use CraftCms\Cms\Cms;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Filesystem\Contracts\FsInterface;
use CraftCms\Cms\Filesystem\Exceptions\FilesystemException;
+use CraftCms\Cms\Filesystem\Filesystems as FilesystemsService;
use CraftCms\Cms\Http\Middleware\SetHeaders;
+use CraftCms\Cms\Image\Contracts\AssetTransformerInterface;
use CraftCms\Cms\Image\Contracts\EagerImageTransformerInterface;
use CraftCms\Cms\Image\Contracts\ImageEditorTransformerInterface;
-use CraftCms\Cms\Image\Contracts\ImageTransformerInterface;
use CraftCms\Cms\Image\Data\ImageTransform;
use CraftCms\Cms\Image\Data\ImageTransformIndex;
use CraftCms\Cms\Image\Events\DeletingTransformedImage;
@@ -23,24 +26,32 @@
use CraftCms\Cms\Shared\Exceptions\NotSupportedException;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\DateTimeHelper;
+use CraftCms\Cms\Support\Env;
use CraftCms\Cms\Support\Facades\I18N;
use CraftCms\Cms\Support\File;
use CraftCms\Cms\Support\Query;
use CraftCms\Cms\Support\Str;
use CraftCms\Cms\Support\Url;
+use CraftCms\Cms\View\TemplateMode;
use DateTimeInterface;
use Exception;
use Illuminate\Database\Query\Builder;
+use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Filesystem\LocalFilesystemAdapter;
+use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Sleep;
use RuntimeException;
+use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
use function CraftCms\Cms\maxPowerCaptain;
use function CraftCms\Cms\t;
+use function CraftCms\Cms\template;
-class ImageTransformer implements EagerImageTransformerInterface, ImageEditorTransformerInterface, ImageTransformerInterface
+class ImageTransformer implements AssetTransformerInterface, EagerImageTransformerInterface, ImageEditorTransformerInterface
{
/** @var array> */
private array $eagerLoadedTransformIndexes = [];
@@ -49,16 +60,93 @@ class ImageTransformer implements EagerImageTransformerInterface, ImageEditorTra
private ?string $editingTempPath = null;
+ public static function handle(): string
+ {
+ return ImageTransform::DEFAULT_TRANSFORMER;
+ }
+
+ public static function displayName(): string
+ {
+ return t('Craft');
+ }
+
+ public static function gqlArguments(): array
+ {
+ return [];
+ }
+
+ public function getTransformString(ImageTransform $imageTransform, bool $ignoreHandle = false): string
+ {
+ return ImageTransformHelper::getTransformString($imageTransform, $ignoreHandle);
+ }
+
+ public function getImageTransformSettingsHtml(ImageTransform $imageTransform, bool $readOnly = false): ?string
+ {
+ return null;
+ }
+
+ public function getFilesystemSettingsHtml(FsInterface $filesystem, bool $readOnly = false): ?string
+ {
+ return template('_components/asset-transformers/ImageTransformer/filesystem-settings', [
+ 'filesystem' => $filesystem,
+ 'settings' => $filesystem->getTransformerSettings(self::handle()),
+ 'readOnly' => $readOnly,
+ ], TemplateMode::Cp);
+ }
+
+ public function getTransformFs(Asset $asset): FsInterface
+ {
+ return $this->getTransformFsForVolume($asset->getVolume());
+ }
+
+ public function getTransformFsForVolume(Volume $volume): FsInterface
+ {
+ $filesystem = $volume->getFs();
+ $settings = $filesystem->getTransformerSettings(self::handle());
+ $handle = $this->normalizeConfiguredFsHandle($settings['transformFsHandle'] ?? null)
+ ?? $volume->getFsHandle();
+
+ if (! $handle) {
+ return $filesystem;
+ }
+
+ $transformFs = app(FilesystemsService::class)->resolve($handle);
+
+ if ($transformFs) {
+ return $transformFs;
+ }
+
+ Log::error("Invalid transform filesystem handle: $handle for the $filesystem->name filesystem.");
+
+ return $filesystem;
+ }
+
+ public function transformDisk(Asset $asset): FilesystemAdapter
+ {
+ return $this->transformDiskForVolume($asset->getVolume());
+ }
+
+ public function transformDiskForVolume(Volume $volume): FilesystemAdapter
+ {
+ $filesystem = $volume->getFs();
+ $settings = $filesystem->getTransformerSettings(self::handle());
+ $handle = $this->normalizeConfiguredFsHandle($settings['transformFsHandle'] ?? null)
+ ?? $volume->getFsHandle();
+ $diskName = $handle ? app(FilesystemsService::class)->resolveDiskName($handle) : null;
+
+ if (! $diskName) {
+ throw new RuntimeException('Asset transform filesystem is missing or invalid.');
+ }
+
+ return $this->storageDiskFor($diskName, $this->transformDiskPrefix($filesystem));
+ }
+
public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bool $immediately): string
{
- $disk = $asset->getVolume()->transformDisk();
+ $disk = $this->transformDisk($asset);
$mimeType = $asset->getMimeType();
$generalConfig = Cms::config();
- if (! $asset->getVolume()->getFs()->hasUrls) {
- throw new NotSupportedException('The asset’s volume’s transform filesystem doesn’t have URLs.');
- }
-
if ($mimeType === 'image/gif' && ! $generalConfig->transformGifs) {
throw new NotSupportedException('GIF files shouldn’t be transformed.');
}
@@ -68,7 +156,7 @@ public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bo
}
$index = $this->getTransformIndex($asset, $imageTransform);
- $uri = str_replace('\\', '/', $this->getTransformBasePath($asset)).$this->getTransformUri($asset, $index);
+ $uri = str_replace('\\', '/', (string) $asset->folderPath).$this->getTransformUri($asset, $index);
// If it's a local filesystem, make sure `fileExists` is accurate
if ($disk instanceof LocalFilesystemAdapter) {
@@ -107,9 +195,7 @@ public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bo
}
// Return the temporary transform URL
- return Url::actionUrl('assets/generate-transform', [
- 'transformId' => $index->id,
- ]);
+ return $this->privateTransformUrl($index);
}
// Is the transform being generated by another request?
@@ -157,7 +243,9 @@ public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bo
}
}
- $url = $disk->url($uri);
+ $url = $this->getTransformFs($asset)->hasUrls
+ ? $disk->url($uri)
+ : $this->privateTransformUrl($index);
if (Cms::config()->revAssetUrls) {
return AssetsHelper::revUrl($url, $asset, $index->dateUpdated);
@@ -177,19 +265,85 @@ public function invalidateAssetTransforms(Asset $asset): void
$this->deleteTransformIndexDataByAssetId($asset->id);
}
+ private function normalizeConfiguredFsHandle(mixed $handle): ?string
+ {
+ if (! is_string($handle) || $handle === '') {
+ return null;
+ }
+
+ $handle = Env::parse($handle);
+
+ return is_string($handle) && $handle !== '' ? $handle : null;
+ }
+
+ private function transformDiskPrefix(FsInterface $filesystem): ?string
+ {
+ $settings = $filesystem->getTransformerSettings(self::handle());
+ $subpath = $settings['transformSubpath'] ?? null;
+ $subpath = is_string($subpath) ? Env::parse($subpath) : null;
+ $subpath = trim((string) $subpath, '/');
+
+ return $subpath !== '' ? $subpath : null;
+ }
+
+ private function storageDiskFor(string $diskName, ?string $prefix): FilesystemAdapter
+ {
+ if ($prefix === null) {
+ return Storage::disk($diskName);
+ }
+
+ $disk = Storage::build([
+ 'driver' => 'scoped',
+ 'disk' => $diskName,
+ 'prefix' => $prefix,
+ ]);
+
+ if (! $disk instanceof FilesystemAdapter) {
+ throw new RuntimeException('Invalid filesystem disk configuration.');
+ }
+
+ return $disk;
+ }
+
public function deleteImageTransformFile(Asset $asset, ImageTransformIndex $transformIndex): void
{
- $path = $this->getTransformBasePath($asset).$this->getTransformSubpath($asset, $transformIndex);
+ $path = $asset->folderPath.$this->getTransformSubpath($asset, $transformIndex);
event(new DeletingTransformedImage(asset: $asset, path: $path));
try {
- $asset->getVolume()->transformDisk()->delete($path);
+ $this->transformDisk($asset)->delete($path);
} catch (RuntimeException|NotSupportedException) {
// NBD
}
}
+ public function getTransformResponse(Asset $asset, ImageTransformIndex $index): StreamedResponse
+ {
+ $this->getTransformUrl($asset, $index->getTransform(), true);
+ $index = $this->getTransformIndexModelById((int) $index->id) ?? $index;
+
+ $path = $asset->folderPath.$this->getTransformSubpath($asset, $index);
+ $disk = $this->transformDisk($asset);
+ $stream = $disk->readStream($path);
+
+ if (! is_resource($stream)) {
+ throw new ImageTransformException('Unable to read generated transform.');
+ }
+
+ return response()->stream(function () use ($stream) {
+ fpassthru($stream);
+
+ if (is_resource($stream)) {
+ fclose($stream);
+ }
+ }, 200, [
+ 'Content-Disposition' => 'inline; filename="'.$index->filename.'"',
+ 'Content-Type' => File::getMimeTypeByExtension($index->filename ?? $asset->getFilename()) ?? 'application/octet-stream',
+ 'Cache-Control' => 'public, max-age=31536000',
+ ]);
+ }
+
public function eagerLoadTransforms(array $transforms, array $assets): void
{
// Index the assets by ID
@@ -201,7 +355,7 @@ public function eagerLoadTransforms(array $transforms, array $assets): void
->whereIn('assetId', array_keys($assetsById))
->where(function (Builder $query) use ($transforms, &$transformsByFingerprint) {
foreach ($transforms as $transform) {
- $transformString = ImageTransformHelper::getTransformString($transform);
+ $transformString = $this->getTransformString($transform);
$fingerprint = $transform->format !== null
? $transformString.':'.$transform->format
: $transformString;
@@ -210,6 +364,11 @@ public function eagerLoadTransforms(array $transforms, array $assets): void
$query->orWhere(function (Builder $query) use ($transform, $transformString) {
$query->where('transformString', $transformString)
+ ->where(function (Builder $query) {
+ $query->where('transformer', ImageTransform::DEFAULT_TRANSFORMER)
+ ->orWhereNull('transformer')
+ ->orWhere('transformer', ImageTransformer::class);
+ })
->when(
$transform->format !== null,
fn (Builder $query) => $query->where('format', $transform->format),
@@ -292,9 +451,8 @@ private function generateTransformedImage(Asset $asset, ImageTransformIndex $ind
return;
}
- $volume = $asset->getVolume();
- $transformDisk = $volume->transformDisk();
- $transformPath = $this->getTransformBasePath($asset).$this->getTransformSubpath($asset, $index);
+ $transformDisk = $this->transformDisk($asset);
+ $transformPath = $asset->folderPath.$this->getTransformSubpath($asset, $index);
if ($transformDisk->exists($transformPath)) {
$dateModified = $transformDisk->lastModified($transformPath);
@@ -360,20 +518,18 @@ private function generateTransform(ImageTransformIndex $index): void
throw new ImageTransformException('Asset not found - '.$index->assetId);
}
- $volume = $asset->getVolume();
-
$index->detectedFormat = $index->format ?: ImageTransformHelper::detectTransformFormat($asset);
$transformFilename = pathinfo($asset->getFilename(), PATHINFO_FILENAME).'.'.$index->detectedFormat;
$index->filename = $transformFilename;
$matchFound = $this->getSimilarTransformIndex($asset, $index);
- $disk = $volume->transformDisk();
+ $disk = $this->transformDisk($asset);
- $target = $this->getTransformBasePath($asset).$this->getTransformSubpath($asset, $index);
+ $target = $asset->folderPath.$this->getTransformSubpath($asset, $index);
// If we have a match, copy the file.
if ($matchFound) {
- $from = $this->getTransformBasePath($asset).$this->getTransformSubpath($asset, $matchFound);
+ $from = $asset->folderPath.$this->getTransformSubpath($asset, $matchFound);
// Sanity check
try {
@@ -411,7 +567,8 @@ public function getTransformIndex(Asset $asset, mixed $transform): ImageTransfor
throw new ImageTransformException('There was a problem finding the transform.');
}
- $transformString = ImageTransformHelper::getTransformString($transform);
+ $transform->setTransformer(ImageTransform::DEFAULT_TRANSFORMER);
+ $transformString = $this->getTransformString($transform);
// Was it eager-loaded?
$fingerprint = $asset->id.':'.$transformString.($transform->format === null ? '' : ':'.$transform->format);
@@ -426,6 +583,11 @@ public function getTransformIndex(Asset $asset, mixed $transform): ImageTransfor
$result = $this->createTransformIndexQuery()
->where('assetId', $asset->id)
->where('transformString', $transformString)
+ ->where(function (Builder $query) {
+ $query->where('transformer', ImageTransform::DEFAULT_TRANSFORMER)
+ ->orWhereNull('transformer')
+ ->orWhere('transformer', ImageTransformer::class);
+ })
->when(
$transform->format,
fn (Builder $query) => $query->where('format', $transform->format),
@@ -455,7 +617,7 @@ public function getTransformIndex(Asset $asset, mixed $transform): ImageTransfor
$index = new ImageTransformIndex([
'assetId' => $asset->id,
'format' => $transform->format,
- 'transformer' => $transform->getTransformer(),
+ 'transformer' => ImageTransform::DEFAULT_TRANSFORMER,
'dateIndexed' => now(),
'transformString' => $transformString,
'fileExists' => false,
@@ -638,14 +800,6 @@ public function cancelImageEditing(): string
return $tempPath;
}
- private function getTransformBasePath(Asset $asset): string
- {
- $subPath = $asset->getVolume()->getTransformSubpath();
- $subPath = Str::chopEnd($subPath, '/');
-
- return ($subPath ? $subPath.DIRECTORY_SEPARATOR : '').$asset->folderPath;
- }
-
private function deleteTransformIndexDataByAssetId(int $assetId): void
{
DB::table(Table::IMAGETRANSFORMINDEX)
@@ -675,10 +829,10 @@ private function getSimilarTransformIndex(Asset $asset, ImageTransformIndex $ind
return null;
}
- $possibleLocations = [ImageTransformHelper::getTransformString($transform, true)];
+ $possibleLocations = [$this->getTransformString($transform, true)];
if ($transform->getIsNamedTransform()) {
- $possibleLocations[] = ImageTransformHelper::getTransformString($transform);
+ $possibleLocations[] = $this->getTransformString($transform);
}
$result = $this->createTransformIndexQuery()
@@ -700,6 +854,7 @@ private function createTransformIndexQuery(): Builder
->select([
'id',
'assetId',
+ 'transformer',
'filename',
'format',
'transformString',
@@ -711,4 +866,14 @@ private function createTransformIndexQuery(): Builder
'dateCreated',
]);
}
+
+ private function privateTransformUrl(ImageTransformIndex $index): string
+ {
+ $token = Crypt::encryptString((string) $index->id);
+
+ return Url::actionUrl('assets/generate-transform', [
+ 'transformToken' => $token,
+ 'transformSignature' => hash_hmac('sha256', $token, (string) config('app.key')),
+ ]);
+ }
}
diff --git a/src/Image/ImageTransforms.php b/src/Image/ImageTransforms.php
index 7f5fd977507..388b08c0c56 100644
--- a/src/Image/ImageTransforms.php
+++ b/src/Image/ImageTransforms.php
@@ -4,16 +4,10 @@
namespace CraftCms\Cms\Image;
-use CraftCms\Cms\Asset\AssetsHelper;
use CraftCms\Cms\Asset\Elements\Asset;
-use CraftCms\Cms\Asset\Exceptions\ImageTransformException;
use CraftCms\Cms\Database\Table;
use CraftCms\Cms\Element\ElementCaches;
-use CraftCms\Cms\Image\Contracts\EagerImageTransformerInterface;
-use CraftCms\Cms\Image\Contracts\ImageTransformerInterface;
use CraftCms\Cms\Image\Data\ImageTransform;
-use CraftCms\Cms\Image\Events\AssetTransformsInvalidating;
-use CraftCms\Cms\Image\Events\ImageTransformersResolving;
use CraftCms\Cms\Image\Events\TransformDeleted;
use CraftCms\Cms\Image\Events\TransformDeleting;
use CraftCms\Cms\Image\Events\TransformDeletionApplying;
@@ -23,8 +17,7 @@
use CraftCms\Cms\ProjectConfig\Events\ConfigEvent;
use CraftCms\Cms\ProjectConfig\ProjectConfig;
use CraftCms\Cms\Support\Arr;
-use CraftCms\Cms\Support\Facades\Path;
-use CraftCms\Cms\Support\File;
+use CraftCms\Cms\Support\Json;
use CraftCms\Cms\Support\Query;
use CraftCms\Cms\Support\Str;
use DateTime;
@@ -32,7 +25,6 @@
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
-use InvalidArgumentException;
#[Singleton]
class ImageTransforms
@@ -40,9 +32,6 @@ class ImageTransforms
/** @var Collection|null */
private ?Collection $transforms = null;
- /** @var array, ImageTransformerInterface> */
- private array $imageTransformers = [];
-
public function __construct(
private readonly ProjectConfig $projectConfig,
private readonly ElementCaches $elementCaches,
@@ -121,9 +110,11 @@ public function handleChangedTransform(ConfigEvent $event): void
$qualityChanged = $transformModel->quality !== ($data['quality'] ?? null);
$interlaceChanged = $transformModel->interlace !== $data['interlace'];
$fillChanged = $transformModel->fill !== ($data['fill'] ?? null);
+ $transformerChanged = $transformModel->transformer !== ($data['transformer'] ?? null);
+ $settingsChanged = $transformModel->settings !== ($data['settings'] ?? null);
$upscaleChanged = ($transformModel->upscale !== null ? (bool) $transformModel->upscale : null) !== ($data['upscale'] ?? null);
- if ($dimensionsChanged || $modeChanged || $qualityChanged || $interlaceChanged || $fillChanged || $upscaleChanged) {
+ if ($dimensionsChanged || $modeChanged || $qualityChanged || $interlaceChanged || $fillChanged || $transformerChanged || $settingsChanged || $upscaleChanged) {
$transformModel->parameterChangeTime = Query::prepareDateForDb(new DateTime);
}
@@ -135,6 +126,8 @@ public function handleChangedTransform(ConfigEvent $event): void
$transformModel->interlace = $data['interlace'];
$transformModel->format = $data['format'] ?? null;
$transformModel->fill = $data['fill'] ?? null;
+ $transformModel->settings = $data['settings'] ?? null;
+ $transformModel->transformer = $data['transformer'] ?? null;
$transformModel->upscale = $data['upscale'] ?? true;
$transformModel->uid = $transformUid;
@@ -198,174 +191,6 @@ public function handleDeletedTransform(ConfigEvent $event): void
$this->elementCaches->invalidateForElementType(Asset::class);
}
- /**
- * Eager-loads transform indexes for the given list of assets.
- *
- * You can include `srcset`-style sizes (e.g. `100w` or `2x`) following a normal transform definition, for example:
- *
- * ```php
- * [['width' => 1000, 'height' => 600], '1.5x', '2x', '3x']
- * ```
- *
- * When a `srcset`-style size is encountered, the preceding normal transform definition will be used as a
- * reference when determining the resulting transform dimensions.
- *
- * @param array $transforms The transform definitions to eager-load
- * @param Asset[] $assets The assets to eager-load transforms for
- */
- public function eagerLoadTransforms(array $assets, array $transforms): void
- {
- if (empty($assets) || empty($transforms)) {
- return;
- }
-
- $transformsByTransformer = [];
-
- /** @var ImageTransform|null $refTransform */
- $refTransform = null;
-
- foreach ($transforms as $transform) {
- // Is this a srcset-style size (2x, 100w, etc.)?
- try {
- [$sizeValue, $sizeUnit] = AssetsHelper::parseSrcsetSize($transform);
- } catch (InvalidArgumentException) {
- $sizeValue = $sizeUnit = null;
- }
-
- if (isset($sizeValue, $sizeUnit)) {
- if ($refTransform === null || ! $refTransform->width) {
- throw new InvalidArgumentException("Can’t eager-load transform “{$transform}” without a prior transform that specifies the base width");
- }
-
- $transform = new ImageTransform(
- $refTransform->toArray(),
- );
-
- unset($transform->name, $transform->handle);
-
- if ($sizeUnit === 'w') {
- $transform->width = (int) $sizeValue;
- } else {
- $transform->width = (int) ceil($refTransform->width * $sizeValue);
- }
-
- // Only set the height if the reference transform has a height set on it
- if ($refTransform->height) {
- if ($sizeUnit === 'w') {
- $transform->height = (int) ceil($refTransform->height * $transform->width / $refTransform->width);
- } else {
- $transform->height = (int) ceil($refTransform->height * $sizeValue);
- }
- }
- }
-
- $transform = ImageTransformHelper::normalizeTransform($transform);
- $transformsByTransformer[$transform->getTransformer()][] = $transform;
-
- if (! isset($sizeValue)) {
- // Use this as the reference transform in case any srcset-style transforms follow it
- $refTransform = $transform;
- }
- }
-
- foreach ($transformsByTransformer as $type => $typeTransforms) {
- $transformer = $this->getImageTransformer($type);
-
- if ($transformer instanceof EagerImageTransformerInterface) {
- $transformer->eagerLoadTransforms($typeTransforms, $assets);
- }
- }
- }
-
- /**
- * Returns an image transformer instance for the given class.
- *
- * @template T of ImageTransformerInterface
- *
- * @param class-string $class
- * @return T
- *
- * @throws ImageTransformException
- */
- public function getImageTransformer(string $class, array $config = []): ImageTransformerInterface
- {
- if (array_key_exists($class, $this->imageTransformers)) {
- return $this->imageTransformers[$class];
- }
-
- if (! is_subclass_of($class, ImageTransformerInterface::class)) {
- throw new ImageTransformException("Invalid image transformer: $class");
- }
-
- return $this->imageTransformers[$class] = new $class($config);
- }
-
- /**
- * Returns all available image transformer class names.
- *
- * @return class-string[]
- */
- public function getAllImageTransformers(): array
- {
- $transformers = [
- ImageTransformer::class,
- ];
-
- event($event = new ImageTransformersResolving(types: $transformers));
-
- return $event->types;
- }
-
- /**
- * Deletes ALL transform data (including thumbs and sources) associated with the asset.
- */
- public function deleteAllTransformData(Asset $asset): void
- {
- $this->deleteResizedAssetVersion($asset);
- $this->deleteCreatedTransformsForAsset($asset);
-
- $file = Path::assetSources($asset->id.'.'.pathinfo($asset->getFilename(), PATHINFO_EXTENSION));
-
- File::delete($file);
- }
-
- public function deleteResizedAssetVersion(Asset $asset): void
- {
- $dirs = [
- Path::imageEditorSources((string) $asset->id),
- ];
-
- foreach ($dirs as $dir) {
- if (file_exists($dir)) {
- $files = glob($dir.'/[0-9]*/'.$asset->id.'.[a-z]*');
-
- if (! is_array($files)) {
- Log::info('Could not list files in '.$dir.' when deleting resized asset versions.');
-
- continue;
- }
-
- foreach ($files as $path) {
- if (! File::delete($path)) {
- Log::warning("Unable to delete the asset thumbnail \"$path\".", [__METHOD__]);
- }
- }
- }
- }
- }
-
- public function deleteCreatedTransformsForAsset(Asset $asset): void
- {
- event(new AssetTransformsInvalidating(asset: $asset));
-
- $transformers = $this->getAllImageTransformers();
-
- foreach ($transformers as $type) {
- $transformer = $this->getImageTransformer($type);
- $transformer->invalidateAssetTransforms($asset);
- }
- }
-
/**
* Returns a memoized collection of all named image transforms.
*
@@ -386,15 +211,26 @@ private function transforms(): Collection
'quality',
'interlace',
'fill',
+ 'settings',
+ 'transformer',
'upscale',
'parameterChangeTime',
'uid',
])
->orderBy('name')
->get()
- ->map(fn ($result) => new ImageTransform(
- Arr::except((array) $result, ['dateCreated', 'dateUpdated', 'dateDeleted'])
- ))
+ ->map(function ($result) {
+ $config = Arr::except((array) $result, ['dateCreated', 'dateUpdated', 'dateDeleted']);
+
+ if (is_string($config['settings'] ?? null)) {
+ $config['settings'] = Json::decode($config['settings']);
+ }
+
+ return new ImageTransform(array_filter(
+ $config,
+ fn (mixed $value): bool => $value !== null,
+ ));
+ })
->values();
}
diff --git a/src/Image/Jobs/GenerateImageTransform.php b/src/Image/Jobs/GenerateImageTransform.php
index 2e0e3699a31..7805bf4b67f 100644
--- a/src/Image/Jobs/GenerateImageTransform.php
+++ b/src/Image/Jobs/GenerateImageTransform.php
@@ -4,7 +4,9 @@
namespace CraftCms\Cms\Image\Jobs;
+use CraftCms\Cms\Asset\AssetTransforms;
use CraftCms\Cms\Asset\Elements\Asset;
+use CraftCms\Cms\Image\Data\ImageTransform;
use CraftCms\Cms\Image\ImageTransformer;
use CraftCms\Cms\Queue\Job;
use CraftCms\Cms\Support\Facades\I18N;
@@ -21,7 +23,7 @@ public function __construct(
parent::__construct();
}
- public function handle(): void
+ public function handle(?AssetTransforms $assetTransforms = null): void
{
$transformer = new ImageTransformer;
$index = $transformer->getTransformIndexModelById($this->transformId);
@@ -33,6 +35,7 @@ public function handle(): void
$asset = Asset::find()->id($index->assetId)->one();
if ($asset) {
+ $transformer = $assetTransforms->getAssetTransformer($index->transformer ?? ImageTransform::DEFAULT_TRANSFORMER);
$transformer->getTransformUrl($asset, $index->getTransform(), true);
}
} catch (Throwable $e) {
diff --git a/src/Image/Models/ImageTransform.php b/src/Image/Models/ImageTransform.php
index aba05ec5c68..d3ffb7e428d 100644
--- a/src/Image/Models/ImageTransform.php
+++ b/src/Image/Models/ImageTransform.php
@@ -23,6 +23,7 @@ protected function casts(): array
'width' => 'int',
'height' => 'int',
'quality' => 'int',
+ 'settings' => 'array',
'upscale' => 'bool',
'parameterChangeTime' => 'datetime',
];
diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php
index 446ce868300..2188c37c52c 100644
--- a/src/Providers/AppServiceProvider.php
+++ b/src/Providers/AppServiceProvider.php
@@ -17,6 +17,7 @@
use CraftCms\Cms\Support\File;
use CraftCms\Cms\Support\Url;
use CraftCms\Cms\Update\Data\Update as UpdateData;
+use CraftCms\Cms\Update\Data\UpdateRelease;
use CraftCms\Cms\Update\Data\Updates as UpdatesData;
use GuzzleHttp\Utils;
use Illuminate\Contracts\Config\Repository;
@@ -28,6 +29,7 @@
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\UrlGenerator;
+use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Config;
@@ -205,11 +207,13 @@ private function registerSerializableClasses(): void
$existing = is_array($existing) ? $existing : [];
$this->app->make(Repository::class)->set('cache.serializable_classes', array_merge($existing, [
+ Carbon::class,
Collection::class,
ElementCollection::class,
stdClass::class,
UpdatesData::class,
UpdateData::class,
+ UpdateRelease::class,
]));
}
diff --git a/src/Support/Facades/AssetTransforms.php b/src/Support/Facades/AssetTransforms.php
new file mode 100644
index 00000000000..95a1a5a249b
--- /dev/null
+++ b/src/Support/Facades/AssetTransforms.php
@@ -0,0 +1,29 @@
+|\CraftCms\Cms\Image\Contracts\AssetTransformerInterface> getAllAssetTransformers()
+ * @method static void deleteAllTransformData(\CraftCms\Cms\Asset\Elements\Asset $asset)
+ * @method static void deleteResizedAssetVersion(\CraftCms\Cms\Asset\Elements\Asset $asset)
+ * @method static void deleteCreatedTransformsForAsset(\CraftCms\Cms\Asset\Elements\Asset $asset)
+ * @method static void reset()
+ *
+ * @see \CraftCms\Cms\Asset\AssetTransforms
+ */
+class AssetTransforms extends Facade
+{
+ #[Override]
+ protected static function getFacadeAccessor(): string
+ {
+ return \CraftCms\Cms\Asset\AssetTransforms::class;
+ }
+}
diff --git a/src/Support/Facades/ImageTransforms.php b/src/Support/Facades/ImageTransforms.php
index 63ede588625..75cfc3b3609 100644
--- a/src/Support/Facades/ImageTransforms.php
+++ b/src/Support/Facades/ImageTransforms.php
@@ -17,12 +17,6 @@
* @method static bool deleteTransformById(int $id)
* @method static bool deleteTransform(\CraftCms\Cms\Image\Data\ImageTransform $transform)
* @method static void handleDeletedTransform(\CraftCms\Cms\ProjectConfig\Events\ConfigEvent $event)
- * @method static void eagerLoadTransforms(\CraftCms\Cms\Asset\Elements\Asset[] $assets, array $transforms)
- * @method static \CraftCms\Cms\Image\Contracts\ImageTransformerInterface getImageTransformer(string $class, array $config = [])
- * @method static string[] getAllImageTransformers()
- * @method static void deleteAllTransformData(\CraftCms\Cms\Asset\Elements\Asset $asset)
- * @method static void deleteResizedAssetVersion(\CraftCms\Cms\Asset\Elements\Asset $asset)
- * @method static void deleteCreatedTransformsForAsset(\CraftCms\Cms\Asset\Elements\Asset $asset)
* @method static void reset()
*
* @see \CraftCms\Cms\Image\ImageTransforms
diff --git a/tests/Feature/Asset/VolumeFilesystemResolutionTest.php b/tests/Feature/Asset/VolumeFilesystemResolutionTest.php
index 048bfe95765..5f34b80501d 100644
--- a/tests/Feature/Asset/VolumeFilesystemResolutionTest.php
+++ b/tests/Feature/Asset/VolumeFilesystemResolutionTest.php
@@ -10,6 +10,7 @@
use CraftCms\Cms\Filesystem\Filesystems\DiskFilesystem;
use CraftCms\Cms\Filesystem\Filesystems\Local;
use CraftCms\Cms\Filesystem\Filesystems\MissingFs;
+use CraftCms\Cms\Image\ImageTransformer;
use CraftCms\Cms\Support\Facades\Assets;
use CraftCms\Cms\Support\Facades\Filesystems;
use Illuminate\Support\Facades\DB;
@@ -96,11 +97,9 @@
'name' => 'Normalize',
'handle' => 'normalizeVolume',
'fsHandle' => 'normalize-disk',
- 'transformFsHandle' => 'normalize-disk',
]);
- expect($volume->getFsHandle(false))->toBe('disk:normalize-disk')
- ->and($volume->getTransformFsHandle(false))->toBe('disk:normalize-disk');
+ expect($volume->getFsHandle(false))->toBe('disk:normalize-disk');
});
it('validates literal filesystem references and permits unresolved env values', function () {
@@ -245,7 +244,7 @@
expect($storageDisk->exists('nested/moved.txt'))->toBeFalse();
});
-it('supports macro-backed legacy methods and property assignment', function () {
+it('supports filesystem property assignment', function () {
config()->set('filesystems.disks.macro-disk', [
'driver' => 'local',
'root' => storage_path('framework/testing/volume-disks/macro-disk'),
@@ -255,56 +254,52 @@
'name' => 'Macro',
'handle' => 'macro',
'fs' => 'macro-disk',
- 'transformFs' => 'macro-disk',
'subpath' => 'initial',
]);
expect($volume->getFsHandle(false))->toBe('disk:macro-disk')
- ->and($volume->getTransformFsHandle(false))->toBe('disk:macro-disk')
->and($volume->getResolvedFsTarget())->toBe('disk:macro-disk')
->and($volume->getSubpath())->toBe('initial/')
->and($volume->getFs())->toBeInstanceOf(DiskFilesystem::class);
$volume->fsHandle = 'macro-disk';
- $volume->transformFsHandle = 'macro-disk';
$volume->subpath = 'changed';
- $volume->transformSubpath = 'transforms';
expect($volume->getFsHandle(false))->toBe('disk:macro-disk')
- ->and($volume->getTransformFsHandle(false))->toBe('disk:macro-disk')
- ->and($volume->getSubpath())->toBe('changed/')
- ->and($volume->getTransformSubpath())->toBe('transforms/');
+ ->and($volume->getSubpath())->toBe('changed/');
});
-it('returns a working transformDisk() for Craft filesystem backed volumes', function () {
+it('returns a working image transformer disk for Craft filesystem backed volumes', function () {
config()->set('filesystems.disks.transform-disk-target', [
'driver' => 'local',
'root' => storage_path('framework/testing/volume-disks/transform-disk-target'),
]);
- createVolumeLocalFilesystem('transform-disk-target');
+ $filesystem = createVolumeLocalFilesystem('transform-disk-target');
+ $filesystem->setTransformerSettings('craft', [
+ 'transformFsHandle' => 'transform-disk-target',
+ 'transformSubpath' => 'transforms',
+ ]);
+ Filesystems::saveFilesystem($filesystem, false);
$volume = new Volume([
'name' => 'Transform',
'handle' => 'transformVolume',
'fsHandle' => 'transform-disk-target',
'subpath' => 'source',
- 'transformFsHandle' => 'transform-disk-target',
- 'transformSubpath' => 'transforms',
]);
- $transformDisk = $volume->transformDisk();
+ $transformDisk = app(ImageTransformer::class)->transformDiskForVolume($volume);
expect($transformDisk->put('thumb.jpg', 'image-data'))->toBeTrue()
->and($transformDisk->exists('thumb.jpg'))->toBeTrue()
->and($transformDisk->get('thumb.jpg'))->toBe('image-data');
- // Verify the transform file is scoped under the transform subpath
$rawDisk = Storage::disk('craft-fs-transform-disk-target');
expect($rawDisk->exists('transforms/thumb.jpg'))->toBeTrue();
});
-it('falls back to source filesystem when no transform fs handle is set', function () {
+it('falls back to source filesystem when no transform filesystem setting is set', function () {
config()->set('filesystems.disks.fallback-source', [
'driver' => 'local',
'root' => storage_path('framework/testing/volume-disks/fallback-source'),
@@ -319,36 +314,40 @@
'subpath' => 'assets',
]);
- $transformDisk = $volume->transformDisk();
+ $transformDisk = app(ImageTransformer::class)->transformDiskForVolume($volume);
- // Ensure with start with clean disks
$transformDisk->delete('transform.jpg');
Storage::disk('craft-fs-fallback-source')->delete('transform.jpg');
expect($transformDisk->put('transform.jpg', 'transform-data'))->toBeTrue()
->and($transformDisk->exists('transform.jpg'))->toBeTrue();
- // No transformSubpath is set, so the transformDisk prefix is null (root of disk)
$rawDisk = Storage::disk('craft-fs-fallback-source');
expect($rawDisk->exists('transform.jpg'))->toBeTrue();
});
-it('returns a working transformDisk() for plain Laravel disk targets', function () {
+it('returns a working image transformer disk for filesystem settings that target plain Laravel disks', function () {
config()->set('filesystems.disks.transform-plain', [
'driver' => 'local',
'root' => storage_path('framework/testing/volume-disks/transform-plain'),
]);
+ createVolumeLocalFilesystem('transform-plain-source');
+ $filesystem = Filesystems::getFilesystemByHandle('transform-plain-source');
+ $filesystem->setTransformerSettings('craft', [
+ 'transformFsHandle' => 'disk:transform-plain',
+ 'transformSubpath' => 'xforms',
+ ]);
+ Filesystems::saveFilesystem($filesystem, false);
+
$volume = new Volume([
'name' => 'Transform Plain',
'handle' => 'transformPlain',
- 'fsHandle' => 'disk:transform-plain',
+ 'fsHandle' => 'transform-plain-source',
'subpath' => 'src',
- 'transformFsHandle' => 'disk:transform-plain',
- 'transformSubpath' => 'xforms',
]);
- $transformDisk = $volume->transformDisk();
+ $transformDisk = app(ImageTransformer::class)->transformDiskForVolume($volume);
expect($transformDisk->put('test.webp', 'webp-data'))->toBeTrue();
@@ -385,8 +384,6 @@
'handle' => 'configVolume',
'fsHandle' => 'config-fs',
'subpath' => 'uploads',
- 'transformFsHandle' => 'config-fs',
- 'transformSubpath' => 'transforms',
'titleTranslationMethod' => 'site',
'altTranslationMethod' => 'none',
'sortOrder' => 3,
@@ -398,8 +395,7 @@
->and($config['handle'])->toBe('configVolume')
->and($config['fs'])->toBe('config-fs')
->and($config['subpath'])->toBe('uploads')
- ->and($config['transformFs'])->toBe('config-fs')
- ->and($config['transformSubpath'])->toBe('transforms')
+ ->and($config)->not()->toHaveKeys(['transformFs', 'transformSubpath'])
->and($config['titleTranslationMethod'])->toBe('site')
->and($config['altTranslationMethod'])->toBe('none')
->and($config['sortOrder'])->toBe(3);
@@ -415,14 +411,13 @@
'name' => 'Disk Config',
'handle' => 'diskConfig',
'fsHandle' => 'config-disk',
- 'transformFsHandle' => 'disk:config-disk',
]);
$config = $volume->getConfig();
// Plain disk names get normalized to disk: prefix
expect($config['fs'])->toBe('disk:config-disk')
- ->and($config['transformFs'])->toBe('disk:config-disk');
+ ->and($config)->not()->toHaveKey('transformFs');
});
function createVolumeLocalFilesystem(string $handle): FsInterface
diff --git a/tests/Feature/Asset/VolumeValidationTest.php b/tests/Feature/Asset/VolumeValidationTest.php
index f0fa7ebaae8..225bddfc90c 100644
--- a/tests/Feature/Asset/VolumeValidationTest.php
+++ b/tests/Feature/Asset/VolumeValidationTest.php
@@ -148,41 +148,11 @@
->and($internal->errors()->has('fsHandle'))->toBeTrue();
});
-it('allows empty transformFsHandle and validates it when present', function () {
- $empty = new Volume;
-
- expect($empty->validate(['transformFsHandle']))->toBeTrue()
- ->and($empty->errors()->has('transformFsHandle'))->toBeFalse();
-
- $invalid = new Volume([
- 'transformFsHandle' => 'missing-transform-filesystem',
- ]);
-
- expect($invalid->validate(['transformFsHandle']))->toBeFalse()
- ->and($invalid->errors()->has('transformFsHandle'))->toBeTrue();
-
- config()->set('filesystems.disks.valid-transform-disk', [
- 'driver' => 'local',
- 'root' => storage_path('framework/testing/volume-validation/valid-transform-disk'),
- ]);
-
- $valid = new Volume([
- 'transformFsHandle' => 'valid-transform-disk',
- ]);
-
- expect($valid->validate(['transformFsHandle']))->toBeTrue()
- ->and($valid->errors()->has('transformFsHandle'))->toBeFalse();
-});
-
-it('rejects temp upload filesystem targets for fsHandle and transformFsHandle', function () {
+it('rejects temp upload filesystem targets for fsHandle', function () {
config()->set('filesystems.disks.temp-reserved', [
'driver' => 'local',
'root' => storage_path('framework/testing/volume-validation/temp-reserved'),
]);
- config()->set('filesystems.disks.temp-allowed', [
- 'driver' => 'local',
- 'root' => storage_path('framework/testing/volume-validation/temp-allowed'),
- ]);
Cms::config()->tempAssetUploadFs = 'disk:temp-reserved';
@@ -192,14 +162,6 @@
expect($volumeFs->validate(['fsHandle']))->toBeFalse()
->and($volumeFs->errors()->has('fsHandle'))->toBeTrue();
-
- $volumeTransformFs = new Volume([
- 'fsHandle' => 'temp-allowed',
- 'transformFsHandle' => 'temp-reserved',
- ]);
-
- expect($volumeTransformFs->validate(['transformFsHandle']))->toBeFalse()
- ->and($volumeTransformFs->errors()->has('transformFsHandle'))->toBeTrue();
});
it('requires subpath for shared filesystems and rejects overlapping roots', function () {
@@ -277,8 +239,6 @@ function insertVolumeValidationRow(array $overrides = []): void
'handle' => "volume{$counter}",
'fs' => 'disk:default-validation-disk',
'subpath' => null,
- 'transformFs' => null,
- 'transformSubpath' => null,
'titleTranslationMethod' => 'site',
'titleTranslationKeyFormat' => null,
'altTranslationMethod' => 'none',
diff --git a/tests/Feature/Asset/VolumesTest.php b/tests/Feature/Asset/VolumesTest.php
index 8d959c54992..7acf792d863 100644
--- a/tests/Feature/Asset/VolumesTest.php
+++ b/tests/Feature/Asset/VolumesTest.php
@@ -109,6 +109,23 @@
Event::assertDispatchedOnce(VolumeSaved::class);
});
+it('does not persist transformer settings on volume config', function () {
+ $this->volumes->saveVolume(new VolumeData([
+ 'name' => 'Transformed Volume',
+ 'handle' => 'transformedVolume',
+ 'fsHandle' => 'test-disk',
+ ]));
+
+ app()->forgetInstance(Volumes::class);
+ $this->volumes = app(Volumes::class);
+
+ $volume = $this->volumes->getVolumeByHandle('transformedVolume');
+ $configPath = CraftCms\Cms\ProjectConfig\ProjectConfig::PATH_VOLUMES.'.'.$volume->uid;
+ $projectConfigData = ProjectConfig::get($configPath);
+
+ expect($projectConfigData)->not()->toHaveKeys(['defaultTransformer', 'transformFs', 'transformSubpath']);
+});
+
it('can save an existing volume', function () {
$this->volumes->saveVolume(new VolumeData([
'name' => 'Original Name',
diff --git a/tests/Feature/Filesystem/FilesystemsTest.php b/tests/Feature/Filesystem/FilesystemsTest.php
index fe54a04ffe2..86d6d7bdb5d 100644
--- a/tests/Feature/Filesystem/FilesystemsTest.php
+++ b/tests/Feature/Filesystem/FilesystemsTest.php
@@ -11,6 +11,8 @@
use CraftCms\Cms\Filesystem\Filesystems\Local;
use CraftCms\Cms\Filesystem\Filesystems\MissingFs;
use CraftCms\Cms\Filesystem\Filesystems\Temp;
+use CraftCms\Cms\Image\Events\AssetTransformersResolving;
+use CraftCms\Cms\Image\ImageTransformer;
use CraftCms\Cms\ProjectConfig\Events\ItemRemoved;
use CraftCms\Cms\ProjectConfig\Events\ItemUpdated;
use CraftCms\Cms\ProjectConfig\ProjectConfig;
@@ -94,6 +96,51 @@
->and($filesystem->errors()->get('path'))->not()->toBeEmpty();
});
+it('does not serialize local filesystem render-only properties into project config', function () {
+ $filesystem = $this->service->createFilesystem([
+ 'type' => Local::class,
+ 'name' => 'Config Settings',
+ 'handle' => 'configSettings',
+ 'settings' => [
+ 'path' => sys_get_temp_dir().'/filesystems-service/config-settings',
+ 'settingsHtml' => '
bad config
',
+ 'rootPath' => sys_get_temp_dir().'/filesystems-service/bad-root-path',
+ ],
+ ]);
+
+ $config = $this->service->createFilesystemConfig($filesystem);
+
+ expect($config['settings'])
+ ->toHaveKey('path')
+ ->not->toHaveKey('settingsHtml')
+ ->not->toHaveKey('rootPath');
+});
+
+it('resolves filesystem default transformers from environment variables', function () {
+ $_SERVER['TEST_DEFAULT_ASSET_TRANSFORMER'] = 'customEnv';
+
+ try {
+ Event::listen(AssetTransformersResolving::class, function (AssetTransformersResolving $event) {
+ $event->types['customEnv'] = ImageTransformer::class;
+ });
+
+ $filesystem = $this->service->createFilesystem([
+ 'type' => Local::class,
+ 'name' => 'Env Default Transformer',
+ 'handle' => 'envDefaultTransformer',
+ 'defaultTransformer' => '$TEST_DEFAULT_ASSET_TRANSFORMER',
+ 'settings' => [
+ 'path' => sys_get_temp_dir().'/filesystems-service/env-default-transformer',
+ ],
+ ]);
+
+ expect($filesystem->getDefaultTransformer(false))->toBe('$TEST_DEFAULT_ASSET_TRANSFORMER')
+ ->and($filesystem->getDefaultTransformer())->toBe('customEnv');
+ } finally {
+ unset($_SERVER['TEST_DEFAULT_ASSET_TRANSFORMER']);
+ }
+});
+
it('rejects local filesystems inside system directories', function () {
$filesystem = FilesystemsFacade::createFilesystem([
'type' => Local::class,
diff --git a/tests/Feature/Http/Controllers/Assets/TransformControllerTest.php b/tests/Feature/Http/Controllers/Assets/TransformControllerTest.php
index f314a7dcb53..ede504329f8 100644
--- a/tests/Feature/Http/Controllers/Assets/TransformControllerTest.php
+++ b/tests/Feature/Http/Controllers/Assets/TransformControllerTest.php
@@ -6,6 +6,8 @@
use CraftCms\Cms\Asset\Models\Volume;
use CraftCms\Cms\Asset\Models\VolumeFolder as VolumeFolderModel;
use CraftCms\Cms\Http\Controllers\Assets\TransformController;
+use CraftCms\Cms\Image\Data\ImageTransform;
+use CraftCms\Cms\Image\ImageTransformer;
use CraftCms\Cms\Support\Facades\Path;
use CraftCms\Cms\User\Elements\User;
use Illuminate\Support\Facades\Crypt;
@@ -59,6 +61,26 @@
'assetId' => $asset->id,
])->assertStatus(400);
});
+
+ it('forbids anonymous raw transform ids for private transform filesystems', function () {
+ $asset = AssetModel::factory()->createElement([
+ 'volumeId' => $this->volume->id,
+ 'folderId' => $this->folder->id,
+ 'filename' => 'transform-test.jpg',
+ 'kind' => 'image',
+ 'width' => 1200,
+ 'height' => 800,
+ 'dateModified' => now()->subMinute(),
+ ]);
+
+ $index = (new ImageTransformer)->getTransformIndex($asset, new ImageTransform([
+ 'width' => 100,
+ 'height' => 100,
+ ]));
+
+ get(action([TransformController::class, 'generate'], ['transformId' => $index->id]))
+ ->assertForbidden();
+ });
});
describe('generateFallback', function () {
diff --git a/tests/Feature/Http/Controllers/Settings/FilesystemsControllerTest.php b/tests/Feature/Http/Controllers/Settings/FilesystemsControllerTest.php
index ac5d9123b7e..06b89bb732d 100644
--- a/tests/Feature/Http/Controllers/Settings/FilesystemsControllerTest.php
+++ b/tests/Feature/Http/Controllers/Settings/FilesystemsControllerTest.php
@@ -138,11 +138,18 @@
'type' => 'craft\fs\Local',
'name' => 'New Test Filesystem',
'handle' => 'newTestFilesystem',
+ 'defaultTransformer' => 'craft',
'types' => [
'craft-fs-Local' => [
'path' => '@webroot/test-uploads',
],
],
+ 'transformerSettings' => [
+ 'craft' => [
+ 'transformFsHandle' => 'newTestFilesystem',
+ 'transformSubpath' => 'transforms',
+ ],
+ ],
]);
$response->assertOk();
@@ -150,7 +157,12 @@
// Verify filesystem was created
$fs = Filesystems::getFilesystemByHandle('newTestFilesystem');
expect($fs)->not()->toBeNull();
- expect($fs->name)->toBe('New Test Filesystem');
+ expect($fs->name)->toBe('New Test Filesystem')
+ ->and($fs->getDefaultTransformer(false))->toBe('craft')
+ ->and($fs->getTransformerSettings('craft'))->toMatchArray([
+ 'transformFsHandle' => 'newTestFilesystem',
+ 'transformSubpath' => 'transforms',
+ ]);
});
test('save ignores null transient filesystem settings', function () {
diff --git a/tests/Feature/Image/ImageTransformerTest.php b/tests/Feature/Image/ImageTransformerTest.php
index 4fb2183b862..521ddaaff1a 100644
--- a/tests/Feature/Image/ImageTransformerTest.php
+++ b/tests/Feature/Image/ImageTransformerTest.php
@@ -2,14 +2,24 @@
declare(strict_types=1);
+use CraftCms\Cms\Asset\AssetTransforms;
+use CraftCms\Cms\Asset\Elements\Asset;
use CraftCms\Cms\Asset\Models\Asset as AssetModel;
use CraftCms\Cms\Asset\Models\Volume;
use CraftCms\Cms\Asset\Models\VolumeFolder as VolumeFolderModel;
+use CraftCms\Cms\Asset\Volumes;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Filesystem\Contracts\FsInterface;
+use CraftCms\Cms\Filesystem\Filesystems\Local;
+use CraftCms\Cms\Image\Contracts\AssetTransformerInterface;
+use CraftCms\Cms\Image\Contracts\EagerImageTransformerInterface;
use CraftCms\Cms\Image\Data\ImageTransform;
use CraftCms\Cms\Image\Data\ImageTransformIndex;
+use CraftCms\Cms\Image\Events\AssetTransformersResolving;
use CraftCms\Cms\Image\ImageTransformer;
+use CraftCms\Cms\Support\Facades\Filesystems;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Event;
beforeEach(function () {
config()->set('filesystems.disks.test-disk', [
@@ -141,3 +151,150 @@
->and($storedDateIndexed)->not->toContain('T')
->and($storedDateIndexed)->not->toContain('+');
});
+
+it('uses the filesystem default transformer when a transform does not specify one', function () {
+ $customTransformer = (new class implements AssetTransformerInterface
+ {
+ public static function handle(): string
+ {
+ return 'custom';
+ }
+
+ public static function displayName(): string
+ {
+ return 'Custom';
+ }
+
+ public static function gqlArguments(): array
+ {
+ return [];
+ }
+
+ public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bool $immediately): string
+ {
+ return 'https://example.test/custom-transform';
+ }
+
+ public function invalidateAssetTransforms(Asset $asset): void {}
+
+ public function getTransformString(ImageTransform $imageTransform, bool $ignoreHandle = false): string
+ {
+ return 'custom';
+ }
+
+ public function getImageTransformSettingsHtml(ImageTransform $imageTransform, bool $readOnly = false): ?string
+ {
+ return null;
+ }
+
+ public function getFilesystemSettingsHtml(FsInterface $filesystem, bool $readOnly = false): ?string
+ {
+ return null;
+ }
+ })::class;
+
+ Event::listen(AssetTransformersResolving::class, function (AssetTransformersResolving $event) use ($customTransformer) {
+ $event->types['custom'] = $customTransformer;
+ });
+
+ $filesystem = Filesystems::createFilesystem([
+ 'type' => Local::class,
+ 'name' => 'Custom Default',
+ 'handle' => 'customDefault',
+ 'defaultTransformer' => 'custom',
+ 'settings' => [
+ 'path' => storage_path('framework/testing/image-transformer-test/custom-default'),
+ ],
+ ]);
+ Filesystems::saveFilesystem($filesystem, false);
+
+ $this->volume->fs = 'customDefault';
+ $this->volume->save();
+ app()->forgetInstance(Volumes::class);
+
+ $asset = ($this->createImageAsset)();
+
+ expect($asset->getUrl(['width' => 100]))->toBe('https://example.test/custom-transform');
+});
+
+it('eager loads transforms with the filesystem default transformer', function () {
+ $customTransformer = new class implements AssetTransformerInterface, EagerImageTransformerInterface
+ {
+ public array $eagerLoadedTransforms = [];
+
+ public array $eagerLoadedAssets = [];
+
+ public static function handle(): string
+ {
+ return 'custom';
+ }
+
+ public static function displayName(): string
+ {
+ return 'Custom';
+ }
+
+ public static function gqlArguments(): array
+ {
+ return [];
+ }
+
+ public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bool $immediately): string
+ {
+ return 'https://example.test/custom-transform';
+ }
+
+ public function invalidateAssetTransforms(Asset $asset): void {}
+
+ public function getTransformString(ImageTransform $imageTransform, bool $ignoreHandle = false): string
+ {
+ return 'custom';
+ }
+
+ public function getImageTransformSettingsHtml(ImageTransform $imageTransform, bool $readOnly = false): ?string
+ {
+ return null;
+ }
+
+ public function getFilesystemSettingsHtml(FsInterface $filesystem, bool $readOnly = false): ?string
+ {
+ return null;
+ }
+
+ public function eagerLoadTransforms(array $transforms, array $assets): void
+ {
+ $this->eagerLoadedTransforms = $transforms;
+ $this->eagerLoadedAssets = $assets;
+ }
+ };
+
+ Event::listen(AssetTransformersResolving::class, function (AssetTransformersResolving $event) use ($customTransformer) {
+ $event->types['custom'] = $customTransformer;
+ });
+
+ $filesystem = Filesystems::createFilesystem([
+ 'type' => Local::class,
+ 'name' => 'Custom Eager Default',
+ 'handle' => 'customEagerDefault',
+ 'defaultTransformer' => 'custom',
+ 'settings' => [
+ 'path' => storage_path('framework/testing/image-transformer-test/custom-eager-default'),
+ ],
+ ]);
+ Filesystems::saveFilesystem($filesystem, false);
+
+ $this->volume->fs = 'customEagerDefault';
+ $this->volume->save();
+ app()->forgetInstance(Volumes::class);
+
+ $asset = ($this->createImageAsset)();
+
+ app(AssetTransforms::class)->eagerLoadTransforms([$asset], [
+ ['width' => 100],
+ ]);
+
+ expect($customTransformer->eagerLoadedAssets)->toHaveCount(1)
+ ->and($customTransformer->eagerLoadedAssets[0]->id)->toBe($asset->id)
+ ->and($customTransformer->eagerLoadedTransforms)->toHaveCount(1)
+ ->and($customTransformer->eagerLoadedTransforms[0]->getTransformer())->toBe('custom');
+});
diff --git a/tests/Feature/Image/ImageTransformsTest.php b/tests/Feature/Image/ImageTransformsTest.php
index c9691d448b7..8e625d50d63 100644
--- a/tests/Feature/Image/ImageTransformsTest.php
+++ b/tests/Feature/Image/ImageTransformsTest.php
@@ -2,11 +2,15 @@
declare(strict_types=1);
+use CraftCms\Cms\Asset\AssetTransforms;
use CraftCms\Cms\Asset\Elements\Asset;
use CraftCms\Cms\Asset\Exceptions\ImageTransformException;
-use CraftCms\Cms\Image\Contracts\ImageTransformerInterface;
+use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Filesystem\Contracts\FsInterface;
+use CraftCms\Cms\Gql\Arguments\Transform as TransformArguments;
+use CraftCms\Cms\Image\Contracts\AssetTransformerInterface;
use CraftCms\Cms\Image\Data\ImageTransform;
-use CraftCms\Cms\Image\Events\ImageTransformersResolving;
+use CraftCms\Cms\Image\Events\AssetTransformersResolving;
use CraftCms\Cms\Image\Events\TransformDeleted;
use CraftCms\Cms\Image\Events\TransformDeleting;
use CraftCms\Cms\Image\Events\TransformDeletionApplying;
@@ -15,12 +19,16 @@
use CraftCms\Cms\Image\ImageTransformer;
use CraftCms\Cms\Image\ImageTransforms;
use CraftCms\Cms\Image\Models\ImageTransform as ImageTransformModel;
+use CraftCms\Cms\Support\Facades\AssetTransforms as AssetTransformsFacade;
use CraftCms\Cms\Support\Facades\ImageTransforms as ImageTransformsFacade;
use CraftCms\Cms\Support\Facades\ProjectConfig;
+use GraphQL\Type\Definition\Type;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
beforeEach(function () {
$this->service = app(ImageTransforms::class);
+ $this->assetTransforms = app(AssetTransforms::class);
});
it('is a singleton', function () {
@@ -28,6 +36,11 @@
expect($this->service)->toBe(app(ImageTransforms::class));
});
+it('has an asset transforms singleton', function () {
+ expect(AssetTransformsFacade::getFacadeRoot())->toBe(app(AssetTransforms::class));
+ expect($this->assetTransforms)->toBe(app(AssetTransforms::class));
+});
+
describe('getAllTransforms', function () {
it('returns empty collection when no transforms exist', function () {
expect($this->service->getAllTransforms())->toBeEmpty();
@@ -175,6 +188,41 @@
Event::assertDispatchedOnce(TransformSaved::class);
});
+ it('persists transformer and extra settings', function () {
+ $this->service->saveTransform(new ImageTransform([
+ 'name' => 'Blurred',
+ 'handle' => 'blurred',
+ 'width' => 500,
+ 'transformer' => 'custom',
+ 'settings' => ['blur' => 12],
+ ]));
+
+ $this->service->reset();
+ $transform = $this->service->getTransformByHandle('blurred');
+
+ expect($transform->getTransformer())->toBe('custom')
+ ->and($transform->settings)->toBe(['blur' => 12]);
+ });
+
+ it('hydrates JSON encoded settings from stored transforms', function () {
+ $this->service->saveTransform(new ImageTransform([
+ 'name' => 'Blurred',
+ 'handle' => 'blurred',
+ 'width' => 500,
+ 'transformer' => 'custom',
+ 'settings' => ['blur' => 12],
+ ]));
+
+ DB::table(Table::IMAGETRANSFORMS)
+ ->where('handle', 'blurred')
+ ->update(['settings' => json_encode(['blur' => 12])]);
+
+ $this->service->reset();
+
+ expect($this->service->getTransformByHandle('blurred')->settings)
+ ->toBe(['blur' => 12]);
+ });
+
it('assigns id to the transform after saving', function () {
$transform = new ImageTransform([
'name' => 'Test',
@@ -345,59 +393,212 @@
});
});
-describe('getAllImageTransformers', function () {
- it('includes the default ImageTransformer', function () {
- $transformers = $this->service->getAllImageTransformers();
+describe('asset transformers', function () {
+ it('includes the craft transformer', function () {
+ expect($this->assetTransforms->getAllAssetTransformers())
+ ->toHaveKey('craft', ImageTransformer::class);
+ });
+
+ it('allows adding custom transformers by handle', function () {
+ $customTransformer = (new class implements AssetTransformerInterface
+ {
+ public static function handle(): string
+ {
+ return 'custom';
+ }
- expect($transformers)->toContain(ImageTransformer::class);
+ public static function displayName(): string
+ {
+ return 'Custom';
+ }
+
+ public static function gqlArguments(): array
+ {
+ return [];
+ }
+
+ public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bool $immediately): string
+ {
+ return 'https://example.test/custom';
+ }
+
+ public function invalidateAssetTransforms(Asset $asset): void {}
+
+ public function getTransformString(ImageTransform $imageTransform, bool $ignoreHandle = false): string
+ {
+ return 'custom';
+ }
+
+ public function getImageTransformSettingsHtml(ImageTransform $imageTransform, bool $readOnly = false): ?string
+ {
+ return null;
+ }
+
+ public function getFilesystemSettingsHtml(FsInterface $filesystem, bool $readOnly = false): ?string
+ {
+ return null;
+ }
+ })::class;
+
+ Event::listen(AssetTransformersResolving::class, function (AssetTransformersResolving $event) use ($customTransformer) {
+ $event->types['custom'] = $customTransformer;
+ });
+
+ expect($this->assetTransforms->getAllAssetTransformers())->toHaveKey('custom', $customTransformer)
+ ->and($this->assetTransforms->getAssetTransformer('custom'))->toBeInstanceOf(AssetTransformerInterface::class);
});
- it('fires ImageTransformersResolving event', function () {
- Event::fake([ImageTransformersResolving::class]);
+ it('allows adding custom transformer instances by handle', function () {
+ $customTransformer = new class implements AssetTransformerInterface
+ {
+ public static function handle(): string
+ {
+ return 'custom';
+ }
+
+ public static function displayName(): string
+ {
+ return 'Custom';
+ }
+
+ public static function gqlArguments(): array
+ {
+ return [];
+ }
+
+ public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bool $immediately): string
+ {
+ return 'https://example.test/custom';
+ }
+
+ public function invalidateAssetTransforms(Asset $asset): void {}
+
+ public function getTransformString(ImageTransform $imageTransform, bool $ignoreHandle = false): string
+ {
+ return 'custom';
+ }
+
+ public function getImageTransformSettingsHtml(ImageTransform $imageTransform, bool $readOnly = false): ?string
+ {
+ return null;
+ }
+
+ public function getFilesystemSettingsHtml(FsInterface $filesystem, bool $readOnly = false): ?string
+ {
+ return null;
+ }
+ };
- $this->service->getAllImageTransformers();
+ Event::listen(AssetTransformersResolving::class, function (AssetTransformersResolving $event) use ($customTransformer) {
+ $event->types['custom'] = $customTransformer;
+ });
- Event::assertDispatchedOnce(ImageTransformersResolving::class);
+ expect($this->assetTransforms->getAssetTransformer('custom'))->toBe($customTransformer);
});
- it('allows adding custom transformers via event', function () {
- $customTransformer = (new class implements ImageTransformerInterface
+ it('adds transformer GraphQL arguments', function () {
+ $customTransformer = (new class implements AssetTransformerInterface
{
+ public static function handle(): string
+ {
+ return 'custom';
+ }
+
+ public static function displayName(): string
+ {
+ return 'Custom';
+ }
+
+ public static function gqlArguments(): array
+ {
+ return [
+ 'blur' => [
+ 'type' => Type::int(),
+ ],
+ ];
+ }
+
public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bool $immediately): string
{
return '';
}
public function invalidateAssetTransforms(Asset $asset): void {}
+
+ public function getTransformString(ImageTransform $imageTransform, bool $ignoreHandle = false): string
+ {
+ return '';
+ }
+
+ public function getImageTransformSettingsHtml(ImageTransform $imageTransform, bool $readOnly = false): ?string
+ {
+ return null;
+ }
+
+ public function getFilesystemSettingsHtml(FsInterface $filesystem, bool $readOnly = false): ?string
+ {
+ return null;
+ }
})::class;
- Event::listen(ImageTransformersResolving::class, function (ImageTransformersResolving $event) use ($customTransformer) {
- $event->types[] = $customTransformer;
+ Event::listen(AssetTransformersResolving::class, function (AssetTransformersResolving $event) use ($customTransformer) {
+ $event->types['custom'] = $customTransformer;
});
- $transformers = $this->service->getAllImageTransformers();
-
- expect($transformers)->toContain($customTransformer);
+ expect(TransformArguments::getArguments())->toHaveKey('blur');
});
-});
-describe('getImageTransformer', function () {
- it('returns an instance of the transformer', function () {
- $transformer = $this->service->getImageTransformer(ImageTransformer::class);
+ it('rejects transformer GraphQL argument collisions', function () {
+ $customTransformer = (new class implements AssetTransformerInterface
+ {
+ public static function handle(): string
+ {
+ return 'custom';
+ }
- expect($transformer)->toBeInstanceOf(ImageTransformerInterface::class);
- });
+ public static function displayName(): string
+ {
+ return 'Custom';
+ }
- it('memoizes transformer instances', function () {
- $first = $this->service->getImageTransformer(ImageTransformer::class);
- $second = $this->service->getImageTransformer(ImageTransformer::class);
+ public static function gqlArguments(): array
+ {
+ return [
+ 'width' => [
+ 'type' => Type::int(),
+ ],
+ ];
+ }
- expect($first)->toBe($second);
- });
+ public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bool $immediately): string
+ {
+ return '';
+ }
+
+ public function invalidateAssetTransforms(Asset $asset): void {}
+
+ public function getTransformString(ImageTransform $imageTransform, bool $ignoreHandle = false): string
+ {
+ return '';
+ }
+
+ public function getImageTransformSettingsHtml(ImageTransform $imageTransform, bool $readOnly = false): ?string
+ {
+ return null;
+ }
+
+ public function getFilesystemSettingsHtml(FsInterface $filesystem, bool $readOnly = false): ?string
+ {
+ return null;
+ }
+ })::class;
+
+ Event::listen(AssetTransformersResolving::class, function (AssetTransformersResolving $event) use ($customTransformer) {
+ $event->types['custom'] = $customTransformer;
+ });
- it('throws for invalid transformer class', function () {
- $this->service->getImageTransformer(stdClass::class);
- })->throws(ImageTransformException::class, 'Invalid image transformer');
+ TransformArguments::getArguments();
+ })->throws(ImageTransformException::class);
});
describe('reset', function () {
diff --git a/tests/Unit/Image/Data/ImageTransformTest.php b/tests/Unit/Image/Data/ImageTransformTest.php
index 96e6d2133a5..cc769fa1957 100644
--- a/tests/Unit/Image/Data/ImageTransformTest.php
+++ b/tests/Unit/Image/Data/ImageTransformTest.php
@@ -3,7 +3,6 @@
declare(strict_types=1);
use CraftCms\Cms\Image\Data\ImageTransform;
-use CraftCms\Cms\Image\ImageTransformer;
describe('defaults', function () {
test('has sensible defaults', function () {
@@ -71,25 +70,25 @@
});
describe('transformer', function () {
- test('defaults to ImageTransformer', function () {
+ test('defaults to volume transformer resolution', function () {
$transform = new ImageTransform;
- expect($transform->getTransformer())->toBe(ImageTransformer::class);
+ expect($transform->getTransformer())->toBeNull();
});
- test('can set a custom transformer', function () {
+ test('can set a custom transformer handle', function () {
$transform = new ImageTransform;
- $transform->setTransformer('Custom\Transformer');
+ $transform->setTransformer('custom');
- expect($transform->getTransformer())->toBe('Custom\Transformer');
+ expect($transform->getTransformer())->toBe('custom');
});
- test('falls back to default when set to null', function () {
+ test('falls back to volume transformer resolution when set to null', function () {
$transform = new ImageTransform;
- $transform->setTransformer('Custom\Transformer');
+ $transform->setTransformer('custom');
$transform->setTransformer(null);
- expect($transform->getTransformer())->toBe(ImageTransformer::class);
+ expect($transform->getTransformer())->toBeNull();
});
});
@@ -106,6 +105,8 @@
'interlace' => 'none',
'format' => 'webp',
'fill' => '#ff0000',
+ 'settings' => ['blur' => 12],
+ 'transformer' => 'custom',
'upscale' => true,
]);
@@ -119,6 +120,8 @@
'name' => 'Thumbnail',
'position' => 'center-center',
'quality' => 80,
+ 'settings' => ['blur' => 12],
+ 'transformer' => 'custom',
'upscale' => true,
'width' => 200,
]);
@@ -317,7 +320,7 @@
});
describe('DEFAULT_TRANSFORMER constant', function () {
- test('points to ImageTransformer class', function () {
- expect(ImageTransform::DEFAULT_TRANSFORMER)->toBe(ImageTransformer::class);
+ test('points to the built-in transformer handle', function () {
+ expect(ImageTransform::DEFAULT_TRANSFORMER)->toBe('craft');
});
});
diff --git a/tests/Unit/Image/ImageTransformHelperTest.php b/tests/Unit/Image/ImageTransformHelperTest.php
index f2cfc2ffdde..1232c2afef2 100644
--- a/tests/Unit/Image/ImageTransformHelperTest.php
+++ b/tests/Unit/Image/ImageTransformHelperTest.php
@@ -414,15 +414,31 @@
expect($result)->toBe($original);
});
- test('ignores unknown parameters', function () {
+ test('preserves unknown parameters as settings', function () {
$original = new ImageTransform(['width' => 800]);
$extended = ImageTransformHelper::extendTransform($original, [
'width' => 400,
- 'nonExistentProperty' => 'value',
+ 'blur' => 12,
]);
- expect($extended->width)->toBe(400);
+ expect($extended->width)->toBe(400)
+ ->and($extended->settings)->toBe(['blur' => 12]);
+ });
+
+ test('preserves transformer overrides as transform properties', function () {
+ $original = new ImageTransform([
+ 'width' => 800,
+ 'transformer' => 'base',
+ ]);
+
+ $extended = ImageTransformHelper::extendTransform($original, [
+ 'transformer' => 'custom',
+ 'blur' => 12,
+ ]);
+
+ expect($extended->getTransformer())->toBe('custom')
+ ->and($extended->settings)->toBe(['blur' => 12]);
});
test('matches legacy extendTransform provider cases', function (ImageTransform $transform, array $parameters, array $expected) {
diff --git a/tests/UnitTestCase.php b/tests/UnitTestCase.php
index a7ad22ef5ba..4ef6f85dd0f 100644
--- a/tests/UnitTestCase.php
+++ b/tests/UnitTestCase.php
@@ -8,6 +8,7 @@
use CraftCms\Cms\Edition;
use CraftCms\Cms\Site\Data\Site;
use CraftCms\Cms\Support\Facades\Sites;
+use CraftCms\Cms\Support\Facades\Updates;
use CraftCms\Cms\Tests\Support\RegistersPackageAliases;
use CraftCms\Cms\View\TemplateMode;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
@@ -66,6 +67,7 @@ protected function setUp(): void
TemplateMode::set(TemplateMode::Cp);
Sites::setCurrentSite(new Site);
+ Updates::shouldReceive('isUpdatePending')->andReturnFalse();
app()->setLocale('en-US');
diff --git a/yii2-adapter/legacy/elements/db/AssetQuery.php b/yii2-adapter/legacy/elements/db/AssetQuery.php
index 7f3446a480e..bc48107c4e0 100644
--- a/yii2-adapter/legacy/elements/db/AssetQuery.php
+++ b/yii2-adapter/legacy/elements/db/AssetQuery.php
@@ -20,7 +20,7 @@
use CraftCms\Cms\Asset\Volumes;
use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Support\Arr;
-use CraftCms\Cms\Support\Facades\ImageTransforms;
+use CraftCms\Cms\Support\Facades\AssetTransforms;
use CraftCms\Cms\User\Elements\User;
use Illuminate\Support\Facades\Auth;
use InvalidArgumentException;
@@ -879,7 +879,7 @@ public function afterPopulate(array $elements): array
$transforms = is_string($transforms) ? str($transforms)->explode(',')->all() : [$transforms];
}
- ImageTransforms::eagerLoadTransforms($elements, $transforms);
+ AssetTransforms::eagerLoadTransforms($elements, $transforms);
}
return $elements;
diff --git a/yii2-adapter/legacy/imagetransforms/FallbackTransformer.php b/yii2-adapter/legacy/imagetransforms/FallbackTransformer.php
index ad6173d301b..004f0adfa1c 100644
--- a/yii2-adapter/legacy/imagetransforms/FallbackTransformer.php
+++ b/yii2-adapter/legacy/imagetransforms/FallbackTransformer.php
@@ -2,16 +2,42 @@
namespace craft\imagetransforms;
-/** @phpstan-ignore-next-line */
-if (false) {
- /**
- * FallbackTransformer transforms image assets using GD or ImageMagick, and stores them in the storage folder.
- *
- * @author Pixel & Tonic, Inc.
- * @since 4.4.0
- * @deprecated 6.0.0 use {@see \CraftCms\Cms\Image\FallbackTransformer} instead.
- */
- class FallbackTransformer
+use CraftCms\Cms\Asset\AssetsHelper;
+use CraftCms\Cms\Asset\Elements\Asset;
+use CraftCms\Cms\Cms;
+use CraftCms\Cms\Image\Data\ImageTransform;
+use CraftCms\Cms\Image\ImageTransformHelper;
+use CraftCms\Cms\Support\Url;
+use Illuminate\Support\Facades\Crypt;
+
+/**
+ * FallbackTransformer transforms image assets using GD or ImageMagick, and stores them in the storage folder.
+ *
+ * @author Pixel & Tonic, Inc.
+ * @since 4.4.0
+ * @deprecated 6.0.0
+ */
+class FallbackTransformer implements \craft\base\imagetransforms\ImageTransformerInterface
+{
+ public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bool $immediately): string
+ {
+ if (match ($asset->getMimeType()) {
+ 'image/gif' => Cms::config()->transformGifs,
+ 'image/svg+xml' => Cms::config()->transformSvgs,
+ default => true,
+ }) {
+ $transformString = ltrim(ImageTransformHelper::getTransformString($imageTransform, true), '_');
+ } else {
+ $transformString = 'original';
+ }
+
+ return Url::actionUrl('assets/generate-fallback-transform', [
+ 'transform' => Crypt::encrypt(sprintf('%s,%s', $asset->id, $transformString)),
+ ] + AssetsHelper::revParams($asset));
+ }
+
+ public function invalidateAssetTransforms(Asset $asset): void
{
+ // No reliable way to do this, so not worth trying
}
}
diff --git a/yii2-adapter/legacy/imagetransforms/LegacyImageTransformerAdapter.php b/yii2-adapter/legacy/imagetransforms/LegacyImageTransformerAdapter.php
new file mode 100644
index 00000000000..8a73d687704
--- /dev/null
+++ b/yii2-adapter/legacy/imagetransforms/LegacyImageTransformerAdapter.php
@@ -0,0 +1,65 @@
+getImageTransforms()
+ ->getImageTransformer($this->type)
+ ->getTransformUrl($asset, $imageTransform, $immediately);
+ }
+
+ public function invalidateAssetTransforms(Asset $asset): void
+ {
+ Craft::$app->getImageTransforms()
+ ->getImageTransformer($this->type)
+ ->invalidateAssetTransforms($asset);
+ }
+
+ public function getTransformString(ImageTransform $imageTransform, bool $ignoreHandle = false): string
+ {
+ return ImageTransformHelper::getTransformString($imageTransform, $ignoreHandle);
+ }
+
+ public function getImageTransformSettingsHtml(ImageTransform $imageTransform, bool $readOnly = false): ?string
+ {
+ return null;
+ }
+
+ public function getFilesystemSettingsHtml(FsInterface $filesystem, bool $readOnly = false): ?string
+ {
+ return null;
+ }
+}
diff --git a/yii2-adapter/legacy/services/ImageTransforms.php b/yii2-adapter/legacy/services/ImageTransforms.php
index 19d5ea9e827..008d111092f 100644
--- a/yii2-adapter/legacy/services/ImageTransforms.php
+++ b/yii2-adapter/legacy/services/ImageTransforms.php
@@ -8,14 +8,17 @@
namespace craft\services;
use Craft;
-use craft\base\imagetransforms\ImageTransformerInterface;
use craft\events\AssetEvent;
use craft\events\ImageTransformEvent;
use craft\events\RegisterComponentTypesEvent;
+use craft\imagetransforms\ImageTransformer;
+use craft\imagetransforms\LegacyImageTransformerAdapter;
+use CraftCms\Cms\Asset\AssetTransforms as AssetTransformsService;
use CraftCms\Cms\Asset\Elements\Asset;
+use CraftCms\Cms\Image\Contracts\ImageTransformerInterface;
use CraftCms\Cms\Image\Data\ImageTransform as ImageTransformData;
+use CraftCms\Cms\Image\Events\AssetTransformersResolving;
use CraftCms\Cms\Image\Events\AssetTransformsInvalidating;
-use CraftCms\Cms\Image\Events\ImageTransformersResolving;
use CraftCms\Cms\Image\Events\TransformDeleted;
use CraftCms\Cms\Image\Events\TransformDeleting;
use CraftCms\Cms\Image\Events\TransformDeletionApplying;
@@ -38,6 +41,11 @@
*/
class ImageTransforms extends Component
{
+ /**
+ * @var ImageTransformerInterface[]
+ */
+ private array $_imageTransformers = [];
+
/**
* @event ImageTransformEvent The event that is triggered before an image transform is saved
*/
@@ -186,7 +194,7 @@ public function handleDeletedTransform(ConfigEvent $event): void
*/
public function eagerLoadTransforms(array $assets, array $transforms): void
{
- $this->service()->eagerLoadTransforms($assets, $transforms);
+ $this->assetTransforms()->eagerLoadTransforms($assets, $transforms);
}
/**
@@ -197,7 +205,15 @@ public function eagerLoadTransforms(array $assets, array $transforms): void
*/
public function getImageTransformer(string $type, array $config = []): ImageTransformerInterface
{
- return $this->service()->getImageTransformer($type, $config);
+ if (array_key_exists($type, $this->_imageTransformers)) {
+ return $this->_imageTransformers[$type];
+ }
+
+ if (!is_subclass_of($type, ImageTransformerInterface::class)) {
+ throw new \InvalidArgumentException("Invalid image transformer: $type");
+ }
+
+ return $this->_imageTransformers[$type] = new $type($config);
}
/**
@@ -207,7 +223,7 @@ public function getImageTransformer(string $type, array $config = []): ImageTran
*/
public function deleteAllTransformData(Asset $asset): void
{
- $this->service()->deleteAllTransformData($asset);
+ $this->assetTransforms()->deleteAllTransformData($asset);
}
/**
@@ -217,7 +233,7 @@ public function deleteAllTransformData(Asset $asset): void
*/
public function deleteResizedAssetVersion(Asset $asset): void
{
- $this->service()->deleteResizedAssetVersion($asset);
+ $this->assetTransforms()->deleteResizedAssetVersion($asset);
}
/**
@@ -227,7 +243,7 @@ public function deleteResizedAssetVersion(Asset $asset): void
*/
public function deleteCreatedTransformsForAsset(Asset $asset): void
{
- $this->service()->deleteCreatedTransformsForAsset($asset);
+ $this->assetTransforms()->deleteCreatedTransformsForAsset($asset);
}
/**
@@ -238,7 +254,17 @@ public function deleteCreatedTransformsForAsset(Asset $asset): void
*/
public function getAllImageTransformers(): array
{
- return $this->service()->getAllImageTransformers();
+ $event = new RegisterComponentTypesEvent([
+ 'types' => [
+ ImageTransformer::class,
+ ],
+ ]);
+
+ if (Craft::$app->getImageTransforms()->hasEventHandlers(self::EVENT_REGISTER_IMAGE_TRANSFORMERS)) {
+ Craft::$app->getImageTransforms()->trigger(self::EVENT_REGISTER_IMAGE_TRANSFORMERS, $event);
+ }
+
+ return $event->types;
}
public static function registerEvents(): void
@@ -305,14 +331,14 @@ public static function registerEvents(): void
]));
});
- EventFacade::listen(ImageTransformersResolving::class, function(ImageTransformersResolving $event) {
- if (!Craft::$app->getImageTransforms()->hasEventHandlers(self::EVENT_REGISTER_IMAGE_TRANSFORMERS)) {
- return;
- }
+ EventFacade::listen(AssetTransformersResolving::class, function(AssetTransformersResolving $event) {
+ foreach (Craft::$app->getImageTransforms()->getAllImageTransformers() as $type) {
+ if ($type === ImageTransformer::class) {
+ continue;
+ }
- $legacyEvent = new RegisterComponentTypesEvent(['types' => $event->types]);
- Craft::$app->getImageTransforms()->trigger(self::EVENT_REGISTER_IMAGE_TRANSFORMERS, $legacyEvent);
- $event->types = $legacyEvent->types;
+ $event->types[$type] = new LegacyImageTransformerAdapter($type);
+ }
});
}
@@ -320,4 +346,9 @@ private function service(): ImageTransformsService
{
return app(ImageTransformsService::class);
}
+
+ private function assetTransforms(): AssetTransformsService
+ {
+ return app(AssetTransformsService::class);
+ }
}
diff --git a/yii2-adapter/legacy/test/Craft.php b/yii2-adapter/legacy/test/Craft.php
index 24c7ed2c7a1..79310b941df 100644
--- a/yii2-adapter/legacy/test/Craft.php
+++ b/yii2-adapter/legacy/test/Craft.php
@@ -253,6 +253,7 @@ public function _after(TestInterface $test): void
app()->forgetInstance(Volumes::class);
app()->forgetInstance(Assets::class);
app()->forgetInstance(Folders::class);
+ app()->forgetInstance(\CraftCms\Cms\Asset\AssetTransforms::class);
app()->forgetInstance(ImageTransforms::class);
$this->resetPathService();
@@ -261,6 +262,7 @@ public function _after(TestInterface $test): void
\CraftCms\Cms\Support\Facades\Sections::clearResolvedInstances();
\CraftCms\Cms\Support\Facades\Assets::clearResolvedInstances();
\CraftCms\Cms\Support\Facades\Folders::clearResolvedInstances();
+ \CraftCms\Cms\Support\Facades\AssetTransforms::clearResolvedInstances();
\CraftCms\Cms\Support\Facades\ImageTransforms::clearResolvedInstances();
DB::disconnect();
diff --git a/yii2-adapter/src/ClassAliases.php b/yii2-adapter/src/ClassAliases.php
index a8af4732390..febddcdceac 100644
--- a/yii2-adapter/src/ClassAliases.php
+++ b/yii2-adapter/src/ClassAliases.php
@@ -320,7 +320,6 @@
use craft\image\Raster;
use craft\image\Svg;
use craft\image\SvgAllowedAttributes;
-use craft\imagetransforms\FallbackTransformer;
use craft\models\AssetIndexData;
use craft\models\AssetIndexingSession;
use craft\models\DeprecationError;
@@ -701,7 +700,6 @@ class_alias(\CraftCms\Cms\Gql\Data\GqlSchema::class, GqlSchema::class);
class_alias(\CraftCms\Cms\Gql\Data\GqlToken::class, GqlToken::class);
class_alias(\CraftCms\Cms\Gql\Exceptions\GqlException::class, GqlException::class);
class_alias(\CraftCms\Cms\Http\Controllers\Users\EditUserTrait::class, EditUserTrait::class);
- class_alias(\CraftCms\Cms\Image\FallbackTransformer::class, FallbackTransformer::class);
class_alias(\CraftCms\Cms\Image\Image::class, Image::class);
class_alias(\CraftCms\Cms\Image\Raster::class, Raster::class);
class_alias(\CraftCms\Cms\Image\Svg::class, Svg::class);
diff --git a/yii2-adapter/tests-laravel/Legacy/Image/LegacyImageTransformersTest.php b/yii2-adapter/tests-laravel/Legacy/Image/LegacyImageTransformersTest.php
new file mode 100644
index 00000000000..d763cdbb839
--- /dev/null
+++ b/yii2-adapter/tests-laravel/Legacy/Image/LegacyImageTransformersTest.php
@@ -0,0 +1,66 @@
+getImageTransforms()->on(
+ LegacyImageTransforms::EVENT_REGISTER_IMAGE_TRANSFORMERS,
+ function(RegisterComponentTypesEvent $event) use ($customTransformer) {
+ $event->types[] = $customTransformer;
+ },
+ );
+
+ expect(Craft::$app->getImageTransforms()->getAllImageTransformers())
+ ->toContain($customTransformer);
+});
+
+it('bridges legacy image transformers into the asset transformer registry', function() {
+ $customTransformer = (new class() implements ImageTransformerInterface {
+ public function __construct(array $config = [])
+ {
+ }
+
+ public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bool $immediately): string
+ {
+ return 'https://example.test/legacy-transform';
+ }
+
+ public function invalidateAssetTransforms(Asset $asset): void
+ {
+ }
+ })::class;
+
+ Craft::$app->getImageTransforms()->on(
+ LegacyImageTransforms::EVENT_REGISTER_IMAGE_TRANSFORMERS,
+ function(RegisterComponentTypesEvent $event) use ($customTransformer) {
+ $event->types[] = $customTransformer;
+ },
+ );
+
+ $transformers = app(AssetTransforms::class)->getAllAssetTransformers();
+
+ expect($transformers)->toHaveKey($customTransformer)
+ ->and(app(AssetTransforms::class)->getAssetTransformer($customTransformer))
+ ->toBeInstanceOf(AssetTransformerInterface::class);
+});
diff --git a/yii2-adapter/tests/unit/elements/AssetElementTest.php b/yii2-adapter/tests/unit/elements/AssetElementTest.php
index 181ec607c27..629be12d111 100644
--- a/yii2-adapter/tests/unit/elements/AssetElementTest.php
+++ b/yii2-adapter/tests/unit/elements/AssetElementTest.php
@@ -8,12 +8,13 @@
namespace crafttests\unit\elements;
use craft\fs\Local;
-use craft\imagetransforms\ImageTransformer;
use craft\models\ImageTransform;
use craft\test\TestCase;
use CraftCms\Cms\Asset\Data\Volume;
use CraftCms\Cms\Asset\Elements\Asset;
use CraftCms\Cms\Cms;
+use CraftCms\Cms\Image\ImageTransformer;
+use CraftCms\Cms\Support\Facades\AssetTransforms;
use CraftCms\Cms\Support\Facades\ImageTransforms;
use UnitTester;
@@ -43,9 +44,6 @@ public function testTransformWithOverrideParameters(): void
'getFs' => $this->make(Local::class, [
'hasUrls' => true,
]),
- 'getTransformFs' => $this->make(Local::class, [
- 'hasUrls' => true,
- ]),
]),
'folderId' => 2,
'filename' => 'foo.jpg',
@@ -55,9 +53,13 @@ public function testTransformWithOverrideParameters(): void
->andReturn($this->make(ImageTransform::class, [
'width' => 400,
'height' => 200,
- 'getImageTransformer' => $this->make(ImageTransformer::class, [
- 'getTransformUrl' => fn(Asset $asset, ImageTransform $transform) => 'w=' . $transform->width . '&h=' . $transform->height,
- ]),
+ ]));
+
+ AssetTransforms::shouldReceive('resolveTransformerHandle')
+ ->andReturn(ImageTransform::DEFAULT_TRANSFORMER);
+ AssetTransforms::shouldReceive('getAssetTransformer')
+ ->andReturn($this->make(ImageTransformer::class, [
+ 'getTransformUrl' => fn(Asset $asset, ImageTransform $transform, bool $immediately) => 'w=' . $transform->width . '&h=' . $transform->height,
]));
$previousValue = Cms::config()->generateTransformsBeforePageLoad;