* The class to operate media data.
defined( 'WPINC' ) || exit();
* Handles media-related optimizations like lazy loading, next-gen image replacement, and admin UI.
class Media extends Root {
const LIB_FILE_IMG_LAZYLOAD = 'assets/js/lazyload.min.js';
* Current page buffer content.
* WordPress uploads directory info.
* List of VPI (viewport images) to preload in <head>.
private $_vpi_preload_list = [];
* The user-level next-gen format supported (''|webp|avif).
* The system-level chosen next-gen format (webp|avif).
private $_sys_format = '';
public function __construct() {
$this->_wp_upload_dir = wp_upload_dir();
if ( $this->conf( Base::O_IMG_OPTM_WEBP ) ) {
$this->_sys_format = 'webp';
if ( 2 === $this->conf( Base::O_IMG_OPTM_WEBP ) ) {
$this->_sys_format = 'avif';
if ( ! $this->_browser_support_next_gen() ) {
$this->_format = apply_filters( 'litespeed_next_gen_format', $this->_format );
* @since 7.4 Add media replace original with scaled.
public function after_user_init() {
// Hook to attachment delete action (PR#844, Issue#841) for AJAX del compatibility.
add_action( 'delete_attachment', [ $this, 'delete_attachment' ], 11, 2 );
// For big images, allow to replace original with scaled image.
if ( $this->conf( Base::O_MEDIA_AUTO_RESCALE_ORI ) ) {
// Added priority 9 to happen before other functions added.
add_filter( 'wp_update_attachment_metadata', [ $this, 'rescale_ori' ], 9, 2 );
// Due to ajax call doesn't send correct accept header, have to limit webp to HTML only.
if ( $this->webp_support() ) {
if ( function_exists( 'wp_calculate_image_srcset' ) ) {
add_filter( 'wp_calculate_image_srcset', [ $this, 'webp_srcset' ], 988 );
// add_filter( 'wp_get_attachment_image_src', [ $this, 'webp_attach_img_src' ], 988 );// todo: need to check why not
// add_filter( 'wp_get_attachment_url', [ $this, 'webp_url' ], 988 ); // disabled to avoid wp-admin display
if ( $this->conf( Base::O_MEDIA_LAZY ) && ! $this->cls( 'Metabox' )->setting( 'litespeed_no_image_lazy' ) ) {
self::debug( 'Suppress default WP lazyload' );
add_filter( 'wp_lazy_loading_enabled', '__return_false' );
add_filter( 'litespeed_buffer_finalize', [ $this, 'finalize' ], 4 );
add_filter( 'litespeed_optm_html_head', [ $this, 'finalize_head' ] );
* Handle attachment create (rescale original).
* @param array $metadata Current meta array.
* @param int $attachment_id Attachment ID.
* @return array Modified metadata.
public function rescale_ori( $metadata, $attachment_id ) {
// Test if create and image was resized.
if ( $metadata && isset( $metadata['original_image'], $metadata['file'] ) && false !== strpos( $metadata['file'], '-scaled' ) ) {
// Get rescaled file name.
$path_exploded = explode( '/', strrev( $metadata['file'] ), 2 );
$rescaled_file_name = strrev( $path_exploded[0] );
// Create paths for images: resized and original.
$base_path = $this->_wp_upload_dir['basedir'] . $this->_wp_upload_dir['subdir'] . '/';
$rescaled_path = $base_path . $rescaled_file_name;
$new_path = $base_path . $metadata['original_image'];
// Change array file key.
$metadata['file'] = $this->_wp_upload_dir['subdir'] . '/' . $metadata['original_image'];
if ( 0 === strpos( $metadata['file'], '/' ) ) {
$metadata['file'] = substr( $metadata['file'], 1 );
// Delete array "original_image" key.
unset( $metadata['original_image'] );
if ( file_exists( $rescaled_path ) && file_exists( $new_path ) ) {
// Move rescaled to original using WP_Filesystem.
if ( ! $wp_filesystem ) {
require_once ABSPATH . '/wp-admin/includes/file.php';
$wp_filesystem->move( $rescaled_path, $new_path, true );
// Update meta "_wp_attached_file".
update_post_meta( $attachment_id, '_wp_attached_file', $metadata['file'] );
* Add featured image and VPI preloads to head.
* @param string $content Current head HTML.
* @return string Modified head HTML.
public function finalize_head( $content ) {
// <link rel="preload" as="image" href="xx">
if ( $this->_vpi_preload_list ) {
foreach ( $this->_vpi_preload_list as $v ) {
$content .= '<link rel="preload" as="image" href="' . esc_url( Str::trim_quotes( $v ) ) . '">';
* Adjust WP default JPG quality.
* @param int $quality Current quality.
* @return int Adjusted quality.
public function adjust_jpg_quality( $quality ) {
$v = $this->conf( Base::O_IMG_OPTM_JPG_QUALITY );
public function after_admin_init() {
add_filter( 'jpeg_quality', [ $this, 'adjust_jpg_quality' ] );
add_filter( 'manage_media_columns', [ $this, 'media_row_title' ] );
add_filter( 'manage_media_custom_column', [ $this, 'media_row_actions' ], 10, 2 );
add_action( 'litespeed_media_row', [ $this, 'media_row_con' ] );
* Media delete action hook.
* @param int $post_id Post ID.
public static function delete_attachment( $post_id ) {
self::debug( 'delete_attachment [pid] ' . $post_id );
Img_Optm::cls()->reset_row( $post_id );
* Return media file info if exists.
* This is for remote attachment plugins.
* @param string $short_file_path Relative file path under uploads.
* @param int $post_id Post ID.
* @return array|false Array( url, md5, size ) or false.
public function info( $short_file_path, $post_id ) {
$short_file_path = wp_normalize_path( $short_file_path );
$basedir = $this->_wp_upload_dir['basedir'] . '/';
if ( 0 === strpos( $short_file_path, $basedir ) ) {
$short_file_path = substr( $short_file_path, strlen( $basedir ) );
$real_file = $basedir . $short_file_path;
if ( file_exists( $real_file ) ) {
'url' => $this->_wp_upload_dir['baseurl'] . '/' . $short_file_path,
'md5' => md5_file( $real_file ),
'size' => filesize( $real_file ),
* WP Stateless compatibility #143 https://github.com/litespeedtech/lscache_wp/issues/143
* Should return array( 'url', 'md5', 'size' ).
$info = apply_filters( 'litespeed_media_info', [], $short_file_path, $post_id );
if ( ! empty( $info['url'] ) && ! empty( $info['md5'] ) && ! empty( $info['size'] ) ) {
* @param string $short_file_path Relative file path under uploads.
* @param int $post_id Post ID.
public function del( $short_file_path, $post_id ) {
$real_file = $this->_wp_upload_dir['basedir'] . '/' . $short_file_path;
if ( file_exists( $real_file ) ) {
wp_delete_file( $real_file );
self::debug( 'deleted ' . $real_file );
do_action( 'litespeed_media_del', $short_file_path, $post_id );
* @param string $short_file_path Old relative path.
* @param string $short_file_path_new New relative path.
* @param int $post_id Post ID.
public function rename( $short_file_path, $short_file_path_new, $post_id ) {
$real_file = $this->_wp_upload_dir['basedir'] . '/' . $short_file_path;
$real_file_new = $this->_wp_upload_dir['basedir'] . '/' . $short_file_path_new;
if ( file_exists( $real_file ) ) {
if ( ! $wp_filesystem ) {
require_once ABSPATH . '/wp-admin/includes/file.php';
$wp_filesystem->move( $real_file, $real_file_new, true );
self::debug( 'renamed ' . $real_file . ' to ' . $real_file_new );
do_action( 'litespeed_media_rename', $short_file_path, $short_file_path_new, $post_id );
* Media Admin Menu -> Image Optimization Column Title.
* @param array $posts_columns Existing columns.
* @return array Modified columns.
public function media_row_title( $posts_columns ) {
$posts_columns['imgoptm'] = esc_html__( 'LiteSpeed Optimization', 'litespeed-cache' );
* Media Admin Menu -> Image Optimization Column.
* @param string $column_name Current column name.
* @param int $post_id Post ID.
public function media_row_actions( $column_name, $post_id ) {
if ( 'imgoptm' !== $column_name ) {
do_action( 'litespeed_media_row', $post_id );
* Display image optimization info in the media list row.
* @param int $post_id Attachment post ID.
public function media_row_con( $post_id ) {
$att_info = wp_get_attachment_metadata( $post_id );
if ( empty( $att_info['file'] ) ) {
$short_path = $att_info['file'];
$size_meta = get_post_meta( $post_id, Img_Optm::DB_SIZE, true );
if ( $size_meta && ! empty( $size_meta['ori_saved'] ) ) {
$percent = (int) ceil( ( (int) $size_meta['ori_saved'] * 100 ) / max( 1, (int) $size_meta['ori_total'] ) );
$extension = pathinfo( $short_path, PATHINFO_EXTENSION );
$bk_file = substr( $short_path, 0, -strlen( $extension ) ) . 'bk.' . $extension;
$bk_optm_file = substr( $short_path, 0, -strlen( $extension ) ) . 'bk.optm.' . $extension;
$link = Utility::build_url( Router::ACTION_IMG_OPTM, 'orig' . $post_id );
if ( $this->info( $bk_file, $post_id ) ) {
$curr_status = esc_html__( '(optm)', 'litespeed-cache' );
$desc = esc_attr__( 'Currently using optimized version of file.', 'litespeed-cache' ) . ' ' . esc_attr__( 'Click to switch to original (unoptimized) version.', 'litespeed-cache' );
} elseif ( $this->info( $bk_optm_file, $post_id ) ) {
$cls .= ' litespeed-warning';
$curr_status = esc_html__( '(non-optm)', 'litespeed-cache' );
$desc = esc_attr__( 'Currently using original (unoptimized) version of file.', 'litespeed-cache' ) . ' ' . esc_attr__( 'Click to switch to optimized version.', 'litespeed-cache' );
esc_html__( 'Original file reduced by %1$s (%2$s)', 'litespeed-cache' ),
Utility::real_size( $size_meta['ori_saved'] )
esc_html__( 'Orig saved %s', 'litespeed-cache' ),
' <a href="%1$s" class="litespeed-media-href %2$s" data-balloon-pos="left" data-balloon-break aria-label="%3$s">%4$s</a>',
' <span class="litespeed-desc" data-balloon-pos="left" data-balloon-break aria-label="%1$s">%2$s</span>',
esc_attr__( 'Using optimized version of file. ', 'litespeed-cache' ) . ' ' . esc_attr__( 'No backup of original file exists.', 'litespeed-cache' ),
esc_html__( '(optm)', 'litespeed-cache' )
} elseif ( $size_meta && 0 === (int) $size_meta['ori_saved'] ) {
echo wp_kses_post( GUI::pie_tiny( 0, 24, esc_html__( 'Congratulation! Your file was already optimized', 'litespeed-cache' ), 'left' ) );
esc_html__( 'Orig %s', 'litespeed-cache' ),
'<span class="litespeed-desc">' . esc_html__( '(no savings)', 'litespeed-cache' ) . '</span>'
echo esc_html__( 'Orig', 'litespeed-cache' ) . '<span class="litespeed-left10">—</span>';
if ( $size_meta && $this->webp_support( true ) && ! empty( $size_meta[ $this->_sys_format . '_saved' ] ) ) {
$is_avif = 'avif' === $this->_sys_format;
$size_meta_saved = $size_meta[ $this->_sys_format . '_saved' ];
$size_meta_total = $size_meta[ $this->_sys_format . '_total' ];
$percent = ceil( ( $size_meta_saved * 100 ) / max( 1, $size_meta_total ) );
$link = Utility::build_url( Router::ACTION_IMG_OPTM, $this->_sys_format . $post_id );
if ( $this->info( $short_path . '.' . $this->_sys_format, $post_id ) ) {
$curr_status = esc_html__( '(optm)', 'litespeed-cache' );
? esc_attr__( 'Currently using optimized version of AVIF file.', 'litespeed-cache' )
: esc_attr__( 'Currently using optimized version of WebP file.', 'litespeed-cache' );
$desc .= ' ' . esc_attr__( 'Click to switch to original (unoptimized) version.', 'litespeed-cache' );
} elseif ( $this->info( $short_path . '.optm.' . $this->_sys_format, $post_id ) ) {
$cls .= ' litespeed-warning';
$curr_status = esc_html__( '(non-optm)', 'litespeed-cache' );
? esc_attr__( 'Currently using original (unoptimized) version of AVIF file.', 'litespeed-cache' )
: esc_attr__( 'Currently using original (unoptimized) version of WebP file.', 'litespeed-cache' );
$desc .= ' ' . esc_attr__( 'Click to switch to optimized version.', 'litespeed-cache' );