Skip to content

Media: Track the original attachment for edited images. - #13303

Draft
ramonjd wants to merge 5 commits into
WordPress:trunkfrom
ramonjd:add/attachment-original-lineage
Draft

Media: Track the original attachment for edited images.#13303
ramonjd wants to merge 5 commits into
WordPress:trunkfrom
ramonjd:add/attachment-original-lineage

Conversation

@ramonjd

@ramonjd ramonjd commented Aug 28, 2026

Copy link
Copy Markdown
Member

What? Why?

When Gutenberg crops an image, Core's /edit creates an entirely new attachment with no stable pointer back to the image the lineage started from.

parent_image records only the immediate source; there is no root/original reference across crop-of-crop chains.

That means we can't navigate a crop back to its original in a performant way, that is, without getting each post up the change where parent_image exists.

This PR adds original_attachment metadata under media_details to track the lineage.

Trac ticket: https://core.trac.wordpress.org/ticket/65987

Use of AI Tools

To create the backport of WordPress/gutenberg#81803 and its tests

@ramonjd ramonjd self-assigned this Aug 28, 2026
Comment thread src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php Outdated
@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Editing an image via the `wp/v2/media/<id>/edit` REST endpoint saves the
result as a new attachment and leaves the edited image untouched, so a
site can build up a chain: an upload, a crop of it, a crop of that crop.
`parent_image` records only the immediately preceding image, so finding
the image a chain started from meant walking it one attachment at a time.

Each attachment created by an edit now records the ID at the top of its
chain in `_wp_attachment_original_id` postmeta, inheriting it from the
image being edited. `wp_get_original_attachment_id()` reads it back in a
single lookup, and returns the ID it was given for attachments that were
uploaded rather than edited.

The attachments REST controller exposes the result as an
`original_attachment` field in the `edit` context only, giving editors
what they need to offer a way back to the original without telling
visitors which images were made from which.

Deleting an attachment clears the record from any image edited from it,
so nothing is left pointing at an ID that could later be reused.

Records are written going forward only; images edited before this lands
are not backfilled.

Fixes #65987.
The field points at another attachment, and every other pointer to
another entity in a media response is top level: `author`, `post`,
`featured_media`. `media_details` holds the width, height, file, size
and derived sizes of one image, and no references to anything else.

Registering it properly also means it can be requested on its own with
`_fields`, which was not possible while it was nested inside another
object.

Follow-up to the original commit on this branch.

See #65987.
@ramonjd
ramonjd force-pushed the add/attachment-original-lineage branch from f271a85 to b6dff27 Compare August 31, 2026 02:36
Comment thread src/wp-includes/post.php
* is indexed, so this only scans the rows for attachments created by editing an image,
* and it avoids searching the serialized attachment metadata for the ID.
*/
delete_metadata( 'post', 0, '_wp_attachment_original_id', $post_id, true );

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

true is for $delete_all

We want this because, when the original is deleted we want to clear all descendants.

See: https://developer.wordpress.org/reference/functions/delete_metadata/

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

A note on deletion paths:

  • wp_delete_post() delegates to wp_delete_attachment() for attachments, so every Core route should reach delete_attachment and clear the record.
  • From what I've traced it has the same "reachability" as Core's _thumbnail_id cleanup, which uses the identical delete_metadata()
  • doesn't fire on trash, which I think is the current pattern (the attachment still exists )

Plugins could still short circuit deletion and do it themselves via any filter, e.g., pre_delete_attachment. That means the clean up might not happen. This isn't Core's to fix, but we should mention this in the dev note for 7.2.

Adds tests for three cases the existing coverage left undefined.

Trashing an original does not clear the record on images edited from it:
`delete_attachment` only fires on permanent deletion, and keeping the
record means untrashing restores the relationship intact.

Editing an image whose original has been deleted starts a new chain from
the image being edited, since there is no lineage left to inherit.

Deleting an image from the middle of a chain leaves the images below it
pointing at the start of the chain, because each one records where the
chain started rather than the image directly above it.

See #65987.
The field carried an `attachment_id` and `source_url` pair. Relationships
in this API are bare IDs — `post`, `parent`, `featured_media` — so it is
now just the ID of the original attachment.

Clients that need the original's URL or dimensions get them from a
`wp:original-attachment` link, which is embeddable in the same way as a
featured image: `?_embed` hydrates the whole attachment record under
`_embedded`.

The link is added where the request is still in scope rather than in
`prepare_links()`, which cannot see it, so the link stays in the `edit`
context alongside the field.

See #65987.
The field was left out entirely for an image that was not created by
editing another one. `featured_media` reports `0` for "no featured
image" rather than disappearing, so this now does the same, and clients
get a field of one type that is always there in the `edit` context.

The stored ID is no longer checked against the original's file before
being sent. Deleting an attachment already clears the ID from everything
edited from it, so the check only affected originals sitting in the
trash, whose files still resolve. A client following an ID that has gone
stale gets no record back, which it must handle in any case.

See #65987.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant