Resolve #3624193 "Fix: Focal point embed crop and focal point setting preserve on delivary mode switch."
Title: Fix focal-point embed crop geometry, cross-mode focal point sync, and the embed dimensions declared for focal-point styles
Summary: Embed-mode image styles produced the wrong crop framing when the requested box met or exceeded the source, and the width/height declared on the embed were the style's nominal box rather than the bitmap Widen actually returns — so the browser stretched the delivered image into a different aspect ratio (900×600 declared, 800×533 delivered). Two further defects surfaced while fixing that: the crop-lookup candidate list omitted the derivative URI with its extension, which is the form saveCropEntity() stores, so every focal-point embed lookup missed and logged Cannot find crop entity; and nothing carried a focal point between the two render modes. This MR fixes the geometry (uniform shrink into Widen's rendition space, no letterbox), makes the declared dimensions match the delivered bitmap, resolves the stored crop rows, adds bidirectional sync, and adds a drush command with targeted and forced runs. The letterbox approach described in the previous revision of this description was replaced in a later commit — the oversized case is scaled to fit rather than padded.
Changes
src/EmbedCodeUrlBuilder.php
Convert the style's crop box and the stored focal point into Widen's web rendition coordinate space (source scaled to at most 2048 px on the long edge), so the delivered crop frames the same area as the local focal_point derivative. A box that still exceeds the rendition is shrunk by a single uniform factor, preserving the style's aspect ratio. The per-axis getDimensionValue() pre-cap before the comparison was removed: it shrank a large box twice (3000×3000 → 1049×1049 instead of 1536×1536) and warped one-axis cases (2500×1000 → 1049×512, aspect 2.049 instead of 2.500). Guard unusable dimensions before any division (missing/zero source or clamped dimensions return an empty set instead of a DivisionByZeroError). getCropSourceUris() is now a shared public static helper with an explicit candidate list: the derivative URI, the derivative URI with its extension restored when the given URI has none, and the bare source with and without .png. Crops are stored against the extension-bearing derivative URI (saveCropEntity() → buildUriForCrop()), while the stream wrapper probes the extension-less form, and CropStorage::getCrop() matches the uri column exactly — the missing candidate was why stored rows were never resolved. findCropEntity() now consumes the same helper. Consolidate duplicated parameter assignment into applyCropParameters() (clears a stale a from an earlier chained effect, deliberately preserves color set by image_rotate). calculateAnchorPointForWiden() returns int rather than a float from an int method. src/EmbedCodeFactory.php
renderAsset() passes the media entity and asset field item into getImageDimensions(). New getEmittedEffectDimensions() runs the style's effects through EmbedCodeUrlBuilder::mapImageEffects() and returns the emitted w/h, which is what Widen delivers. getImageDimensions() prefers it and falls back to the style's nominal box, so the declared attributes describe the bitmap instead of the configuration. original formats and the remotely_referenced_thumbnail_image fallback are unaffected. src/ImageStyleHelper.php
The coordinate conversions are space-explicit: relativeToAbsoluteEmbed() / absoluteToRelativeEmbed() for acquia-dam://styles/... rows (2048 px clamped, matching Widen), and relativeToAbsoluteFile() / absoluteToRelativeFile() for public:// file rows (true pixels, matching focal_point's own math). Adds getDownloadedFile(), getImageDimensions(), setFocalPointOnFile() (accepts pre-resolved dimensions so a caller's getimagesize() fallback reaches the crop writer instead of being discarded), getCropEntityByPossibleUris(). mirrorSyncFocalPointOntoEmbed(MediaInterface $media, bool $force = FALSE): bool mirrors the Download-and-sync focal point onto every Embed style and flushes the changed styles. It returns TRUE when a crop was written or an in-sync style was force-flushed. Core's flush() is a no-op when no derivative exists, so the forced case reports the attempt, not a deletion. flushEmbedStyle() flushes only valid source forms — candidates containing ://styles/ are filtered out, because ImageStyle::flush() prepends the derivative prefix and would build a nested, nonexistent path. No file_exists() probe, which would resolve the wrapper and cost a live Widen request per candidate. applyAbsoluteToCrop() separates persistence from conversion so the conversions stay pure. relativeToAbsolute() and absoluteToRelative() are retained, not removed, and marked @deprecated in acquia_dam:1.1.19 and removed in acquia_dam:1.2.0, delegating to the new methods. src/AssetFileEntityHelper.php
downloadFile() reduced to a single mirror call site, with the mirror running before handleImageDerivatives() so a fresh download's styles are built from the preserved crop rather than the site default; derivative regeneration is gated on a new $is_new_file flag so existing files are not rebuilt. mirrorEmbedFocalPointOntoSync() carries an Embed crop onto the downloaded file only when the file crop is still at the site default and every matching Embed style agrees on the position. A user-set file crop wins; disagreeing styles are skipped with a warning; no Embed crop rows is logged at notice level; source metadata is preferred but falls back to getimagesize() on the downloaded file, since image_properties is often still empty immediately after a download. src/Form/FocalPointMediaEditFormAlter.php, src/Hook/AcquiaDamFormHooks.php
formAlter() now runs in both modes. In Download-and-sync mode it registers a submit handler so a focal point set with the native image widget reaches Embed styles; the custom edit UI remains embed-only. updateCrop() and the new syncFileFocalPointToEmbedStyles() are instance handlers using the injected helper instead of the static service locator, and updateCrop() carries the last changed position onto Download-and-sync via setFocalPointOnFile(). src/Drush/Commands/FocalPointUpdateDrushCommands.php (new)
New acquia-dam:update-media-focal-point (ad:umfp) command for pre-existing content: it reads each image media's file focal point and writes it into the Embed style crops, flushing those derivatives. Options: --limit, --offset, --media-type, --media-id (comma-separated list, every token validated — a non-numeric or zero token throws instead of silently becoming mid=0), --resave-all (force a re-flush of already-matching styles), --batch-size (1–200, default 50). --limit/--offset are ignored with a warning when --media-id is set. An unknown --media-type warns and stops rather than processing every DAM type; media types without a focal-point style are skipped. Empty/stale asset dimensions are refreshed once per asset (deduplicated so media sharing an asset do not each trigger a live Widen metadata request) and counted separately when they cannot be resolved. Result table: Synced focal point (or “Synced or force-flushed” under --resave-all), Skipped, no focal point, Skipped, already in sync, Skipped, dimensions unresolved, Errors. Cache tags of changed media are invalidated in a single call; the command exits non-zero when any item fails. acquia_dam.services.yml, drush.services.yml
Injects acquia_dam.image_style_support into acquia_dam.asset_file_helper. Registers the drush command with entity_type.manager, config.factory, media_type_resolver, image_style_support and cache_tags.invalidator. Why the conversions were split
Embed crops are computed against Widen's 2048 px long-edge cap; focal_point renders file crops against the image's true pixel dimensions. A single converter with an implicit clamp silently wrote the wrong position for any source over 2048 px (20% of a 4000×3000 stored as 410,307 instead of 800,600). The method names now state which space they operate in, so the mistake is visible at the call site.
Testing
New fixture tests/fixtures/56ff14de-…-c917eab19abf.large.json (4000×3000) so the conversions are exercised above the 2048 px cap, where the two spaces actually diverge. ImageStyleHelperTest: testRelativeToAbsoluteFile() / testAbsoluteToRelativeFile() assert the file pair is unclamped and diverges from the embed pair, so aliasing the two spaces fails the test. FocalPointIntegrationTest: the oversized-box case now expects the scaled result (crop=yes&w=1536&h=1536) rather than a letterbox; plus a small-source scale-to-fit provider row, the image_rotate + focal-point-crop colour regression, downloadFile() mirror wiring, and three gate tests (file crop at default, file crop user-set, disagreeing Embed styles). New tests/src/Unit/EmbedCodeUrlBuilderTest::testGetCropSourceUris() with three provider rows: an extension-less derivative resolves the stored extension form, a derivative that already carries an extension still resolves it, and a non-derivative URI is passed through with its .png variant — this is the regression test for the crop-lookup miss. FocalPointMediaLibraryTest and AssetFieldTypeWithCanvasTest updated; the reflection in FocalPointIntegrationTest keeps pace with the mirrorEmbedFocalPointOntoSync / mirrorSyncFocalPointOntoEmbed renames.