Skip to content

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

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

[3.x] Add edge image editor saves#168
timkelty wants to merge 12 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 lite 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 review requested due to automatic review settings July 24, 2026 05:58
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

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 thread src/imagetransforms/ImageEditor.php
@timkelty
timkelty marked this pull request as draft August 18, 2026 17:07
@timkelty
timkelty force-pushed the timkelty/cloud-image-editor-edge-save branch from e42ad39 to 2b65a24 Compare August 18, 2026 17:09
Copilot AI review requested due to automatic review settings August 18, 2026 17:09
@timkelty
timkelty marked this pull request as ready for review August 18, 2026 17:13

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 7 out of 8 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/imagetransforms/ImageEditor.php:210

  • focalPoint() returns null when the event doesn’t include focal point data (if (!$focalPoint) { return null; }). Downstream, replaceAsset()/createAsset() unconditionally call setFocalPoint($focal), so an edit that only changes crop/rotation/flip could unintentionally clear an existing focal point (and trigger transform invalidation) if Craft supplies null here to mean “unchanged”. Consider distinguishing “no focal point payload” from “clear focal point” so edits don’t drop the existing focal point by default.
    protected function focalPoint(Asset $asset, ?array $focalPoint, int $viewportRotation, float $imageRotation, array $cropData, array $imageDimensions, ?array $flipData, float $zoom): ?array
    {
        if (!$focalPoint) {
            return null;
        }

src/imagetransforms/ImageEditor.php:398

  • When creating a new asset (replace=false), createAsset() doesn’t set uploaderId. Elsewhere in this codebase (e.g. src/controllers/AssetsController.php) newly created assets set uploaderId to the current user for attribution/auditing. Consider setting it here as well so “Save as new” edits are attributed consistently.
        $newAsset = new Asset();
        $newAsset->avoidFilenameConflicts = true;
        $newAsset->setScenario(Asset::SCENARIO_CREATE);
        $newAsset->sanitizeOnUpload = $this->sanitizeEditedFile($asset);
        $newAsset->setFilename($asset->getFilename());
        $newAsset->newFolderId = $asset->folderId;
        $newAsset->setVolumeId($asset->volumeId);
        $newAsset->setFocalPoint($focal);

Copilot AI review requested due to automatic review settings August 18, 2026 17:14

@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: 282ffb9326

ℹ️ 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".


$newAsset->tempFilePath = $tempPath ?? $asset->getCopyOfFile();

Craft::$app->getElements()->saveElement($newAsset);

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 Propagate element save failures

When a validation rule or before-save handler rejects the new asset, saveElement() returns false, but this path still returns $newAsset; because the event was already marked handled, the image editor can report success with a null or invalid newAssetId even though no copy was saved. Check the return value and surface the asset errors; the focal-point-only replacement call at line 380 has the same silent-failure behavior.

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 7 out of 8 changed files in this pull request and generated no new comments.

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