Skip to content

[3.x] Add edge image editor saves#168

Open
timkelty wants to merge 11 commits into
3.xfrom
timkelty/cloud-image-editor-edge-save
Open

[3.x] Add edge image editor saves#168
timkelty wants to merge 11 commits into
3.xfrom
timkelty/cloud-image-editor-edge-save

Conversation

@timkelty

@timkelty timkelty commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Description

Adds a Cloud image-editor save handler that maps Craft editor state to Cloudflare image transform options, then saves or replaces the asset without PHP image decoding.

Requires Craft CMS versions that expose the image-editor save event and expands Cloudflare option support for the editor transform path.

@timkelty
timkelty force-pushed the timkelty/cloud-image-editor-edge-save branch 3 times, most recently from 7ca1d8b to 19827b5 Compare June 12, 2026 14:51
@timkelty
timkelty marked this pull request as ready for review June 12, 2026 14:54
@timkelty
timkelty requested a review from Copilot June 12, 2026 14:54

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 19827b5f8d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/imagetransforms/ImageEditor.php Outdated
Comment thread src/imagetransforms/ImageTransformer.php Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a Craft Cloud image-editor save handler that converts Craft’s image editor state (crop/rotate/flip/zoom + focal point) into Cloudflare image transform options, allowing edited images to be saved/replaced without doing server-side image decoding.

Changes:

  • Register an asset-image save event handler and implement a new ImageEditor class that downloads the transformed image and saves/replaces the asset.
  • Add ImageTransformer::fromImageEditor() to map editor state into a Cloud ImageTransform + Cloudflare options.
  • Expand Cloudflare option output to include quality, and add unit tests covering quality + editor mappings.

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/unit/ImageTransformTest.php Adds unit tests for quality mapping and image-editor state → Cloudflare option mapping.
src/Module.php Hooks Craft’s “before save image” event to the new Cloud image editor save handler when Asset CDN is enabled.
src/imagetransforms/ImageTransformer.php Adds fromImageEditor() to build a Cloud image transform from editor crop/rotate/flip/zoom.
src/imagetransforms/ImageTransformBehavior.php Updates Cloudflare docs link and includes quality in generated Cloudflare options.
src/imagetransforms/ImageEditor.php New handler to intercept image-editor saves, compute focal point/transform, and replace or create assets by downloading Cloudflare-transformed output.
composer.json Tightens supported Craft CMS versions to those exposing the image-editor save event.
composer.lock Updates dependency lockfile (including Craft CMS and transitive packages).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/unit/ImageTransformTest.php Outdated
Comment thread src/imagetransforms/ImageEditor.php
Comment thread src/imagetransforms/ImageEditor.php
@timkelty
timkelty force-pushed the timkelty/cloud-image-editor-edge-save branch 3 times, most recently from 3d2a075 to 2b63be8 Compare June 14, 2026 13:29
Copilot AI review requested due to automatic review settings July 23, 2026 16:45
@timkelty
timkelty force-pushed the timkelty/cloud-image-editor-edge-save branch from 2b63be8 to a0c1aa8 Compare July 23, 2026 16:45
@timkelty
timkelty force-pushed the timkelty/cloud-image-editor-edge-save branch from a0c1aa8 to 14a2c65 Compare July 23, 2026 16:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 3 comments.

Comment thread src/imagetransforms/ImageEditor.php Outdated
Comment thread src/imagetransforms/ImageTransformBehavior.php
Comment thread composer.json
Copilot AI review requested due to automatic review settings July 23, 2026 16:51
@timkelty
timkelty marked this pull request as draft July 23, 2026 16:54

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/imagetransforms/ImageTransformBehavior.php:90

  • quality is declared as int|string|null, but transform-array normalization only attempts integer validation when a union contains int (see ImageTransformer::normalizeCloudOption). As a result, string quality values like 'low'/'high' will currently be rejected and silently dropped when passed via transform arrays/options.
    /**
     * @var int<1, 100>|'high'|'medium-high'|'medium-low'|'low'|null
     */
    public int|string|null $quality = null;

src/imagetransforms/ImageEditor.php:119

  • crop() can produce negative left/top or widths/heights that extend outside the edited image. That can cascade into invalid focal point math; it’s safer to constrain the crop rectangle to the rotated image bounds before returning it.
            $focal['x'] = 1 - $focal['x'];
        }

        if (!empty($flipData['y'])) {
            $focal['y'] = 1 - $focal['y'];
        }

        return $focal;
    }

    protected function crop(Asset $asset, int $rotation, array $cropData, array $imageDimensions, float $zoom): array
    {
        $adjustmentRatio = min(
            $asset->width / $imageDimensions['width'],
            $asset->height / $imageDimensions['height'],
        );
        $editedDimensions = $this->rotatedDimensions($asset->width, $asset->height, $rotation);

Comment thread src/imagetransforms/ImageTransformer.php Outdated
Comment thread src/imagetransforms/ImageEditor.php Outdated
Copilot AI review requested due to automatic review settings July 23, 2026 16:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (3)

src/imagetransforms/ImageTransformer.php:53

  • fromImageEditor() reads $imageDimensions[...], $cropData[...], and divides by $zoom / dimension values without validating the expected keys and that values are > 0. Because these come from the image-editor request payload, malformed input can trigger undefined-index notices or DivisionByZeroError (e.g. $zoom = 0, width/height = 0). Add a small guard that validates required keys and positive numbers and throw NotSupportedException so the controller can return a 400.
        $sourceReferenceDimensions = self::rotatedDimensions($imageDimensions['width'], $imageDimensions['height'], 0);
        $cropReferenceDimensions = self::rotatedDimensions($imageDimensions['width'], $imageDimensions['height'], $rotation);
        $cropDimensions = [
            'width' => (int)round($cropData['width']),
            'height' => (int)round($cropData['height']),

src/imagetransforms/ImageEditor.php:95

  • focalPoint() assumes the incoming $focalPoint payload has offsetX/offsetY/imageDimensions[...] and that the derived crop width/height are non-zero. With malformed input, this can raise undefined-index notices or DivisionByZeroError, and the computed focal point can fall outside the expected 0..1 range. Validate required keys/positive dimensions and clamp the final x/y to [0,1] before saving.
        $adjustmentRatio = min(
            $asset->width / $focalPoint['imageDimensions']['width'],
            $asset->height / $focalPoint['imageDimensions']['height'],
        );

src/imagetransforms/ImageEditor.php:117

  • crop() divides by $imageDimensions['width'/'height'] and multiplies by $zoom without validating that dimensions are present and > 0 (and that $zoom is > 0). Since these values originate from the editor request payload, a bad request can currently trigger division-by-zero or warnings. Add a guard and throw NotSupportedException for invalid payloads.
        $adjustmentRatio = min(
            $asset->width / $imageDimensions['width'],
            $asset->height / $imageDimensions['height'],
        );

Comment thread src/imagetransforms/ImageEditor.php
Comment thread src/imagetransforms/ImageTransformer.php Outdated
Copilot AI review requested due to automatic review settings July 23, 2026 18:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (2)

src/imagetransforms/ImageEditor.php:67

  • In editImage(), the computed focal point is stored in $focal, but the call to ImageTransformer::fromImageEditor() passes the original $focalPoint input instead. Even though fromImageEditor() doesn’t currently use this argument, this mismatch is confusing and likely to become a bug if focal point support is added there.
        $focal = $this->focalPoint($asset, $focalPoint, $viewportRotation, $imageRotation, $cropData, $imageDimensions, $flipData, $zoom);
        $transform = ImageTransformer::fromImageEditor($asset, $viewportRotation, $imageRotation, $cropData, $imageDimensions, $flipData, $zoom, $focalPoint);

src/imagetransforms/ImageTransformer.php:39

  • ImageTransformer::fromImageEditor() declares a $focalPoint parameter, but it’s currently unused. This makes the API misleading (and suggests missing functionality). Either remove the parameter + update call sites, or implement focal-point-aware transform behavior.
    public static function fromImageEditor(
        Asset $asset,
        int $viewportRotation,
        float $imageRotation,
        array $cropData,
        array $imageDimensions,
        ?array $flipData,
        float $zoom,
        ?array $focalPoint = null,
    ): ?ImageTransform {

Comment thread src/imagetransforms/ImageTransformer.php Outdated
Copilot AI review requested due to automatic review settings July 23, 2026 18:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

src/imagetransforms/ImageEditor.php:143

  • crop() can calculate a zero/negative width/height (after rounding and/or when offsets push the crop fully out of bounds). That can later cause invalid trim values and will also trigger division-by-zero in focalPoint() (which divides by $crop['width']/$crop['height']). It should validate/constrain the crop and reject invalid results.
    protected function crop(Asset $asset, int $rotation, array $cropData, array $imageDimensions, float $zoom): array
    {
        $adjustmentRatio = min(
            $asset->width / $imageDimensions['width'],
            $asset->height / $imageDimensions['height'],
        );
        $editedDimensions = $this->rotatedDimensions($asset->width, $asset->height, $rotation);

        $width = (int)round($cropData['width'] * $zoom * $adjustmentRatio);
        $height = (int)round($cropData['height'] * $zoom * $adjustmentRatio);

        return [
            'left' => (int)round(($editedDimensions['width'] / 2) + ($cropData['offsetX'] * $zoom * $adjustmentRatio) - ($width / 2)),
            'top' => (int)round(($editedDimensions['height'] / 2) + ($cropData['offsetY'] * $zoom * $adjustmentRatio) - ($height / 2)),
            'width' => $width,
            'height' => $height,
        ];
    }

Comment thread src/imagetransforms/ImageTransformer.php Outdated
Comment thread src/imagetransforms/ImageEditor.php Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/imagetransforms/ImageEditor.php:146

  • crop() can return negative/out-of-bounds rectangles and can also round down to a 0 width/height (e.g. fractional cropData['width']/height), which would lead to incorrect focal-point mapping and potential division-by-zero in focalPoint(). Consider clamping the crop rectangle to the edited image bounds and rejecting non-positive dimensions, similar to the constraining logic used for transforms in ImageTransformer::fromImageEditor().
    protected function crop(Asset $asset, int $rotation, array $cropData, array $imageDimensions, float $zoom): array
    {
        $adjustmentRatio = min(
            $asset->width / $imageDimensions['width'],
            $asset->height / $imageDimensions['height'],
        );
        $editedDimensions = $this->rotatedDimensions($asset->width, $asset->height, $rotation);

        $width = (int)round($cropData['width'] * $zoom * $adjustmentRatio);
        $height = (int)round($cropData['height'] * $zoom * $adjustmentRatio);

        return [
            'left' => (int)round(($editedDimensions['width'] / 2) + ($cropData['offsetX'] * $zoom * $adjustmentRatio) - ($width / 2)),
            'top' => (int)round(($editedDimensions['height'] / 2) + ($cropData['offsetY'] * $zoom * $adjustmentRatio) - ($height / 2)),
            'width' => $width,
            'height' => $height,
        ];
    }

Copilot AI review requested due to automatic review settings July 24, 2026 06:07
@timkelty
timkelty marked this pull request as ready for review July 24, 2026 06:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 3 comments.

Comment thread src/imagetransforms/ImageEditor.php
Comment thread src/imagetransforms/ImageEditor.php
Comment thread src/imagetransforms/ImageEditor.php

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a023294ede

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/imagetransforms/ImageEditor.php
@timkelty
timkelty marked this pull request as draft July 24, 2026 10:15
Copilot AI review requested due to automatic review settings July 24, 2026 10:18
@timkelty
timkelty marked this pull request as ready for review July 24, 2026 10:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 3 comments.

Comment thread src/imagetransforms/ImageEditor.php
Comment thread src/imagetransforms/ImageEditor.php
Comment thread src/imagetransforms/ImageEditor.php Outdated
@timkelty
timkelty marked this pull request as draft July 24, 2026 10:48
Copilot AI review requested due to automatic review settings July 24, 2026 10:50
@timkelty
timkelty marked this pull request as ready for review July 24, 2026 10:52

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b1601a9698

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/imagetransforms/ImageEditor.php Outdated
@timkelty
timkelty marked this pull request as draft July 24, 2026 10:55

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

src/imagetransforms/ImageEditor.php:49

  • The RequestException handler uses the upstream HTTP response body as the client-facing BadRequest message. That can leak internal details (and potentially signed transform URLs) and may return large/HTML responses to the CP UI. Prefer a fixed, safe message here (and optionally log details separately).
        } catch (RequestException $e) {
            $message = trim((string)$e->getResponse()?->getBody()) ?: 'Could not save the edited image.';

            throw new BadRequestHttpException($message, 0, $e);

src/imagetransforms/ImageEditor.php:161

  • This class duplicates the image-editor math/validation already implemented in ImageTransformer (crop/rotation/right-angle checks/rotatedDimensions/validDimensions). Keeping these in sync will be error-prone over time; consider extracting a shared internal helper (or reusing ImageTransformer’s methods where possible) so the transform + focal-point paths can’t drift.
    protected function crop(Asset $asset, int $rotation, array $cropData, array $imageDimensions, float $zoom): array
    {
        $adjustmentRatio = min(
            $asset->width / $imageDimensions['width'],
            $asset->height / $imageDimensions['height'],

Copilot AI review requested due to automatic review settings July 24, 2026 11:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/imagetransforms/ImageEditor.php:49

  • The RequestException handler uses the upstream response body as the BadRequest message. That body can contain sensitive details (including signed transform URLs or internal error context) and would be returned to the client. It’s safer to always return a generic message here and rely on server-side logs for diagnostics.
            throw new BadRequestHttpException($e->getMessage(), 0, $e);
        } catch (TransferException $e) {
            $message = $e instanceof RequestException
                ? trim((string)$e->getResponse()?->getBody())

Copilot AI review requested due to automatic review settings July 24, 2026 11:06
@timkelty
timkelty marked this pull request as ready for review July 24, 2026 11:06

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e42ad3991c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread composer.json
"bref/bref": "^3",
"bref/extra-php-extensions": "^3",
"craftcms/cms": "^4.6 || ^5",
"craftcms/cms": "^4.18.2 || ^5.10.6",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the documented Craft minimum version

With this constraint, the Craft 4.6–4.18.1 projects advertised as supported in README.md:18 can no longer resolve or install this extension. Update the installation/support documentation to state the new 4.18.2 minimum so users do not encounter an unexpected Composer conflict.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (2)

src/imagetransforms/ImageEditor.php:54

  • handleSaveImage() uses the upstream HTTP response body as the exception message. That can leak implementation details (and potentially sensitive info) back to the client and also produce very large/unstructured error messages. It’s safer to return a consistent generic message here (and optionally log details server-side).
        } catch (TransferException $e) {
            $message = $e instanceof RequestException
                ? trim((string)$e->getResponse()?->getBody())
                : '';
            $message = $message ?: 'Could not save the edited image.';

            throw new BadRequestHttpException($message, 0, $e);
        }

src/imagetransforms/ImageEditor.php:395

  • createAsset() unconditionally sets sanitizeOnUpload = false, which can skip SVG sanitization when the source asset is an SVG. Consider enabling sanitization for image/svg+xml to avoid storing potentially unsafe SVG content.
        $newAsset->avoidFilenameConflicts = true;
        $newAsset->setScenario(Asset::SCENARIO_CREATE);
        $newAsset->sanitizeOnUpload = false;
        $newAsset->setFilename($asset->getFilename());

Comment on lines +373 to +378
if ($transform !== null) {
$tempPath = $this->downloadEditedImage($asset, $transform);
$asset->sanitizeOnUpload = false;
Craft::$app->getAssets()->replaceAssetFile($asset, $tempPath, $asset->getFilename());
return;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants