Skip to content
Closed
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
236d585
REST API: Expose size-aware encode quality on attachment responses.
adamsilverstein May 18, 2026
1c60f7b
Apply suggestions from code review
adamsilverstein May 21, 2026
823596a
Media: Add wp_get_image_encode_quality() helper.
adamsilverstein May 21, 2026
8a1ae1a
REST API: Honor jpeg_quality and bound the image_quality schema.
adamsilverstein May 21, 2026
61b33fd
Tests: Reference Trac ticket #65262 for image_quality coverage.
adamsilverstein May 21, 2026
824ee07
Update src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-c…
adamsilverstein May 28, 2026
393e27c
Add types and address PHPStan issues with wp_get_image_encode_quality()
westonruter May 28, 2026
b09105c
REST API: Reset out-of-range encode quality to the per-format default.
adamsilverstein Jun 27, 2026
2436b94
Merge remote-tracking branch 'origin/trunk' into add/rest-image-quali…
adamsilverstein Jul 1, 2026
8a2aca7
Fix param type for $default_quality
westonruter Jul 1, 2026
ae86911
Add return type info for wp_get_registered_image_subsizes()
westonruter Jul 1, 2026
7e0501e
Ensure non-negative-int values are used in dimensions passed to wp_ge…
westonruter Jul 1, 2026
f20e7df
Fix assign.byRefForeachExpr
westonruter Jul 1, 2026
5bc92b3
Cast mime_type to string since guaranteed to be so
westonruter Jul 1, 2026
48706d1
Merge remote-tracking branch 'origin/trunk' into add/rest-image-quali…
adamsilverstein Jul 2, 2026
a847c9c
Merge branch 'trunk' into add/rest-image-quality-field
adamsilverstein Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions src/wp-includes/media.php
Original file line number Diff line number Diff line change
Expand Up @@ -906,8 +906,13 @@ function get_intermediate_image_sizes() {
* @uses wp_get_additional_image_sizes()
* @uses get_intermediate_image_sizes()
*
* @return array[] Associative array of arrays of image sub-size information,
* keyed by image size name.
* @return array<string, array> Associative array of arrays of image sub-size information,
* keyed by image size name.
* @phpstan-return array<non-empty-string, array{
* width: non-negative-int,
* height: non-negative-int,
* crop: bool|array{ 'left'|'center'|'right', 'top'|'center'|'bottom' },
* }>
*/
function wp_get_registered_image_subsizes() {
$additional_sizes = wp_get_additional_image_sizes();
Expand Down Expand Up @@ -955,6 +960,69 @@ function wp_get_registered_image_subsizes() {
return $all_sizes;
}

/**
* Determines the encode quality WordPress would use for an image.
*
* Resolves the quality the same way WP_Image_Editor::set_quality() does when no
* explicit quality is supplied: it starts from the per-format default, applies the
* 'wp_editor_set_quality' filter, then the 'jpeg_quality' filter for JPEG output,
* resets out-of-range values to the per-format default, and squashes 0 to 1.
*
* This lets code outside of an image editor instance - such as the REST API, which
* reports the quality client-side processing should use - resolve the same value the
* server would apply, without loading the image into an editor.
*
* @since 7.1.0
*
* @param string $mime_type The output image MIME type, e.g. 'image/jpeg'.
* @param array $size {
* Optional. Dimensions of the image, passed to the 'wp_editor_set_quality' filter.
*
* @type int $width The image width in pixels.
* @type int $height The image height in pixels.
* }
* @param int|null $default_quality Optional. Starting quality before filters are applied.
* Defaults to the per-format default (86 for WebP, 82 otherwise).
* @return int Encode quality between 1 and 100.
*
* @phpstan-param non-empty-string $mime_type
* @phpstan-param array{ width?: non-negative-int, height?: non-negative-int } $size
* @phpstan-param int<0, 100>|null $default_quality
* @phpstan-return int<1, 100>
*/
function wp_get_image_encode_quality( string $mime_type, array $size = array(), ?int $default_quality = null ): int {
if ( null === $default_quality ) {
// Mirror WP_Image_Editor::get_default_quality(): WebP defaults to 86, everything else to 82.
$default_quality = ( 'image/webp' === $mime_type ) ? 86 : 82;
}

/** This filter is documented in wp-includes/class-wp-image-editor.php */
$quality = apply_filters( 'wp_editor_set_quality', $default_quality, $mime_type, $size );

if ( 'image/jpeg' === $mime_type ) {
/** This filter is documented in wp-includes/class-wp-image-editor.php */
$quality = apply_filters( 'jpeg_quality', $quality, 'image_resize' );
}

if ( ! is_numeric( $quality ) ) {
$quality = $default_quality;
} else {
$quality = (int) $quality;
}

// Reset out-of-range values to the default, matching WP_Image_Editor::set_quality().
if ( $quality < 0 || $quality > 100 ) {
$quality = $default_quality;
}

// Allow 0, but squash to 1, matching WP_Image_Editor::set_quality().
if ( 0 === $quality ) {
$quality = 1;
}

return $quality;
}

/**
* Retrieves an image to represent an attachment.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1215,6 +1215,7 @@ public function prepare_item_for_response( $item, $request ) {

$size_data['source_url'] = $image_src[0];
}
unset( $size_data );
Comment thread
adamsilverstein marked this conversation as resolved.

$full_src = wp_get_attachment_image_src( $post->ID, 'full' );

Expand Down Expand Up @@ -1299,7 +1300,7 @@ public function prepare_item_for_response( $item, $request ) {
}

if ( wp_attachment_is_image( $post ) ) {
$mime_type = get_post_mime_type( $post );
$mime_type = (string) get_post_mime_type( $post );
Comment thread
adamsilverstein marked this conversation as resolved.

/*
* Per-file output format for images, evaluated with the real filename
Expand Down Expand Up @@ -1330,6 +1331,53 @@ public function prepare_item_for_response( $item, $request ) {
/** This filter is documented in wp-includes/class-wp-image-editor-gd.php */
$data['image_save_progressive'] = (bool) apply_filters( 'image_save_progressive', false, $mime_type );
}

if ( in_array( 'image_quality', $fields, true ) ) {
$filename = get_attached_file( $post->ID );

/** This filter is documented in wp-includes/media.php */
$output_formats = apply_filters(
'image_editor_output_format',
array( $mime_type => $mime_type ),
$filename ? $filename : '',
$mime_type
);
$output_mime = $output_formats[ $mime_type ] ?? $mime_type;

$metadata = wp_get_attachment_metadata( $post->ID, true );
$full_width = max( 0, ( is_array( $metadata ) && isset( $metadata['width'] ) ) ? (int) $metadata['width'] : 0 );
$full_height = max( 0, ( is_array( $metadata ) && isset( $metadata['height'] ) ) ? (int) $metadata['height'] : 0 );

$full_quality = wp_get_image_encode_quality(
$output_mime,
array(
'width' => $full_width,
'height' => $full_height,
)
);

$size_quality = array();

foreach ( wp_get_registered_image_subsizes() as $size_name => $size_data ) {
$quality = wp_get_image_encode_quality(
$output_mime,
array(
'width' => (int) $size_data['width'],
'height' => (int) $size_data['height'],
)
);

// Only report sizes whose quality diverges from the full-size value.
if ( $quality !== $full_quality ) {
$size_quality[ $size_name ] = $quality;
}
}

$data['image_quality'] = array(
'default' => $full_quality,
'sizes' => $size_quality,
);
}
}

$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
Expand Down Expand Up @@ -1522,6 +1570,35 @@ public function get_item_schema() {
'readonly' => true,
);

// Enumerate the registered sub-sizes so the schema documents exactly which
// keys may appear under "sizes".
$size_quality_properties = array();
foreach ( array_keys( wp_get_registered_image_subsizes() ) as $size_name ) {
$size_quality_properties[ $size_name ] = array(
'type' => 'integer',
'minimum' => 1,
'maximum' => 100,
);
}

$schema['properties']['image_quality'] = array(
'description' => __( 'Encode quality (1-100) from the wp_editor_set_quality filter, resolved against the output MIME type. The "default" value applies to the full-size image; "sizes" lists per-registered-size overrides where the filtered value differs from "default".' ),
'type' => 'object',
'context' => array( 'edit' ),
'readonly' => true,
'properties' => array(
'default' => array(
'type' => 'integer',
'minimum' => 1,
'maximum' => 100,
),
'sizes' => array(
'type' => 'object',
'properties' => $size_quality_properties,
),
),
);

$schema['properties']['image_output_format'] = array(
'description' => __( 'The output MIME type this image should be converted to, based on the image_editor_output_format filter. Null if no conversion is needed.' ),
'type' => array( 'string', 'null' ),
Expand Down
75 changes: 75 additions & 0 deletions tests/phpunit/tests/media.php
Original file line number Diff line number Diff line change
Expand Up @@ -7283,6 +7283,81 @@ private function get_insufficient_width_height_for_high_priority(): array {
'height' => 100,
);
}

/**
* @ticket 65262
* @covers ::wp_get_image_encode_quality
*/
public function test_wp_get_image_encode_quality_defaults() {
// JPEG (and any non-WebP) defaults to 82, WebP to 86.
$this->assertSame( 82, wp_get_image_encode_quality( 'image/jpeg' ) );
$this->assertSame( 82, wp_get_image_encode_quality( 'image/png' ) );
$this->assertSame( 86, wp_get_image_encode_quality( 'image/webp' ) );
}

/**
* @ticket 65262
* @covers ::wp_get_image_encode_quality
*/
public function test_wp_get_image_encode_quality_applies_wp_editor_set_quality() {
$filter = static function ( $quality, $mime_type, $size ) {
return ( ! empty( $size['width'] ) && $size['width'] <= 300 ) ? 55 : $quality;
};
add_filter( 'wp_editor_set_quality', $filter, 10, 3 );

$small = wp_get_image_encode_quality( 'image/webp', array( 'width' => 150 ) );
$large = wp_get_image_encode_quality( 'image/webp', array( 'width' => 1200 ) );

remove_filter( 'wp_editor_set_quality', $filter, 10 );

$this->assertSame( 55, $small );
$this->assertSame( 86, $large );
}

/**
* The legacy jpeg_quality filter must apply for JPEG output only, matching
* WP_Image_Editor::set_quality().
*
* @ticket 65262
* @covers ::wp_get_image_encode_quality
*/
public function test_wp_get_image_encode_quality_applies_jpeg_quality() {
$filter = static function () {
return 70;
};
add_filter( 'jpeg_quality', $filter );

$jpeg = wp_get_image_encode_quality( 'image/jpeg' );
$webp = wp_get_image_encode_quality( 'image/webp' );

remove_filter( 'jpeg_quality', $filter );

$this->assertSame( 70, $jpeg );
// Non-JPEG output ignores jpeg_quality.
$this->assertSame( 86, $webp );
}

/**
* Out-of-range filtered values fall back to the default; 0 squashes to 1.
*
* @ticket 65262
* @covers ::wp_get_image_encode_quality
*/
public function test_wp_get_image_encode_quality_clamps_out_of_range() {
$too_high = static function () {
return 150;
};
add_filter( 'wp_editor_set_quality', $too_high );
$this->assertSame( 82, wp_get_image_encode_quality( 'image/jpeg' ) );
remove_filter( 'wp_editor_set_quality', $too_high );

$zero = static function () {
return 0;
};
add_filter( 'wp_editor_set_quality', $zero );
$this->assertSame( 1, wp_get_image_encode_quality( 'image/png' ) );
remove_filter( 'wp_editor_set_quality', $zero );
}
}

/**
Expand Down
Loading