WebP), ignoring set_quality(). * This filter makes the quality Spinix chose stick. * * @param int $q Quality. */ private static function force_quality( $q ) { static $hooked = false; self::$force_q = (int) $q; if ( ! $hooked ) { add_filter( 'wp_editor_set_quality', array( __CLASS__, 'filter_quality' ), PHP_INT_MAX, 2 ); $hooked = true; } } /** * Filter callback. * * @param int $quality Quality. * @param string $mime_type Mime type. * @return int */ public static function filter_quality( $quality, $mime_type = '' ) { return ( self::$force_q > 0 && 'image/webp' === $mime_type ) ? self::$force_q : $quality; } /** * Mime types we process. * * @return string[] */ public static function mimes() { return (array) apply_filters( 'spinix_io_mime_types', array( 'image/jpeg', 'image/png', 'image/webp' ) ); } /** * Optimize one attachment. * * @param int $id Attachment ID. * @param bool $force Re-optimize even if already done. * @param array|null $meta Fresh metadata (during upload it is not saved yet). * @return array Result with keys status, message, before, after. */ public static function optimize( $id, $force = false, $meta = null ) { $id = (int) $id; $start = microtime( true ); if ( ! $force && 'optimized' === get_post_meta( $id, self::META_STATUS, true ) ) { return self::result( 'optimized', __( 'Already optimized', 'spinix-image-optimization' ), $id ); } $mime = get_post_mime_type( $id ); if ( ! in_array( $mime, self::mimes(), true ) ) { return self::finish( $id, 'skipped', __( 'File type not supported (GIF/SVG are left as they are).', 'spinix-image-optimization' ) ); } /** * Fires before an attachment is optimized (for example to bring back an original * that an add-on moved to cloud storage). * * @param int $id Attachment ID. */ do_action( 'spinix_io_before_optimize', $id ); if ( ! Spinix_Image_Optimization_Environment::webp_supported() ) { return self::finish( $id, 'failed', __( 'This server cannot create WebP images.', 'spinix-image-optimization' ) ); } $file = get_attached_file( $id ); if ( ! $file || ! file_exists( $file ) ) { return self::finish( $id, 'failed', __( 'Original file is missing.', 'spinix-image-optimization' ) ); } $rel_full = Spinix_Image_Optimization_Storage::relative( $file ); if ( false === $rel_full ) { return self::finish( $id, 'skipped', __( 'File is outside the uploads folder.', 'spinix-image-optimization' ) ); } if ( ! Spinix_Image_Optimization_Storage::ensure_dir() ) { return self::finish( $id, 'failed', __( 'Cannot write to the Spinix Image Optimization storage folder.', 'spinix-image-optimization' ) ); } if ( function_exists( 'wp_raise_memory_limit' ) ) { wp_raise_memory_limit( 'image' ); } // Remove previous output when re-optimizing. if ( $force || get_post_meta( $id, self::META_DATA, true ) ) { self::delete_files( $id, false ); } if ( ! is_array( $meta ) ) { $meta = wp_get_attachment_metadata( $id ); } $meta = is_array( $meta ) ? $meta : array(); $quality = Spinix_Image_Optimization_Settings::quality(); $dir_rel = dirname( $rel_full ); $dir_rel = ( '.' === $dir_rel ) ? '' : $dir_rel . '/'; $data = array( 'v' => 1, 'source' => $rel_full, 'quality' => $quality, 'files' => array(), 'time' => time(), ); $before = 0; $after = 0; $errors = array(); // 1. Full size + every registered size. $targets = array( 'full' => $rel_full ); if ( ! empty( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) { foreach ( $meta['sizes'] as $size_name => $size ) { if ( empty( $size['file'] ) ) { continue; } if ( ! empty( $size['mime-type'] ) && ! in_array( $size['mime-type'], self::mimes(), true ) ) { continue; } $targets[ $size_name ] = $dir_rel . $size['file']; } } $targets = array_unique( $targets ); // Largest first, so the quality found for big files is reused for the smaller ones. $widths = array( 'full' => isset( $meta['width'] ) ? (int) $meta['width'] : PHP_INT_MAX ); if ( ! empty( $meta['sizes'] ) ) { foreach ( $meta['sizes'] as $size_name => $size ) { $widths[ $size_name ] = isset( $size['width'] ) ? (int) $size['width'] : 0; } } uksort( $targets, static function ( $a, $b ) use ( $widths ) { $wa = isset( $widths[ $a ] ) ? $widths[ $a ] : 0; $wb = isset( $widths[ $b ] ) ? $widths[ $b ] : 0; return $wb <=> $wa; } ); self::$carry_q = 0; foreach ( $targets as $size_name => $rel ) { $src = Spinix_Image_Optimization_Storage::uploads_dir() . '/' . $rel; if ( ! file_exists( $src ) ) { continue; } $orig_bytes = (int) filesize( $src ); $webp_rel = Spinix_Image_Optimization_Storage::webp_rel( $rel ); $saved = self::encode( $src, Spinix_Image_Optimization_Storage::abs( $webp_rel ), $quality ); if ( is_wp_error( $saved ) ) { $errors[] = $saved->get_error_message(); continue; } $new_bytes = isset( $saved['bytes'] ) ? (int) $saved['bytes'] : (int) filesize( $saved['path'] ); $entry = array( 'size' => $size_name, 'webp' => $webp_rel, 'before' => $orig_bytes, 'after' => $new_bytes, 'w' => (int) $saved['width'], 'h' => (int) $saved['height'], ); // Serve the WebP only when it is really smaller. Otherwise the original is // already the smallest file, so visitors keep getting the original. if ( $new_bytes >= $orig_bytes ) { Spinix_Image_Optimization_Filesystem::delete( $saved['path'] ); $entry['webp'] = ''; $entry['after'] = $orig_bytes; $new_bytes = $orig_bytes; } $data['files'][ $rel ] = $entry; $before += $orig_bytes; $after += $new_bytes; } if ( ! isset( $data['files'][ $rel_full ] ) && $errors ) { // The main image itself could not be converted: report it, even if small sizes worked. self::delete_files_list( $data ); /* translators: %s: error from the image library */ return self::finish( $id, 'failed', sprintf( __( 'The original image could not be read or converted: %s', 'spinix-image-optimization' ), implode( ' ', array_unique( $errors ) ) ) ); } if ( empty( $data['files'] ) ) { $msg = $errors ? implode( ' ', array_unique( $errors ) ) : __( 'Nothing could be converted.', 'spinix-image-optimization' ); return self::finish( $id, 'failed', $msg ); } $data['ms'] = (int) round( ( microtime( true ) - $start ) * 1000 ); update_post_meta( $id, self::META_DATA, $data ); update_post_meta( $id, self::META_BEFORE, $before ); update_post_meta( $id, self::META_AFTER, $after ); delete_post_meta( $id, self::META_ERROR ); update_post_meta( $id, self::META_STATUS, 'optimized' ); /** * Fires after an attachment was optimized. * * @param int $id Attachment ID. * @param array $data Spinix data. */ do_action( 'spinix_io_optimized', $id, $data ); $pct = $before > 0 ? round( ( 1 - $after / $before ) * 100, 1 ) : 0; $webps = count( array_filter( wp_list_pluck( $data['files'], 'webp' ) ) ); $total = count( $data['files'] ); if ( 0 === $webps ) { $message = __( 'Already optimal · original kept (it is already smaller than WebP)', 'spinix-image-optimization' ); } elseif ( $webps < $total ) { /* translators: 1: percent saved, 2: sizes served as WebP, 3: all sizes */ $message = sprintf( __( 'Saved %1$s%% · %2$d of %3$d sizes as WebP', 'spinix-image-optimization' ), $pct, $webps, $total ); } else { /* translators: 1: percent saved, 2: number of WebP files */ $message = sprintf( _n( 'Saved %1$s%% · %2$d WebP file', 'Saved %1$s%% · %2$d WebP files', $webps, 'spinix-image-optimization' ), $pct, $webps ); } return self::result( 'optimized', $message, $id, $before, $after ); } /** * Quality to start from: the preset, or what the previous (larger) file settled on. * * @param int $quality Preset quality. * @return int */ private static function start_quality( $quality ) { $floor = Spinix_Image_Optimization_Settings::quality_floor(); if ( $floor > 0 && self::$carry_q > 0 ) { return max( $floor, min( (int) $quality, self::$carry_q ) ); } return (int) $quality; } /** * Target bytes per pixel for smart compression. * * @return float */ private static function target() { return (float) apply_filters( 'spinix_io_target_bytes_per_pixel', 0.085 ); } /** * Encode an open handle (fast path) and remember the quality for the next file. * * @param array $h Handle. * @param string $dest Destination. * @param int $quality Preset quality. * @return array|WP_Error */ private static function save_handle( $h, $dest, $quality ) { $floor = Spinix_Image_Optimization_Settings::quality_floor(); $saved = Spinix_Image_Optimization_Encoder::save( $h, $dest, self::start_quality( $quality ), $floor, self::target() ); if ( ! is_wp_error( $saved ) && $floor > 0 ) { self::$carry_q = (int) $saved['quality']; } return $saved; } /** * Convert one file to WebP: fast encoder first, WordPress' image editor as fallback. * * @param string $src Source. * @param string $dest Destination. * @param int $quality Preset quality. * @return array|WP_Error */ private static function encode( $src, $dest, $quality ) { $h = Spinix_Image_Optimization_Encoder::open( $src ); if ( ! is_wp_error( $h ) ) { $saved = self::save_handle( $h, $dest, $quality ); Spinix_Image_Optimization_Encoder::close( $h ); if ( ! is_wp_error( $saved ) ) { return $saved; } } return self::convert( $src, $dest, $quality ); } /** * Convert a file to WebP. * * @param string $src Source path. * @param string $dest Destination path. * @param int $quality Quality. * @return array|WP_Error Saved info. */ public static function convert( $src, $dest, $quality ) { $editor = wp_get_image_editor( $src ); if ( is_wp_error( $editor ) ) { return $editor; } $floor = Spinix_Image_Optimization_Settings::quality_floor(); $quality = self::start_quality( $quality ); self::force_quality( (int) $quality ); $editor->set_quality( $quality ); wp_mkdir_p( dirname( $dest ) ); $saved = $editor->save( $dest, 'image/webp' ); if ( is_wp_error( $saved ) ) { return $saved; } if ( empty( $saved['path'] ) || ! file_exists( $saved['path'] ) ) { return new WP_Error( 'spinix_io_save', __( 'WebP file could not be written.', 'spinix-image-optimization' ) ); } // Smart compression: PageSpeed flags WebP files above ~1/6 byte per pixel // ("Increase the compression factor"). Step the quality down until the file is // comfortably below that, but never under the preset's floor. if ( $floor > 0 ) { $target = self::target(); $pixels = max( 1, (int) $saved['width'] * (int) $saved['height'] ); $q = (int) $quality; $tries = 0; clearstatcache( true, $saved['path'] ); while ( $tries < 5 && filesize( $saved['path'] ) / $pixels > $target && ( filesize( $saved['path'] ) - $pixels * $target ) > 2048 && $q > $floor ) { $q = max( $floor, $q - 7 ); self::force_quality( $q ); $editor->set_quality( $q ); $again = $editor->save( $dest, 'image/webp' ); if ( is_wp_error( $again ) || empty( $again['path'] ) ) { break; } $saved = $again; clearstatcache( true, $saved['path'] ); ++$tries; } $saved['quality'] = $q; self::$carry_q = $q; } self::$force_q = 0; return $saved; } /** * Delete Spinix files for an attachment (originals untouched). * * @param int $id Attachment ID. * @param bool $clear_meta Also remove status/meta. */ public static function delete_files( $id, $clear_meta = true ) { $data = get_post_meta( $id, self::META_DATA, true ); if ( is_array( $data ) ) { $paths = array(); if ( ! empty( $data['files'] ) ) { foreach ( $data['files'] as $f ) { if ( ! empty( $f['webp'] ) ) { $paths[] = $f['webp']; } } } foreach ( $paths as $rel ) { if ( false !== strpos( $rel, '..' ) ) { continue; } $abs = Spinix_Image_Optimization_Storage::abs( $rel ); Spinix_Image_Optimization_Filesystem::delete( $abs ); } } if ( $clear_meta ) { foreach ( array( self::META_STATUS, self::META_DATA, self::META_BEFORE, self::META_AFTER, self::META_ERROR ) as $key ) { delete_post_meta( $id, $key ); } } } /** * Delete files listed in a (not yet saved) data array. * * @param array $data Data. */ private static function delete_files_list( $data ) { foreach ( (array) $data['files'] as $f ) { if ( ! empty( $f['webp'] ) && false === strpos( $f['webp'], '..' ) ) { Spinix_Image_Optimization_Filesystem::delete( Spinix_Image_Optimization_Storage::abs( $f['webp'] ) ); } } } /** * Save a final status for non-optimized outcomes. * * @param int $id ID. * @param string $status Status. * @param string $message Message. * @return array */ private static function finish( $id, $status, $message ) { update_post_meta( $id, self::META_STATUS, $status ); update_post_meta( $id, self::META_ERROR, $message ); return self::result( $status, $message, $id ); } /** * Result array. * * @param string $status Status. * @param string $message Message. * @param int $id ID. * @param int $before Before bytes. * @param int $after After bytes. * @return array */ private static function result( $status, $message, $id, $before = 0, $after = 0 ) { return array( 'id' => $id, 'status' => $status, 'message' => $message, 'before' => $before, 'after' => $after, 'title' => get_the_title( $id ), 'thumb' => wp_get_attachment_image_url( $id, 'thumbnail' ), ); } /** * Summary for one attachment (used by UI). * * @param int $id ID. * @return array */ public static function summary( $id ) { $status = get_post_meta( $id, self::META_STATUS, true ); $before = (int) get_post_meta( $id, self::META_BEFORE, true ); $after = (int) get_post_meta( $id, self::META_AFTER, true ); $data = get_post_meta( $id, self::META_DATA, true ); return array( 'status' => $status ? $status : 'pending', 'before' => $before, 'after' => $after, 'percent' => $before > 0 ? round( ( 1 - $after / $before ) * 100, 1 ) : 0, 'webp_count' => is_array( $data ) && ! empty( $data['files'] ) ? count( array_filter( wp_list_pluck( $data['files'], 'webp' ) ) ) : 0, 'file_count' => is_array( $data ) && ! empty( $data['files'] ) ? count( $data['files'] ) : 0, 'error' => (string) get_post_meta( $id, self::META_ERROR, true ), 'ms' => is_array( $data ) && isset( $data['ms'] ) ? (int) $data['ms'] : 0, ); } }