Make WordPress Core

Opened 3 days ago

Last modified 3 days ago

#65188 new defect (bug)

Uploading PNG images with a larger number of colors causes timeouts and eventually CPU starvation

Reported by: romainmrhenry's profile romainmrhenry Owned by:
Milestone: Awaiting Review Priority: normal
Severity: normal Version: 6.8
Component: Upload Keywords:
Focuses: performance Cc:

Description

Since WordPress 6.8 there is new handling of PNG images.

Specifically in thumbnail_image at wp-includes/class-wp-image-editor-imagick.php:403

Uploading a png image with a large number of colors, for example a photographic image in .png format, causes the getImageColors() step at line 506 to be extremely slow.

This seems to be the result of the chosen filter FILTER_TRIANGLE.

A standalone version of this function with some extra log lines:

<?php

$image = new \Imagick( realpath( './foo.png' ) );

thumbnail_image( $image, 2048, 2048 );

function thumbnail_image( $image, $dst_w, $dst_h, $filter_name = 'FILTER_TRIANGLE' ) {
        error_log( 'a' );
        $current_colors = $image->getImageColors();
        error_log( 'b' );
        error_log( $current_colors );

        $allowed_filters = array(
                'FILTER_POINT',
                'FILTER_BOX',
                'FILTER_TRIANGLE',
                'FILTER_HERMITE',
                'FILTER_HANNING',
                'FILTER_HAMMING',
                'FILTER_BLACKMAN',
                'FILTER_GAUSSIAN',
                'FILTER_QUADRATIC',
                'FILTER_CUBIC',
                'FILTER_CATROM',
                'FILTER_MITCHELL',
                'FILTER_LANCZOS',
                'FILTER_BESSEL',
                'FILTER_SINC',
        );

        /**
         * Set the filter value if '$filter_name' name is in the allowed list and the related
         * Imagick constant is defined or fall back to the default filter.
         */
        if ( in_array( $filter_name, $allowed_filters, true ) && defined( 'Imagick::' . $filter_name ) ) {
                $filter = constant( 'Imagick::' . $filter_name );
        } else {
                $filter = defined( 'Imagick::FILTER_TRIANGLE' ) ? Imagick::FILTER_TRIANGLE : false;
        }

        try {
                $size = null;
                try {
                        error_log( '1' );
                        $size = $image->getImageGeometry();
                        error_log( '2' );
                } catch ( Exception $e ) {
                        return new WP_Error( 'invalid_image', __( 'Could not read image size.' ) );
                }

                /*
                        * To be more efficient, resample large images to 5x the destination size before resizing
                        * whenever the output size is less that 1/3 of the original image size (1/3^2 ~= .111),
                        * unless we would be resampling to a scale smaller than 128x128.
                        */
                if ( is_callable( array( $image, 'sampleImage' ) ) ) {
                        $resize_ratio  = ( $dst_w / $size['width'] ) * ( $dst_h / $size['height'] );
                        $sample_factor = 5;

                        if ( $resize_ratio < .111 && ( $dst_w * $sample_factor > 128 && $dst_h * $sample_factor > 128 ) ) {
                                error_log( '3' );
                                $image->sampleImage( $dst_w * $sample_factor, $dst_h * $sample_factor );
                                error_log( '4' );
                        }
                }

                /*
                        * Use resizeImage() when it's available and a valid filter value is set.
                        * Otherwise, fall back to the scaleImage() method for resizing, which
                        * results in better image quality over resizeImage() with default filter
                        * settings and retains backward compatibility with pre 4.5 functionality.
                        */
                if ( is_callable( array( $image, 'resizeImage' ) ) && $filter ) {
                        error_log( '5' );
                        $image->setOption( 'filter:support', '2.0' );
                        $image->resizeImage( $dst_w, $dst_h, $filter, 1 );
                        error_log( '6' );
                } else {
                        error_log( '7' );
                        $image->scaleImage( $dst_w, $dst_h );
                        error_log( '8' );
                }

                error_log( '9' );
                $image->setOption( 'png:compression-filter', '5' );
                $image->setOption( 'png:compression-level', '9' );
                $image->setOption( 'png:compression-strategy', '1' );
                error_log( '10' );
                // Check to see if a PNG is indexed, and find the pixel depth.
                if ( is_callable( array( $image, 'getImageDepth' ) ) ) {
                        error_log( '11' );
                        $indexed_pixel_depth = $image->getImageDepth();
                        error_log( $indexed_pixel_depth );
                        error_log( '12' );

                        // Indexed PNG files get some additional handling.
                        if ( 0 < $indexed_pixel_depth && 8 >= $indexed_pixel_depth ) {
                                // Check for an alpha channel.
                                error_log( '13' );
                                if (
                                        is_callable( array( $image, 'getImageAlphaChannel' ) )
                                        && $image->getImageAlphaChannel()
                                ) {
                                        $image->setOption( 'png:include-chunk', 'tRNS' );
                                } else {
                                        $image->setOption( 'png:exclude-chunk', 'all' );
                                }
                                error_log( '14' );

                                // Reduce colors in the images to maximum needed, using the global colorspace.
                                $max_colors = pow( 2, $indexed_pixel_depth );
                                error_log( $max_colors );
                                if ( is_callable( array( $image, 'getImageColors' ) ) ) {
                                        $current_colors = $image->getImageColors();
                                        error_log( $current_colors );
                                        $max_colors = min( $max_colors, $current_colors );
                                }
                                error_log( $max_colors );

                                error_log( '15' );
                                $image->quantizeImage( $max_colors, $image->getColorspace(), 0, false, false );
                                error_log( '16' );

                                error_log( 'c' );
                                $current_colors = $image->getImageColors();
                                error_log( 'd' );
                                error_log( $current_colors );

                                /**
                                 * If the colorspace is 'gray', use the png8 format to ensure it stays indexed.
                                 */
                                if ( Imagick::COLORSPACE_GRAY === $image->getImageColorspace() ) {
                                        $image->setOption( 'png:format', 'png8' );
                                }
                        }
                }

                /*
                        * If alpha channel is not defined, set it opaque.
                        *
                        * Note that Imagick::getImageAlphaChannel() is only available if Imagick
                        * has been compiled against ImageMagick version 6.4.0 or newer.
                        */
                if ( is_callable( array( $image, 'getImageAlphaChannel' ) )
                        && is_callable( array( $image, 'setImageAlphaChannel' ) )
                        && defined( 'Imagick::ALPHACHANNEL_UNDEFINED' )
                        && defined( 'Imagick::ALPHACHANNEL_OPAQUE' )
                ) {
                        if ( $image->getImageAlphaChannel() === Imagick::ALPHACHANNEL_UNDEFINED ) {
                                $image->setImageAlphaChannel( Imagick::ALPHACHANNEL_OPAQUE );
                        }
                }

                // Limit the bit depth of resized images.
                if ( is_callable( array( $image, 'getImageDepth' ) ) && is_callable( array( $image, 'setImageDepth' ) ) ) {
                        /**
                         * Filters the maximum bit depth of resized images.
                         *
                         * This filter only applies when resizing using the Imagick editor since GD
                         * does not support getting or setting bit depth.
                         *
                         * Use this to adjust the maximum bit depth of resized images.
                         *
                         * @since 6.8.0
                         *
                         * @param int $max_depth   The maximum bit depth. Default is the input depth.
                         * @param int $image_depth The bit depth of the original image.
                         */
                        $max_depth = $image->getImageDepth();
                        $image->setImageDepth( $max_depth );
                }
        } catch ( Exception $e ) {
                return new WP_Error( 'image_resize_error', $e->getMessage() );
        }
}

It also seems that there is some confusion around channel depth and colors in this function.
The $max_colors is a value per channel while $current_colors is for all channels combined.

I think $max_colors should be multiplied by 3.


It is extremely easy to DOS a WordPress server that allows file uploads by feeding it photographic PNG images.

Change History (7)

#1 @blackstar1991
3 days ago

Could you please provide a sample image to demonstrate the issue? You can test this on a blank WordPress site without any other plugins. *If you're using an uncompressed image larger than 1 Mb, this is expected behavior.

*thumbnail_image( $image, 2048, 2048 ); -- It looks strange to me.
Last edited 3 days ago by blackstar1991 (previous) (diff)

#2 @romainmrhenry
3 days ago

2048x2048 is one of the sizes used by WordPress.

If you're using an uncompressed image larger than 1 Mb, this is expected behavior.

What do you mean?
Do you mean that it is expected that any upload larger than 1 Mb gives a timeout?
That seems wild to me.


Sample image:

https://storage.googleapis.com/shared-files-mrhenry/romain/witty-hammerhead-shark-30e776b1bf.png

#3 @blackstar1991
3 days ago

It looks like your pages are using very large image files. Unless your website is specifically a high-definition photo gallery or an art exhibition, images of this scale are generally unnecessary and can significantly slow down your site.

Since most screens won’t even display those extra pixels, your users don't get the benefit of the large file size, but they do suffer the slower loading times. If you run a Google PageSpeed Insights test, you’ll likely see recommendations to "serve images in next-gen formats" or "properly size images."

My Recommendation:
I suggest compressing your images before uploading them to the site. A great free tool for this is [Squoosh.app | https://squoosh.app/], which allows you to reduce file size without losing noticeable quality.

Note: This isn't a technical bug within the system; it’s a matter of optimizing your assets to ensure the best possible user experience.

#4 @romainmrhenry
3 days ago

I am sorry but your response is completely detached from the report.

2048x2048 is one of the default sizes generated by WordPress.
It is independent of themes or plugins.

The linked source image is 1600x2000 which is smaller than this default size generated by WordPress.
If such sizes are so problematic (they really aren't) then why is this a default in WordPress?

This definitely is a technical bug.
Please don't blame the user and actually investigate the issue.

We also know many ways to circumvent the issue that doesn't take away that there is a problem in WordPress core.
The existence of a workaround doesn't magically makes bugs go away.

#5 in reply to: ↑ description @siliconforks
3 days ago

Replying to romainmrhenry:

Uploading a png image with a large number of colors, for example a photographic image in .png format, causes the getImageColors() step at line 506 to be extremely slow.

You're using version 6.8? This is a known issue in 6.8. See https://core.trac.wordpress.org/ticket/63481#comment:5

Can you update to version 6.9?

#6 follow-up: @romainmrhenry
3 days ago

Thank you @siliconforks, we indeed intend to update to 6.9 in the coming months.

Could this be backported to 6.8?

Happy to already know that eventually this issue will be resolved for us when we update.

#7 in reply to: ↑ 6 @siliconforks
3 days ago

Replying to romainmrhenry:

Could this be backported to 6.8?

I doubt that would ever be backported - older versions of WordPress (like 6.8) are not really supported any more, they only receive updates for critical bug fixes.

If you really want that fixed in 6.8, you could probably just replace the file class-wp-image-editor-imagick.php with the new version from 6.9:

https://raw.githubusercontent.com/WordPress/wordpress-develop/refs/tags/6.9.4/src/wp-includes/class-wp-image-editor-imagick.php

Note: See TracTickets for help on using tickets.