Skip to content

fix(main-editor): keep layers on the image point when cropping and re… - #874

Merged
hm21 merged 4 commits into
hm21:stablefrom
saif-ellafi:fix/crop-reopen-scale
Sep 30, 2026
Merged

hm21 merged 4 commits into
hm21:stablefrom
saif-ellafi:fix/crop-reopen-scale

Conversation

@saif-ellafi

Copy link
Copy Markdown
Contributor

Hello,

This is my second attempt to fix crop on images with history in them, editable layers. AI-Assisted, I am still testing this but so far so good.

Description

Layers on a cropped image did not stay on the image point they were placed on:

  1. Applying a crop moved existing layers when the image filled the editor by its width (e.g. portrait screens).
    LayerTransformGenerator derives layer offsets from crop editor units (offset, scaleUser, scaleRotation,
    fitToScreenFactor), which don't match how the main editor paints the cropped background.
  2. Reopening a cropped history on a body of another shape made layers drift. On import, layers were rescaled by the
    uncropped image fit (decodedImageSize / lastRenderedImgSize), which doesn't describe where the crop window is painted.

Fix

A new ImageLayerFrame maps a layer offset (distance from the body center) to a point on the image and back. It follows
the transforms TransformedContentGenerator paints the background with: the FittedBox contain scale, fitFactor,
scaleUser, crop pan, rotation and flips. Its fit helper is now a public static,
TransformedContentGenerator.fitFactor, so painting and mapping share one implementation.

  • Applying a crop: each layer is mapped to the image under the old transform and back under the new one.
    LayerTransformGenerator still provides layer rotation and flips; only offset and scale are replaced.
  • Importing a cropped history: layers are mapped from the exported body to the current one. The export now records
    editorBodySize (minified key e). Histories exported before this estimate the body from lastRenderedImgSize and
    cropEditorScreenRatio (the crop editor's area aspect on the same screen).
  • Unchanged: uncropped histories, live window resizing, undo/redo, and the decode path. Tilted transforms keep the
    previous behaviour, since their perspective isn't linear.

Tests

test/features/main_editor/crop_reopen_across_devices_test.dart measures where each layer is painted, in raw image
pixels, through the background's render box (globalToLocal, so rotation is accounted for). It covers:

  • layers placed before and after the crop, checked right after the crop in the same editor
  • all 12 pairs of four surfaces (desktop, phone, tablet, ultrawide), plus a round trip over four surfaces
  • undo after a crop
  • rotated crops
  • exports without editorBodySize

On stable, 11 of the original 15 scenarios fail. With this change all 23 pass, and the full suite passes.

Test note: CropRotateEditorState.done() never completes when awaited directly in the fake-async zone while layers
exist, so the test pumps until it finishes and has a 45 s timeout.

Supersedes #871. It keeps #871's editorBodySize export field and key, but also fixes the shift when a crop is applied,
and handles histories exported before the field existed.

Related Issue: Closes #

Type of Change

  • ✨ New feature (non-breaking change which adds functionality)
  • 🛠️ Bug fix (non-breaking change which fixes an issue)
  • ❌ Breaking change (fix or feature that would cause existing functionality to change)
  • 🧹 Code refactor
  • ✅ Build configuration change
  • 📝 Documentation
  • 🗑️ Chore

saif-ellafi and others added 4 commits September 29, 2026 09:22
…opening crops

Layer offsets on a cropped image were derived from crop editor units and
rescaled on import by the uncropped image fit. Layers jumped when a crop was
applied while the image filled the editor by its width, and drifted when a
cropped history was reopened in a body of another shape.

ImageLayerFrame maps a layer offset to a point on the image and back, using
the transforms the background is painted with. Applying a crop places each
layer on the image point it covered before, and importing a cropped history
maps layers from the exported body to the current one. The export records
the editor body size; older exports estimate it from cropEditorScreenRatio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mage points

The crop editor undid the active crop for its layer preview with
LayerTransformGenerator, which no longer matches how the main editor places
layers on a cropped image. Map the preview layers with ImageLayerFrame onto
the uncropped image box, and again once that box is laid out, since
_setRawLayers first runs before layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mage points

- Histories exported before `editorBodySize` only estimate the body when
  `lastRenderedImgSize` is fitted to it. A blank canvas or a raw video size
  is not, and moved its layers even on the same device. The current body is
  used when the image fits it the same way, so reopening on the exporting
  device no longer drifts for crops that fit the body by height.
- Version 1.0.0 offsets keep the plain rescale.
- The crop editor places the layers it closes with on the same image points
  as the main editor, so the hero flight no longer starts with a jump.
- Applying a crop also rescales the slide distance of layer animations.
- The frame pair and layer move live in `ImageLayerFrame`, and an import
  builds the frames once per history step.
# Conflicts:
#	CHANGELOG.md
#	pubspec.yaml

@hm21 hm21 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks a lot for this PR, @saif-ellafi. Mapping the layers through the same transforms the background is painted with is the right fix, and measuring painted positions in raw image pixels made it easy to verify across surfaces.

Here is what changed on top of your commits:

  • Old exports on a blank canvas stay put. Histories exported before editorBodySize estimated the body from lastRenderedImgSize, but a blank canvas (and a raw-size video) records its unscaled size there, so layers moved by ~60 px even on the same device. Those exports keep the previous rescale now.
  • Old exports reopened on the same device no longer drift. When the image fits the current body the same way, that body is used instead of the cropEditorScreenRatio estimate, which describes the crop editor's area rather than the main body (a 1:2 crop on a phone moved layers by ~5 px).
  • The crop editor closes without a jump. done() still placed the fake-hero layers with LayerTransformGenerator and the old transform, so the hero flight started ~37 px off on portrait screens.
  • Applying a crop also rescales slideFrom of layer animations, as the import already did. Version 1.0.0 imports keep the plain rescale, since their offsets aren't measured from the body center.
  • The frame pair and the layer move live in ImageLayerFrame now, shared by the main editor, the crop editor and the import (which builds the frames once per history step).
  • Added tests for flipped crops, cropping a cropped image, blank canvases, a same-device reopen of an old export and the closing crop editor.
  • Merged stable, bumped the release to 14.6.1 and shortened the CHANGELOG entries.

Thanks again!

@saif-ellafi

Copy link
Copy Markdown
Contributor Author

Thanks to you for building on top and improving further! What the editor can do is unbelievable so far!

@hm21
hm21 merged commit f4dee91 into hm21:stable Sep 30, 2026
3 checks passed
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