Skip to content

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

Open
ramonjd wants to merge 9 commits into
WordPress:trunkfrom
ramonjd:add/attachment-original-lineage
Open

ramonjd wants to merge 9 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 records the attachment an edit chain started from in _wp_attachment_edit_root_id postmeta, reads it back with wp_get_edit_root_attachment_id(), and exposes it as a top-level edit_root field on the attachment REST response: the ID of the edit root, or 0 when the image was not created by editing another one. The field is sent in the edit context only, alongside an embeddable wp:edit-root link so clients can hydrate the edit root with _embed.

The link is only added when the edit root is published or the user can read it, the same check as the featured_media link. The edit_root field still reports the recorded ID.

Deleting an attachment clears the record from every image edited from it. Records are written going forward only, so images edited before this lands are not backfilled.

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.

@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 Outdated
* 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.

Comment thread src/wp-includes/post.php Outdated
* @return int ID of the attachment the chain started from, or `$attachment_id` when the
* attachment was not created by editing another one.
*/
function wp_get_original_attachment_id( $attachment_id ) {

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.

wp_get_original_image_url() and wp_get_original_image_path() return the unscaled upload of the same attachment.

wp_get_original_attachment_id() returns the first image in an edit chain of different attachments.

On an edited image those near-identical names answer different questions. What about

  • wp_get_root_image_id()
  • wp_get_source_attachment_id()

Also does this need a filter?

);

$schema['properties']['original_attachment'] = array(
'description' => __( 'The ID of the attachment this image was created from by editing, or 0 if it was not created by editing another image.' ),

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.

Images edited through /edit since 5.5 have parent_image in their metadata but no new meta, so they report 0. So "not created by editing another image" is false for them.

@ramonjd
ramonjd force-pushed the add/attachment-original-lineage branch from 2c60221 to 65af124 Compare September 14, 2026 06:59
@ramonjd
ramonjd requested a review from andrewserong October 2, 2026 02:26
@ramonjd
ramonjd marked this pull request as ready for review October 2, 2026 02:26
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props ramonopoly, andrewserong.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

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.
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.
The field comment said `original_attachment` is limited to the `edit`
context so visitors cannot tell which images were made from which.
`media_details` already exposes `parent_image` in the `view` and `embed`
contexts, so that was not the reason. It is limited to `edit` because
only editors need it.

The schema description and the `@return` of
`wp_get_original_attachment_id()` said a missing original meant the
image was not created by editing another one. Images edited before this
change have no record either, so both now say none is recorded.

See #65987.
`wp_get_original_attachment_id()` sat next to `wp_get_original_image_path()`
and `wp_get_original_image_url()`, which describe the unscaled upload of
the same attachment rather than the attachment a chain of edits started
from. The names now say which one they mean:

- `wp_get_original_attachment_id()` -> `wp_get_edit_root_attachment_id()`
- `_wp_delete_original_attachment_id()` -> `_wp_delete_edit_root_attachment_id()`
- `_wp_attachment_original_id` postmeta -> `_wp_attachment_edit_root_id`
- `original_attachment` REST field -> `edit_root`
- `wp:original-attachment` link relation -> `wp:edit-root`

No change in behaviour.

See #65987.
A response limited with `_fields` carries no `_links` member unless the
request asks for `_links` or `_embedded`, because the posts controller
builds no links at all in that case. The edit root link was added outside
that check and keyed off the response field, so `_fields=id,edit_root`
came back with a `_links` member holding this one link alone, and
`_fields=id,_links` came back without it while every other link was there.

The link is now gated on the same check the parent controller uses for its
own links, and reads the edit root itself rather than the prepared field,
so asking for links no longer depends on asking for the field.

Follows WordPress/gutenberg#81803. The plugin also
treats a bare `_embed` parameter as a request for links. Core does not:
`get_fields_for_response()` drops `_embedded` from a `_fields` list that
omits it, and core's own links stay out of that response too.

See #65987.
@ramonjd
ramonjd force-pushed the add/attachment-original-lineage branch from fdbe530 to ea3620e Compare October 2, 2026 03:37
Comment on lines +1704 to +1710
if ( $edit_root_id !== (int) $post->ID ) {
$response->add_link(
'https://api.w.org/edit-root',
rest_url( rest_get_route_for_post( $edit_root_id ) ),
array( 'embeddable' => true )
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is possibly very edge casey, but just an idea to potentially harden this check with a current_user_can( 'read_post' check to match how the featured media link is constructed? Here's how featured media handles it:

if ( $featured_media && ( 'publish' === get_post_status( $featured_media ) || current_user_can( 'read_post', $featured_media ) ) ) {
$image_url = rest_url( rest_get_route_for_post( $featured_media ) );
$links['https://api.w.org/featuredmedia'] = array(
'href' => $image_url,
'embeddable' => true,
);
}

In practice, I think to reach this state you'd have to have edit_root pointing to an id that no longer exists (i.e. somehow your environment has a stale value) and then as a result the rest_get_route_for_post() call returns the REST root rather than the url of the attachment. This is fairly contrived, but I could reproduce it locally by running npm run wp-env -- run cli wp post meta update 1958 _wp_attachment_edit_root_id 999999 to set an attachment id of 1958 to a bogus 999999 edit root id.

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.

I just nabbed 20 mins before appointments 😄

Thanks for catching this. Yeah 100%

Catch stale values and a cheap read check so we don't show what a caller can't read anyway. I'll update here and GB as well.

@andrewserong andrewserong left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is testing nicely for me! As discussed on the related Gutenberg PR I think this settles on a good naming structure for this feature, and I think post meta is the right place to store it as it'll enable useful features further down the track, like a UI for navigating sibling crops, etc.

One other question I had, and I don't think it's a blocker for this PR but would be good to consider, is what should happen for importers / WXR exports.

For example, I see over in the importers repo there was a PR that skips some post meta here:

I have no experience with the importers code, but one idea after this PR lands could be to update the importers to skip _wp_attachment_edit_root_id just as it skips _wp_attachment_metadata. The result would be that the lineage would be severed on WXR exports, so it would be lossy, but maybe not terrible? In any case, I think the export/import behaviour here is likely beyond the scope of this PR.

If we did want to deal with it in some way now, one idea could be to skip the post meta in the export:

/**
* Determines whether to selectively skip post meta used for WXR exports.
*
* @since 3.3.0
*
* @param bool $return_me Whether to skip the current post meta. Default false.
* @param string $meta_key Meta key.
* @return bool
*/
function wxr_filter_postmeta( $return_me, $meta_key ) {
if ( '_edit_lock' === $meta_key ) {
$return_me = true;
}
return $return_me;
}
add_filter( 'wxr_export_skip_postmeta', 'wxr_filter_postmeta', 10, 2 );

However, since the importer repo already handles other attachment post meta, my hunch is that the fix/handling will be better done over in the wordpress-importer repo sometime before the 7.2 release.

What do you think? No other blockers here IMO!

The `wp:edit-root` link promises that `_embed` can fetch the edit root, but
the recorded ID is not checked, so an edit root that no longer exists, or
one the user cannot read, was still offered as a link.

The link now uses the same check as the `featured_media` link: it is added
only when the edit root is published or the user can read it. The
`edit_root` field still reports the recorded ID as is.

Follows WordPress/gutenberg#81803.

See #65987.
@ramonjd

ramonjd commented Oct 2, 2026

Copy link
Copy Markdown
Member Author

One other question I had, and I don't think it's a blocker for this PR but would be good to consider, is what should happen for importers / WXR exports.

Thanks for flagging this! I had a bit of a dig around and found something else that looks interesting:

_thumbnail_id is collected during post meta import and rewritten to the new ids afterwards in remap_featured_images().

So maybe a follow up to the simpler, skipping could be to remap in a similar way. I think your idea of skipping is probably the best, first version: it'll avoid bad relationships after import, e.g., if an attachment with the same id already exists

@ramonjd

ramonjd commented Oct 2, 2026

Copy link
Copy Markdown
Member Author

I think your idea of skipping is probably the best, first version: it'll avoid bad relationships after import, e.g., if an attachment with the same id already exists

Draft PR here when/if we need it:

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.

2 participants