s' ? Optml_Media_Offload::instance()->rollback_images( $batch ) : Optml_Media_Offload::instance()->upload_images( $batch ); } $step = $step + 1; self::schedule_action( time(), 'optml_start_processing_images', [ $action, $batch, $page, $total, $step, ] ); } catch ( Exception $e ) { // Reschedule the cron to run again after a delay. Sometimes memory limit is exausted. $delay_in_seconds = 10; self::$instance->logger->add_log( $type, $e->getMessage() ); self::schedule_action( time() + $delay_in_seconds, 'optml_start_processing_images', [ $action, $batch, $page, $total, $step, ] ); } } /** * Alter attachment image src for offloaded images. * * @param array|false $image { * Array of image data. * * @type string $0 Image source URL. * @type int $1 Image width in pixels. * @type int $2 Image height in pixels. * @type bool $3 Whether the image is a resized image. * } * * @param int $attachment_id attachment id. * @param string|int[] $size image size. * @param bool $icon Whether the image should be treated as an icon. * * @return array $image. */ public function alter_attachment_image_src( $image, $attachment_id, $size, $icon ) { if ( ! $this->is_new_offloaded_attachment( $attachment_id ) ) { return $image; } if ( isset( $image[0] ) ) { $url = $image[0]; } else { $url = get_post( $attachment_id ); $url = $url->guid; } $metadata = wp_get_attachment_metadata( $attachment_id ); // Use the original size if the requested size is full. if ( $size === 'full' || $this->is_attachment_edit_page( $attachment_id ) ) { $image_url = $this->get_new_offloaded_attachment_url( $url, $attachment_id, [ 'width' => $metadata['width'], 'height' => $metadata['height'], 'attachment_id' => $attachment_id, ], $metadata ); return [ $image_url, $metadata['width'], $metadata['height'], false, ]; } if ( doing_action( 'wp_insert_post_data' ) && wp_attachment_is( 'video', $attachment_id ) ) { return $image; } $sizes = $this->size_to_dimension( $size, $metadata ); $image_url = $this->get_new_offloaded_attachment_url( $url, $attachment_id, [ 'width' => $sizes['width'], 'height' => $sizes['height'], 'resize' => $sizes['resize'] ?? [], 'attachment_id' => $attachment_id, ], $metadata ); return [ $image_url, $sizes['width'], $sizes['height'], $size === 'full', ]; } /** * Needed for image sizes inside the editor. * * @param array $response Array of prepared attachment data. @see wp_prepare_attachment_for_js(). * @param WP_Post $attachment Attachment object. * @param array|false $meta Array of attachment meta data, or false if there is none. * * @return array */ public function alter_attachment_for_js( $response, $attachment, $meta ) { if ( ! $this->is_new_offloaded_attachment( $attachment->ID ) ) { return $response; } $meta = []; if ( isset( $response['width'] ) ) { $meta['width'] = $response['width']; } if ( isset( $response['height'] ) ) { $meta['height'] = $response['height']; } $sizes = Optml_App_Replacer::image_sizes(); foreach ( $sizes as $size => $args ) { if ( isset( $response['sizes'][ $size ] ) ) { continue; } $args = $this->size_to_dimension( $size, $meta ); $response['sizes'][ $size ] = array_merge( $args, [ 'url' => $this->get_new_offloaded_attachment_url( $response['url'], $attachment->ID, $args ), 'orientation' => ( $args['height'] > $args['width'] ) ? 'portrait' : 'landscape', ] ); } $response['url'] = $this->get_new_offloaded_attachment_url( $response['url'], $attachment->ID, $meta ); return $response; } /** * Alter attachment metadata. * * @param array $metadata The attachment metadata. * @param int $id The attachment ID. * * @return array */ public function alter_attachment_metadata( $metadata, $id ) { if ( ! $this->is_new_offloaded_attachment( $id ) ) { return $metadata; } return $this->get_altered_metadata_for_remote_images( $metadata, $id ); } /** * Get offloaded image attachment URL for new offloads. * * @param string $url The initial attachment URL. * @param int $attachment_id The attachment ID. * @param array $args The additional arguments. * - width: The width of the image. * - height: The height of the image. * - crop: Whether to crop the image. * @param array|null $attachment_metadata The attachment metadata. * @return string */ private function get_new_offloaded_attachment_url( $url, $attachment_id, $args = [], $attachment_metadata = null ) { $process_flag = self::KEYS['not_processed_flag'] . $attachment_id; // Image might have already passed through this filter. if ( strpos( $url, $process_flag ) !== false ) { return $url; } if ( $attachment_metadata === null ) { $meta = wp_get_attachment_metadata( $attachment_id ); } else { $meta = $attachment_metadata; } if ( ! isset( $meta['file'] ) ) { return $url; } $default_args = [ 'width' => 'auto', 'height' => 'auto', 'resize' => apply_filters( 'optml_default_crop', [] ), ]; $args = wp_parse_args( $args, $default_args ); // If this is not cropped, we constrain the dimensions to the original image. if ( empty( $args['resize'] ) && ! in_array( 'auto', [ $args['width'], $args['height'] ], true ) ) { $dimensions = wp_constrain_dimensions( $meta['width'], $meta['height'], $args['width'], $args['height'] ); $args['width'] = $dimensions[0]; $args['height'] = $dimensions[1]; } $file = $meta['file']; if ( self::is_uploaded_image( $file ) ) { $optimized_url = $this->get_optimized_image_url( $this->get_offloaded_attachment_url( $attachment_id, $url ), $args['width'], $args['height'], $args['resize'] ); return strpos( $optimized_url, $process_flag ) === false ? str_replace( '/' . ltrim( $file, '/' ), '/' . $process_flag . $file, $optimized_url ) : $optimized_url; } else { // this is for the users that already offloaded the images before the other fixes $local_file = get_attached_file( $attachment_id ); if ( ! file_exists( $local_file ) ) { $duplicated_images = apply_filters( 'optml_offload_duplicated_images', [], $attachment_id ); if ( is_array( $duplicated_images ) && ! empty( $duplicated_images ) ) { foreach ( $duplicated_images as $id ) { if ( ! empty( $id ) ) { $duplicated_meta = wp_get_attachment_metadata( $id ); if ( isset( $duplicated_meta['file'] ) && self::is_uploaded_image( $duplicated_meta['file'] ) ) { return $this->get_optimized_image_url( $this->get_offloaded_attachment_url( $attachment_id, $url ), $args['width'], $args['height'], $args['resize'] ); } } } } } } return $url; } /** * Replace the URLs in the editor content with the offloaded ones. * * @param string $content The incoming content. * * @return string */ public function replace_urls_in_editor_content( $content ) { $raw_extracted = Optml_Main::instance()->manager->extract_urls_from_content( $content ); if ( empty( $raw_extracted ) ) { return $content; } $to_replace = []; foreach ( $raw_extracted as $url ) { $attachment = $this->get_local_attachement_id_from_url( $url ); // No local attachment. if ( $attachment['attachment_id'] === 0 ) { if ( $this->can_replace_url( $url ) ) { $to_replace[ $url ] = $this->get_optimized_image_url( $url, 'auto', 'auto' ); } continue; } $attachment_id = $attachment['attachment_id']; // Not offloaded. if ( ! $this->is_new_offloaded_attachment( $attachment_id ) ) { continue; } // Get W/H from url. $size = $this->parse_dimensions_from_filename( $url ); $width = $size[0] !== false ? $size[0] : 'auto'; $height = $size[1] !== false ? $size[1] : 'auto'; // Handle resize. $sizes2crop = self::size_to_crop(); $resize = apply_filters( 'optml_default_crop', [] ); $sizes = image_get_intermediate_size( $attachment_id, $size ); if ( false !== $sizes ) { if ( isset( $sizes2crop[ $width . $height ] ) ) { $resize = $this->to_optml_crop( $sizes2crop[ $width . $height ] ); } } // Build the optimized URL. $optimized_url = $this->get_optimized_image_url( self::KEYS['not_processed_flag'] . $attachment_id . '/' . ltrim( $this->get_offloaded_attachment_url( $attachment_id, $url ), '/' ), $width, $height, $resize ); // Drop any image size from the URL. $optimized_url = str_replace( '-' . $width . 'x' . $height, '', $optimized_url ); $to_replace[ $url ] = $optimized_url; } return str_replace( array_keys( $to_replace ), array_values( $to_replace ), $content ); } /** * Replaces the post content URLs to use Offloaded ones on editor fetch. * * @param \WP_REST_Response $response The response object. * @param \WP_Post $post The post object. * @param \WP_REST_Request $request The request object. * * @return \WP_REST_Response */ public function pre_filter_rest_content( \WP_REST_Response $response, \WP_Post $post, \WP_REST_Request $request ) { $context = $request->get_param( 'context' ); if ( $context !== 'edit' ) { return $response; } $data = $response->get_data(); // Actually replace all URLs. $data['content']['raw'] = $this->replace_urls_in_editor_content( $data['content']['raw'] ); $response->set_data( $data ); return $response; } /** * Legacy function to be used for WordPress versions under 6.0.0. * * @param array $post_data Slashed, sanitized, processed post data. * @param array $postarr Slashed sanitized post data. * @param array $unsanitized_postarr Un-sanitized post data. * * @return array */ public function legacy_filter_saved_data( $post_data, $postarr, $unsanitized_postarr ) { return $this->filter_saved_data( $post_data, $postarr, $unsanitized_postarr, true ); } /** * Filter post content to use local attachments when saving offloaded images. * * @param array $post_data Slashed, sanitized, processed post data. * @param array $postarr Slashed sanitized post data. * @param array $unsanitized_postarr Un-sanitized post data. * @param bool $update Whether this is an existing post being updated or not. * * @return array */ public function filter_saved_data( $post_data, $postarr, $unsanitized_postarr, $update ) { if ( $postarr['post_status'] === 'trash' ) { return $post_data; } $content = $post_data['post_content']; $extracted = Optml_Main::instance()->manager->extract_urls_from_content( $content ); $replace = []; foreach ( $extracted as $idx => $url ) { $id = self::get_attachment_id_from_url( $url ); if ( $id === false ) { continue; } $id = (int) $id; if ( $this->is_legacy_offloaded_attachment( $id ) ) { continue; } $original = self::get_original_url( $id ); if ( $original === false ) { continue; } $replace[ $url ] = $original; $size = $this->parse_dimension_from_optimized_url( $url ); if ( $size[0] === false || $size[1] === false ) { continue; } if ( $size[0] === 'auto' || $size[1] === 'auto' ) { continue; } $extension = $this->get_ext( $url ); $metadata = wp_get_attachment_metadata( $id ); // Is this the full URL. if ( $metadata['width'] === (int) $size[0] && $metadata['height'] === (int) $size[1] ) { continue; } $size_crop_map = self::size_to_crop(); $crop = false; if ( isset( $size_crop_map[ $size[0] . $size[1] ] ) ) { $crop = $size_crop_map[ $size[0] . $size[1] ]; } if ( $crop ) { $width = $size[0]; $height = $size[1]; } else { // In case of an image size, we need to calculate the new dimensions for the proper file path. $constrained = wp_constrain_dimensions( $metadata['width'], $metadata['height'], $size[0], $size[1] ); $width = $constrained[0]; $height = $constrained[1]; } $replace[ $url ] = $this->maybe_strip_scaled( $replace[ $url ] ); $suffix = sprintf( '-%sx%s.%s', $width, $height, $extension ); $replace[ $url ] = str_replace( '.' . $extension, $suffix, $replace[ $url ] ); } $post_data['post_content'] = str_replace( array_keys( $replace ), array_values( $replace ), $content ); return $post_data; } /** * Alter the image size for the image widget. * * @param string $html the attachment image HTML string. * @param array $settings Control settings. * @param string $image_size_key Optional. Settings key for image size. * Default is `image`. * @param string $image_key Optional. Settings key for image. Default * is null. If not defined uses image size key * as the image key. * * @return string */ public function alter_elementor_image_size( $html, $settings, $image_size_key, $image_key ) { if ( ! isset( $settings['image'] ) ) { return $html; } $image = $settings['image']; if ( ! isset( $image['id'] ) ) { return $html; } if ( ! $this->is_new_offloaded_attachment( $image['id'] ) ) { return $html; } if ( ! isset( $settings['image_size'] ) ) { return $html; } if ( $settings['image_size'] === 'custom' ) { if ( ! isset( $settings['image_custom_dimension'] ) ) { return $html; } $custom_dimensions = $settings['image_custom_dimension']; if ( ! isset( $custom_dimensions['width'] ) || ! isset( $custom_dimensions['height'] ) ) { return $html; } $new_args = [ 'width' => $custom_dimensions['width'], 'height' => $custom_dimensions['height'], 'resize' => $this->to_optml_crop( true ), ]; $new_url = $this->get_new_offloaded_attachment_url( $image['url'], $image['id'], $new_args ); return str_replace( $image['url'], $new_url, $html ); } return $html; } /** * Adds new actions for new offloads. * * @return void */ public function add_new_actions() { add_filter( 'wp_prepare_attachment_for_js', [ self::$instance, 'alter_attachment_for_js' ], 999, 3 ); add_filter( 'wp_get_attachment_metadata', [ self::$instance, 'alter_attachment_metadata' ], 10, 2 ); add_filter( 'wp_get_attachment_image_src', [ self::$instance, 'alter_attachment_image_src' ], 10, 4 ); // Needed for rendering beaver builder css properly. add_filter( 'fl_builder_render_css', [ self::$instance, 'replace_urls_in_editor_content' ], 10, 1 ); // Filter saved data on insert to use local attachments. // Backwards compatibility for older versions of WordPress < 6.0.0 requiring 3 parameters for this specific filter. $below_6_0_0 = version_compare( get_bloginfo( 'version' ), '6.0.0', '<' ); if ( $below_6_0_0 ) { add_filter( 'wp_insert_post_data', [ self::$instance, 'legacy_filter_saved_data' ], 10, 3 ); } else { add_filter( 'wp_insert_post_data', [ self::$instance, 'filter_saved_data' ], 10, 4 ); } // Filter loaded data in the editors to use local attachments. add_filter( 'content_edit_pre', [ self::$instance, 'replace_urls_in_editor_content' ], 10, 1 ); add_action( 'init', function () { $types = get_post_types_by_support( 'editor' ); foreach ( $types as $type ) { $post_type = get_post_type_object( $type ); if ( property_exists( $post_type, 'show_in_rest' ) && true === $post_type->show_in_rest ) { add_filter( 'rest_prepare_' . $type, [ self::$instance, 'pre_filter_rest_content' ], 10, 3 ); } } }, PHP_INT_MAX ); add_filter( 'get_attached_file', [ $this, 'alter_attached_file_response' ], 10, 2 ); add_filter( 'elementor/image_size/get_attachment_image_html', [ $this, 'alter_elementor_image_size', ], 10, 4 ); } /** * Elementor checks if the file exists before requesting a specific image size. * * Needed because otherwise there won't be any width/height on the `img` tags, breaking lazyload. * * Also needed because some * * @param string $file The file path. * @param int $id The attachment ID. * * @return bool|string */ public function alter_attached_file_response( $file, $id ) { if ( ! $this->is_new_offloaded_attachment( $id ) ) { return $file; } $metadata = wp_get_attachment_metadata( $id ); if ( isset( $metadata['file'] ) ) { $uploads = wp_get_upload_dir(); return $uploads['basedir'] . '/' . $metadata['file']; } return true; } /** * Maybe strip the `-scaled` from the URL. * * @param string $url The url. * * @return string */ public function maybe_strip_scaled( $url ) { $ext = $this->get_ext( $url ); return str_replace( '-scaled.' . $ext, '.' . $ext, $url ); } /** * Is it a PHPUnit test run. * * @return bool */ public static function is_phpunit_test() { return defined( 'OPTML_PHPUNIT_TESTING' ) && OPTML_PHPUNIT_TESTING === true; } /** * Get offloaded image attachment URL based on the given attachment ID and URL. * * @param mixed $attachment_id The attachment ID. * @param string $url The attachment URL. * * @return string */ private function get_offloaded_attachment_url( $attachment_id, $url ) { if ( ! $this->settings->is_offload_enabled() || ! is_numeric( $attachment_id ) ) { return $url; } elseif ( empty( $attachment_id ) && strpos( $url, self::KEYS['not_processed_flag'] ) !== false ) { $attachment_id = (int) self::get_attachment_id_from_url( $url ); } elseif ( empty( $attachment_id ) ) { $attachment_id = $this->attachment_url_to_post_id( $url ); } if ( $attachment_id > 0 || ! empty( get_post_meta( $attachment_id, self::OM_OFFLOADED_FLAG, true ) ) ) { $url = wp_get_attachment_metadata( $attachment_id )['file']; } return $url; } /** * Cleanup the offload errors meta. */ public static function clear_offload_errors_meta() { global $wpdb; return $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s", self::META_KEYS['offload_error'] ) ); } }