Changeset 3444709
- Timestamp:
- 01/22/2026 10:44:36 AM (9 days ago)
- Location:
- avif-local-support
- Files:
-
- 8 edited
- 1 copied
-
tags/0.5.21 (copied) (copied from avif-local-support/trunk)
-
tags/0.5.21/avif-local-support.php (modified) (2 diffs)
-
tags/0.5.21/includes/ThumbHash.php (modified) (31 diffs)
-
tags/0.5.21/includes/class-support.php (modified) (8 diffs)
-
tags/0.5.21/readme.txt (modified) (2 diffs)
-
trunk/avif-local-support.php (modified) (2 diffs)
-
trunk/includes/ThumbHash.php (modified) (31 diffs)
-
trunk/includes/class-support.php (modified) (8 diffs)
-
trunk/readme.txt (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
avif-local-support/tags/0.5.21/avif-local-support.php
r3433195 r3444709 7 7 * Plugin URI: https://github.com/ddegner/avif-local-support 8 8 * Description: High-quality AVIF image conversion for WordPress — local, quality-first. 9 * Version: 0.5.2 09 * Version: 0.5.21 10 10 * Author: ddegner 11 11 * Author URI: https://www.daviddegner.com … … 22 22 23 23 // Define constants 24 \define('AVIFLOSU_VERSION', '0.5.2 0');24 \define('AVIFLOSU_VERSION', '0.5.21'); 25 25 \define('AVIFLOSU_PLUGIN_FILE', __FILE__); 26 26 \define('AVIFLOSU_PLUGIN_DIR', plugin_dir_path(__FILE__)); -
avif-local-support/tags/0.5.21/includes/ThumbHash.php
r3432335 r3444709 8 8 9 9 // Prevent direct access. 10 \defined( 'ABSPATH') || exit;10 \defined( 'ABSPATH' ) || exit; 11 11 12 12 /** … … 16 16 * client-side to smooth placeholders while full images load. 17 17 */ 18 final class ThumbHash 19 { 18 final class ThumbHash { 19 20 20 21 21 … … 27 27 /** 28 28 * Maximum dimension for thumbnail before hashing. 29 * Fixed at 32px to match ThumbHash decoder output resolution. 30 */ 31 private const MAX_DIMENSION = 32; 29 * Set to 100px (ThumbHash maximum) to capture more detail in the DCT encoding. 30 * The decoder outputs 32px, but larger input = more frequency data = richer placeholders. 31 */ 32 private const MAX_DIMENSION = 100; 32 33 33 34 /** … … 35 36 * Fixed at 32px to match the decoder output. 36 37 */ 37 public static function getMaxDimension(): int 38 { 38 public static function getMaxDimension(): int { 39 39 return self::MAX_DIMENSION; 40 40 } … … 43 43 * Check if ThumbHash feature is enabled. 44 44 */ 45 public static function isEnabled(): bool 46 { 47 return (bool) \get_option('aviflosu_thumbhash_enabled', false); 45 public static function isEnabled(): bool { 46 return (bool) \get_option( 'aviflosu_thumbhash_enabled', false ); 48 47 } 49 48 … … 53 52 * @return bool True if the library class exists, false otherwise. 54 53 */ 55 public static function isLibraryAvailable(): bool 56 { 57 return class_exists('Thumbhash\Thumbhash'); 54 public static function isLibraryAvailable(): bool { 55 return class_exists( 'Thumbhash\Thumbhash' ); 58 56 } 59 57 … … 64 62 * @return string|null Base64-encoded ThumbHash or null on failure. 65 63 */ 66 public static function generate(string $imagePath): ?string 67 { 64 public static function generate( string $imagePath ): ?string { 68 65 // Check if the ThumbHash library is available 69 if ( !self::isLibraryAvailable()) {66 if ( ! self::isLibraryAvailable() ) { 70 67 self::$lastError = 'ThumbHash library not found. Please run "composer install" in the plugin directory to install dependencies.'; 71 if ( class_exists(Logger::class)) {72 ( new Logger())->addLog(68 if ( class_exists( Logger::class ) ) { 69 ( new Logger() )->addLog( 73 70 'error', 74 71 'ThumbHash library not available', 75 72 array( 76 'path' => $imagePath,73 'path' => $imagePath, 77 74 'error' => 'Thumbhash\Thumbhash class not found. Composer dependencies may not be installed.', 78 75 ) … … 82 79 } 83 80 84 if ( !file_exists($imagePath) || !is_readable($imagePath)) {81 if ( ! file_exists( $imagePath ) || ! is_readable( $imagePath ) ) { 85 82 self::$lastError = "File not found or unreadable: $imagePath"; 86 if ( class_exists(Logger::class)) {87 ( new Logger())->addLog('error', 'ThumbHash failed: File not found', array('path' => $imagePath));83 if ( class_exists( Logger::class ) ) { 84 ( new Logger() )->addLog( 'error', 'ThumbHash failed: File not found', array( 'path' => $imagePath ) ); 88 85 } 89 86 return null; … … 92 89 try { 93 90 // Try Imagick first (better alpha support) 94 if ( extension_loaded('imagick') && class_exists(\Imagick::class)) {95 return self::generateWithImagick( $imagePath);91 if ( extension_loaded( 'imagick' ) && class_exists( \Imagick::class ) ) { 92 return self::generateWithImagick( $imagePath ); 96 93 } 97 94 98 95 // Fall back to GD 99 if ( extension_loaded('gd')) {100 return self::generateWithGd( $imagePath);96 if ( extension_loaded( 'gd' ) ) { 97 return self::generateWithGd( $imagePath ); 101 98 } 102 99 103 100 self::$lastError = 'No supported image library (Imagick or GD) found.'; 104 101 return null; 105 } catch ( \Throwable $e) {102 } catch ( \Throwable $e ) { 106 103 // Log error but don't block conversion 107 104 self::$lastError = $e->getMessage(); 108 105 109 if ( defined('WP_DEBUG') && WP_DEBUG) {106 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { 110 107 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log 111 error_log( 'ThumbHash generation failed for ' . $imagePath . ': ' . $e->getMessage());112 } 113 114 if ( class_exists(Logger::class)) {115 ( new Logger())->addLog(108 error_log( 'ThumbHash generation failed for ' . $imagePath . ': ' . $e->getMessage() ); 109 } 110 111 if ( class_exists( Logger::class ) ) { 112 ( new Logger() )->addLog( 116 113 'error', 117 114 'ThumbHash generation exception', 118 115 array( 119 'path' => $imagePath,116 'path' => $imagePath, 120 117 'error' => $e->getMessage(), 121 118 ) … … 135 132 * Get the last error that occurred during generation. 136 133 */ 137 public static function getLastError(): ?string 138 { 134 public static function getLastError(): ?string { 139 135 return self::$lastError; 140 136 } … … 143 139 * Generate ThumbHash using Imagick. 144 140 */ 145 private static function generateWithImagick(string $imagePath): ?string 146 { 141 private static function generateWithImagick( string $imagePath ): ?string { 147 142 $imagick = new \Imagick(); 148 143 // Optimization: Hint to libjpeg to load a smaller version (downscale) to save memory. … … 150 145 // sufficient source data for smooth downscaling while still significantly reducing memory for large JPEGs. 151 146 try { 152 $imagick->setOption( 'jpeg:size', '200x200');153 } catch ( \Throwable $e) {147 $imagick->setOption( 'jpeg:size', '200x200' ); 148 } catch ( \Throwable $e ) { 154 149 // Ignore if setOption fails (e.g. older ImageMagick versions) 155 150 } 156 $imagick->readImage( $imagePath);151 $imagick->readImage( $imagePath ); 157 152 158 153 // Get original dimensions 159 $width = $imagick->getImageWidth();154 $width = $imagick->getImageWidth(); 160 155 $height = $imagick->getImageHeight(); 161 156 162 157 // Calculate thumbnail dimensions maintaining aspect ratio 163 if ( $width > self::getMaxDimension() || $height > self::getMaxDimension()) {164 if ( $width >= $height) {165 $newWidth = self::getMaxDimension();166 $newHeight = (int) round( $height * (self::getMaxDimension() / $width));158 if ( $width > self::getMaxDimension() || $height > self::getMaxDimension() ) { 159 if ( $width >= $height ) { 160 $newWidth = self::getMaxDimension(); 161 $newHeight = (int) round( $height * ( self::getMaxDimension() / $width ) ); 167 162 } else { 168 163 $newHeight = self::getMaxDimension(); 169 $newWidth = (int) round($width * (self::getMaxDimension() / $height));164 $newWidth = (int) round( $width * ( self::getMaxDimension() / $height ) ); 170 165 } 171 166 // Ensure minimum of 1px 172 $newWidth = max(1, $newWidth);173 $newHeight = max( 1, $newHeight);174 $imagick->thumbnailImage( $newWidth, $newHeight);167 $newWidth = max( 1, $newWidth ); 168 $newHeight = max( 1, $newHeight ); 169 $imagick->thumbnailImage( $newWidth, $newHeight ); 175 170 } else { 176 $newWidth = $width; 171 $newWidth = $width; 172 $newHeight = $height; 173 } 174 175 // Extract RGBA pixels 176 $pixels = array(); 177 $iterator = $imagick->getPixelIterator(); 178 179 foreach ( $iterator as $row ) { 180 foreach ( $row as $pixel ) { 181 /** @var \ImagickPixel $pixel */ 182 // Use getColorValue() for compatibility across Imagick versions 183 $pixels[] = (int) round( $pixel->getColorValue( \Imagick::COLOR_RED ) * 255 ); 184 $pixels[] = (int) round( $pixel->getColorValue( \Imagick::COLOR_GREEN ) * 255 ); 185 $pixels[] = (int) round( $pixel->getColorValue( \Imagick::COLOR_BLUE ) * 255 ); 186 $pixels[] = (int) round( $pixel->getColorValue( \Imagick::COLOR_ALPHA ) * 255 ); 187 } 188 $iterator->syncIterator(); 189 } 190 191 $imagick->destroy(); 192 193 // Generate hash 194 $hash = ThumbhashLib::RGBAToHash( $newWidth, $newHeight, $pixels ); 195 196 return ThumbhashLib::convertHashToString( $hash ); 197 } 198 199 /** 200 * Generate ThumbHash using GD. 201 */ 202 private static function generateWithGd( string $imagePath ): ?string { 203 $imageInfo = @getimagesize( $imagePath ); 204 if ( ! $imageInfo ) { 205 return null; 206 } 207 208 $mimeType = $imageInfo['mime'] ?? ''; 209 $image = match ( $mimeType ) { 210 'image/jpeg', 'image/jpg' => @imagecreatefromjpeg( $imagePath ), 211 'image/png' => @imagecreatefrompng( $imagePath ), 212 'image/gif' => @imagecreatefromgif( $imagePath ), 213 'image/webp' => @imagecreatefromwebp( $imagePath ), 214 default => false, 215 }; 216 217 if ( ! $image ) { 218 return null; 219 } 220 221 $width = imagesx( $image ); 222 $height = imagesy( $image ); 223 224 // Calculate thumbnail dimensions maintaining aspect ratio 225 if ( $width > self::getMaxDimension() || $height > self::getMaxDimension() ) { 226 if ( $width >= $height ) { 227 $newWidth = self::getMaxDimension(); 228 $newHeight = (int) round( $height * ( self::getMaxDimension() / $width ) ); 229 } else { 230 $newHeight = self::getMaxDimension(); 231 $newWidth = (int) round( $width * ( self::getMaxDimension() / $height ) ); 232 } 233 $newWidth = max( 1, $newWidth ); 234 $newHeight = max( 1, $newHeight ); 235 236 $resized = imagecreatetruecolor( $newWidth, $newHeight ); 237 if ( ! $resized ) { 238 return null; 239 } 240 241 // Preserve alpha channel 242 imagealphablending( $resized, false ); 243 imagesavealpha( $resized, true ); 244 245 imagecopyresampled( $resized, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height ); 246 $image = $resized; 247 } else { 248 $newWidth = $width; 177 249 $newHeight = $height; 178 250 } … … 180 252 // Extract RGBA pixels 181 253 $pixels = array(); 182 $iterator = $imagick->getPixelIterator(); 183 184 foreach ($iterator as $row) { 185 foreach ($row as $pixel) { 186 /** @var \ImagickPixel $pixel */ 187 // Use getColorValue() for compatibility across Imagick versions 188 $pixels[] = (int) round($pixel->getColorValue(\Imagick::COLOR_RED) * 255); 189 $pixels[] = (int) round($pixel->getColorValue(\Imagick::COLOR_GREEN) * 255); 190 $pixels[] = (int) round($pixel->getColorValue(\Imagick::COLOR_BLUE) * 255); 191 $pixels[] = (int) round($pixel->getColorValue(\Imagick::COLOR_ALPHA) * 255); 192 } 193 $iterator->syncIterator(); 194 } 195 196 $imagick->destroy(); 197 198 // Generate hash 199 $hash = ThumbhashLib::RGBAToHash($newWidth, $newHeight, $pixels); 200 201 return ThumbhashLib::convertHashToString($hash); 202 } 203 204 /** 205 * Generate ThumbHash using GD. 206 */ 207 private static function generateWithGd(string $imagePath): ?string 208 { 209 $imageInfo = @getimagesize($imagePath); 210 if (!$imageInfo) { 211 return null; 212 } 213 214 $mimeType = $imageInfo['mime'] ?? ''; 215 $image = match ($mimeType) { 216 'image/jpeg', 'image/jpg' => @imagecreatefromjpeg($imagePath), 217 'image/png' => @imagecreatefrompng($imagePath), 218 'image/gif' => @imagecreatefromgif($imagePath), 219 'image/webp' => @imagecreatefromwebp($imagePath), 220 default => false, 221 }; 222 223 if (!$image) { 224 return null; 225 } 226 227 $width = imagesx($image); 228 $height = imagesy($image); 229 230 // Calculate thumbnail dimensions maintaining aspect ratio 231 if ($width > self::getMaxDimension() || $height > self::getMaxDimension()) { 232 if ($width >= $height) { 233 $newWidth = self::getMaxDimension(); 234 $newHeight = (int) round($height * (self::getMaxDimension() / $width)); 235 } else { 236 $newHeight = self::getMaxDimension(); 237 $newWidth = (int) round($width * (self::getMaxDimension() / $height)); 238 } 239 $newWidth = max(1, $newWidth); 240 $newHeight = max(1, $newHeight); 241 242 $resized = imagecreatetruecolor($newWidth, $newHeight); 243 if (!$resized) { 244 return null; 245 } 246 247 // Preserve alpha channel 248 imagealphablending($resized, false); 249 imagesavealpha($resized, true); 250 251 imagecopyresampled($resized, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height); 252 $image = $resized; 253 } else { 254 $newWidth = $width; 255 $newHeight = $height; 256 } 257 258 // Extract RGBA pixels 259 $pixels = array(); 260 for ($y = 0; $y < $newHeight; $y++) { 261 for ($x = 0; $x < $newWidth; $x++) { 262 $rgba = imagecolorat($image, $x, $y); 263 $pixels[] = ($rgba >> 16) & 0xFF; // R 264 $pixels[] = ($rgba >> 8) & 0xFF; // G 254 for ( $y = 0; $y < $newHeight; $y++ ) { 255 for ( $x = 0; $x < $newWidth; $x++ ) { 256 $rgba = imagecolorat( $image, $x, $y ); 257 $pixels[] = ( $rgba >> 16 ) & 0xFF; // R 258 $pixels[] = ( $rgba >> 8 ) & 0xFF; // G 265 259 $pixels[] = $rgba & 0xFF; // B 266 260 // GD alpha: 127 = transparent, 0 = opaque (invert for ThumbHash) 267 $alpha = ($rgba >> 24) & 0x7F; 268 $pixels[] = (int) round((127 - $alpha) * (255 / 127)); 269 } 270 } 271 272 261 $alpha = ( $rgba >> 24 ) & 0x7F; 262 $pixels[] = (int) round( ( 127 - $alpha ) * ( 255 / 127 ) ); 263 } 264 } 273 265 274 266 // Generate hash 275 $hash = ThumbhashLib::RGBAToHash( $newWidth, $newHeight, $pixels);276 277 return ThumbhashLib::convertHashToString( $hash);267 $hash = ThumbhashLib::RGBAToHash( $newWidth, $newHeight, $pixels ); 268 269 return ThumbhashLib::convertHashToString( $hash ); 278 270 } 279 271 … … 285 277 * @return string|null Base64-encoded ThumbHash or null if not available. 286 278 */ 287 public static function getForAttachment(int $attachmentId, string $size = 'full'): ?string 288 { 289 if (!self::isEnabled()) { 290 return null; 291 } 292 293 $meta = \get_post_meta($attachmentId, self::META_KEY, true); 294 if (!is_array($meta)) { 295 return null; 296 } 297 298 return $meta[$size] ?? $meta['full'] ?? null; 279 public static function getForAttachment( int $attachmentId, string $size = 'full' ): ?string { 280 if ( ! self::isEnabled() ) { 281 return null; 282 } 283 284 $meta = \get_post_meta( $attachmentId, self::META_KEY, true ); 285 if ( ! is_array( $meta ) ) { 286 return null; 287 } 288 289 return $meta[ $size ] ?? $meta['full'] ?? null; 299 290 } 300 291 … … 305 296 * @return array<string, string>|null Hash array keyed by size name, or null on failure. 306 297 */ 307 public static function generateForAttachment(int $attachmentId): ?array 308 { 309 if (!self::isEnabled()) { 310 return null; 311 } 312 313 return self::doGenerateForAttachment($attachmentId); 298 public static function generateForAttachment( int $attachmentId ): ?array { 299 if ( ! self::isEnabled() ) { 300 return null; 301 } 302 303 return self::doGenerateForAttachment( $attachmentId ); 314 304 } 315 305 … … 319 309 * @param int $attachmentId WordPress attachment ID. 320 310 */ 321 public static function deleteForAttachment(int $attachmentId): void 322 { 323 \delete_post_meta($attachmentId, self::META_KEY); 311 public static function deleteForAttachment( int $attachmentId ): void { 312 \delete_post_meta( $attachmentId, self::META_KEY ); 324 313 } 325 314 … … 328 317 * Used by uninstall.php for cleanup. 329 318 */ 330 public static function getMetaKey(): string 331 { 319 public static function getMetaKey(): string { 332 320 return self::META_KEY; 333 321 } … … 339 327 * @return array{generated: int, skipped: int, failed: int} 340 328 */ 341 public static function generateAll(bool $force = false): array 342 { 329 public static function generateAll( bool $force = false ): array { 343 330 $result = array( 344 331 'generated' => 0, 345 'skipped' => 0,346 'failed' => 0,332 'skipped' => 0, 333 'failed' => 0, 347 334 ); 348 335 349 336 $query = new \WP_Query( 350 337 array( 351 'post_type' => 'attachment',352 'post_mime_type' => array('image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'),353 'post_status' => 'inherit',354 'posts_per_page' => -1,355 'fields' => 'ids',356 'no_found_rows' => true,338 'post_type' => 'attachment', 339 'post_mime_type' => array( 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp' ), 340 'post_status' => 'inherit', 341 'posts_per_page' => -1, 342 'fields' => 'ids', 343 'no_found_rows' => true, 357 344 'update_post_meta_cache' => false, // Avoid potential caching issues 358 345 'update_post_term_cache' => false, … … 360 347 ); 361 348 362 $logger = class_exists( Logger::class) ? new Logger() : null;363 364 foreach ( $query->posts as $attachmentId) {349 $logger = class_exists( Logger::class ) ? new Logger() : null; 350 351 foreach ( $query->posts as $attachmentId ) { 365 352 // Clear object cache for this post to ensure fresh meta data 366 353 // This prevents stale data from persistent object caching (Redis/Memcached) 367 \clean_post_cache( (int) $attachmentId);354 \clean_post_cache( (int) $attachmentId ); 368 355 369 356 // Skip if already has valid ThumbHash (unless forcing regeneration) 370 if ( !$force) {371 $existing = \get_post_meta( $attachmentId, self::META_KEY, true);357 if ( ! $force ) { 358 $existing = \get_post_meta( $attachmentId, self::META_KEY, true ); 372 359 // Verify it's a valid ThumbHash array with at least a 'full' entry 373 if ( is_array($existing) && isset($existing['full']) && is_string($existing['full']) && strlen($existing['full']) > 10) {360 if ( is_array( $existing ) && isset( $existing['full'] ) && is_string( $existing['full'] ) && strlen( $existing['full'] ) > 10 ) { 374 361 ++$result['skipped']; 375 362 // Log individual skip 376 if ( $logger) {363 if ( $logger ) { 377 364 $logger->addLog( 378 365 'info', 379 sprintf( 'LQIP skipped for attachment ID %d (already exists)', $attachmentId),380 array( 'attachment_id' => $attachmentId)366 sprintf( 'LQIP skipped for attachment ID %d (already exists)', $attachmentId ), 367 array( 'attachment_id' => $attachmentId ) 381 368 ); 382 369 } … … 386 373 387 374 // Use private helper to generate (bypasses isEnabled check for bulk operations) 388 $hashes = self::doGenerateForAttachment( (int) $attachmentId);389 390 if ( is_array($hashes) && !empty($hashes['full'])) {375 $hashes = self::doGenerateForAttachment( (int) $attachmentId ); 376 377 if ( is_array( $hashes ) && ! empty( $hashes['full'] ) ) { 391 378 ++$result['generated']; 392 379 // Log individual success 393 if ( $logger) {380 if ( $logger ) { 394 381 $logger->addLog( 395 382 'success', 396 sprintf( 'LQIP generated for attachment ID %d', $attachmentId),383 sprintf( 'LQIP generated for attachment ID %d', $attachmentId ), 397 384 array( 398 'attachment_id' => $attachmentId,399 'sizes_generated' => count( $hashes),385 'attachment_id' => $attachmentId, 386 'sizes_generated' => count( $hashes ), 400 387 ) 401 388 ); … … 404 391 ++$result['failed']; 405 392 // Capture the last error for debugging 406 if ( !isset($result['last_error']) && self::$lastError) {393 if ( ! isset( $result['last_error'] ) && self::$lastError ) { 407 394 $result['last_error'] = self::$lastError; 408 395 } 409 396 // Log individual failure 410 if ( $logger) {397 if ( $logger ) { 411 398 $logger->addLog( 412 399 'error', 413 sprintf( 'LQIP generation failed for attachment ID %d', $attachmentId),400 sprintf( 'LQIP generation failed for attachment ID %d', $attachmentId ), 414 401 array( 415 402 'attachment_id' => $attachmentId, 416 'error' => self::$lastError ?? 'Unknown error',403 'error' => self::$lastError ?? 'Unknown error', 417 404 ) 418 405 ); … … 422 409 423 410 // Log summary of bulk operation 424 if ( $logger && ($result['generated'] > 0 || $result['failed'] > 0 || $result['skipped'] > 0)) {411 if ( $logger && ( $result['generated'] > 0 || $result['failed'] > 0 || $result['skipped'] > 0 ) ) { 425 412 $logger->addLog( 426 413 $result['failed'] > 0 ? 'warning' : 'success', … … 433 420 array( 434 421 'generated' => $result['generated'], 435 'skipped' => $result['skipped'],436 'failed' => $result['failed'],422 'skipped' => $result['skipped'], 423 'failed' => $result['failed'], 437 424 ) 438 425 ); … … 449 436 * @return array<string, string>|null Hash array or null on failure. 450 437 */ 451 private static function doGenerateForAttachment(int $attachmentId): ?array 452 { 453 $metadata = \wp_get_attachment_metadata($attachmentId); 454 if (!is_array($metadata) || empty($metadata['file'])) { 438 private static function doGenerateForAttachment( int $attachmentId ): ?array { 439 $metadata = \wp_get_attachment_metadata( $attachmentId ); 440 if ( ! is_array( $metadata ) || empty( $metadata['file'] ) ) { 455 441 self::$lastError = "Invalid or missing metadata for attachment $attachmentId"; 456 442 return null; … … 458 444 459 445 $uploadDir = \wp_upload_dir(); 460 $baseDir = $uploadDir['basedir'] ?? '';461 if ( !$baseDir) {446 $baseDir = $uploadDir['basedir'] ?? ''; 447 if ( ! $baseDir ) { 462 448 self::$lastError = 'Upload basedir not found.'; 463 449 return null; 464 450 } 465 451 466 $hashes = array();467 $file = $metadata['file'];468 $fileDir = dirname( $file);452 $hashes = array(); 453 $file = $metadata['file']; 454 $fileDir = dirname( $file ); 469 455 470 456 // Generate for original/full image 471 457 $fullPath = $baseDir . '/' . $file; 472 458 // Some setups have 'file' as absolute path (rare but possible in offload plugins) 473 if ( !file_exists($fullPath)) {474 if ( file_exists($file)) {459 if ( ! file_exists( $fullPath ) ) { 460 if ( file_exists( $file ) ) { 475 461 $fullPath = $file; 476 462 } else { 477 463 self::$lastError = "File not found: $fullPath"; 478 if ( class_exists(Logger::class)) {479 ( new Logger())->addLog('warning', "ThumbHash skipped: Source file missing for ID $attachmentId", array('path' => $fullPath));464 if ( class_exists( Logger::class ) ) { 465 ( new Logger() )->addLog( 'warning', "ThumbHash skipped: Source file missing for ID $attachmentId", array( 'path' => $fullPath ) ); 480 466 } 481 467 } 482 468 } 483 469 484 if ( file_exists($fullPath)) {485 $hash = self::generate( $fullPath);486 if ( $hash) {470 if ( file_exists( $fullPath ) ) { 471 $hash = self::generate( $fullPath ); 472 if ( $hash ) { 487 473 $hashes['full'] = $hash; 488 474 } … … 491 477 // Generate for each registered size 492 478 $sizes = $metadata['sizes'] ?? array(); 493 foreach ( $sizes as $sizeName => $sizeData) {479 foreach ( $sizes as $sizeName => $sizeData ) { 494 480 $sizeFile = $sizeData['file'] ?? ''; 495 if ( !$sizeFile) {481 if ( ! $sizeFile ) { 496 482 continue; 497 483 } 498 484 499 485 $sizePath = $baseDir . '/' . $fileDir . '/' . $sizeFile; 500 if ( file_exists($sizePath)) {501 $hash = self::generate( $sizePath);502 if ( $hash) {503 $hashes[ $sizeName] = $hash;486 if ( file_exists( $sizePath ) ) { 487 $hash = self::generate( $sizePath ); 488 if ( $hash ) { 489 $hashes[ $sizeName ] = $hash; 504 490 } 505 491 } 506 492 } 507 493 508 if ( !empty($hashes)) {509 \update_post_meta( $attachmentId, self::META_KEY, $hashes);494 if ( ! empty( $hashes ) ) { 495 \update_post_meta( $attachmentId, self::META_KEY, $hashes ); 510 496 return $hashes; 511 497 } … … 519 505 * @return int Number of meta entries deleted. 520 506 */ 521 public static function deleteAll(): int 522 { 507 public static function deleteAll(): int { 523 508 global $wpdb; 524 509 … … 532 517 ); 533 518 534 if ( empty($postIds)) {519 if ( empty( $postIds ) ) { 535 520 return 0; 536 521 } … … 546 531 547 532 // Clear object cache for affected posts to prevent stale data in get_post_meta 548 foreach ( $postIds as $postId) {549 \clean_post_cache( (int) $postId);533 foreach ( $postIds as $postId ) { 534 \clean_post_cache( (int) $postId ); 550 535 } 551 536 552 537 // Log the deletion 553 if ( class_exists(Logger::class)) {554 ( new Logger())->addLog(538 if ( class_exists( Logger::class ) ) { 539 ( new Logger() )->addLog( 555 540 'info', 556 sprintf( 'LQIP bulk delete: %d entries deleted', $deleted),557 array( 'deleted' => (int) $deleted)541 sprintf( 'LQIP bulk delete: %d entries deleted', $deleted ), 542 array( 'deleted' => (int) $deleted ) 558 543 ); 559 544 } … … 570 555 * @return array{with_hash: int, without_hash: int, total: int} 571 556 */ 572 public static function getStats(): array 573 { 557 public static function getStats(): array { 574 558 global $wpdb; 575 559 … … 590 574 ); 591 575 592 $withHash = 0;576 $withHash = 0; 593 577 $seenPostIds = array(); 594 578 595 foreach ( $rows as $row) {579 foreach ( $rows as $row ) { 596 580 // Skip if we've already counted this post 597 if ( isset($seenPostIds[$row->post_id])) {581 if ( isset( $seenPostIds[ $row->post_id ] ) ) { 598 582 continue; 599 583 } 600 584 601 585 // Validate the structure matches what generateAll() skip logic expects 602 $meta = maybe_unserialize( $row->meta_value);603 if ( is_array($meta) && isset($meta['full']) && is_string($meta['full']) && strlen($meta['full']) > 10) {586 $meta = maybe_unserialize( $row->meta_value ); 587 if ( is_array( $meta ) && isset( $meta['full'] ) && is_string( $meta['full'] ) && strlen( $meta['full'] ) > 10 ) { 604 588 ++$withHash; 605 $seenPostIds[ $row->post_id] = true;589 $seenPostIds[ $row->post_id ] = true; 606 590 } 607 591 } 608 592 609 593 return array( 610 'with_hash' => $withHash,594 'with_hash' => $withHash, 611 595 'without_hash' => $total - $withHash, 612 'total' => $total,596 'total' => $total, 613 597 ); 614 598 } -
avif-local-support/tags/0.5.21/includes/class-support.php
r3432774 r3444709 6 6 7 7 // Prevent direct access. 8 \defined( 'ABSPATH') || exit;9 10 final class Support 11 { 12 13 14 15 16 private array $fileCache = array();8 \defined( 'ABSPATH' ) || exit; 9 10 final class Support { 11 12 13 14 15 16 private array $fileCache = array(); 17 17 private array $uploadsInfo = array(); 18 18 19 public function init(): void 20 { 19 public function init(): void { 20 21 21 $this->uploadsInfo = \wp_upload_dir(); 22 $this->fileCache = \get_transient('aviflosu_file_cache') ?: array();23 add_filter( 'wp_get_attachment_image', array($this, 'wrapAttachment'), 10, 5);24 add_filter('the_content', array($this, 'wrapContentImages'));25 add_filter( 'post_thumbnail_html', array($this, 'wrapContentImages'));26 add_filter( 'render_block', array($this, 'renderBlock'), 10, 2);27 add_action( 'shutdown', array($this, 'saveCache'));22 $this->fileCache = \get_transient( 'aviflosu_file_cache' ) ?: array(); 23 add_filter( 'wp_get_attachment_image', array( $this, 'wrapAttachment' ), 10, 5 ); 24 // Priority 15 runs AFTER WordPress adds srcset via wp_filter_content_tags at priority 10. 25 add_filter( 'the_content', array( $this, 'wrapContentImages' ), 15 ); 26 add_filter( 'post_thumbnail_html', array( $this, 'wrapContentImages' ), 15 ); 27 add_action( 'shutdown', array( $this, 'saveCache' ) ); 28 28 29 29 // Enqueue ThumbHash decoder if enabled - inline in head for early execution. 30 if ( ThumbHash::isEnabled() && !\is_admin()) {31 add_action( 'wp_head', array($this, 'inlineThumbHashDecoder'), 1);30 if ( ThumbHash::isEnabled() && ! \is_admin() ) { 31 add_action( 'wp_head', array( $this, 'inlineThumbHashDecoder' ), 1 ); 32 32 } 33 33 } … … 37 37 * This ensures placeholders appear before images start loading. 38 38 */ 39 public function inlineThumbHashDecoder(): void 40 { 39 public function inlineThumbHashDecoder(): void { 41 40 $scriptPath = AVIFLOSU_PLUGIN_DIR . 'assets/thumbhash-decoder.min.js'; 42 if ( !file_exists($scriptPath)) {41 if ( ! file_exists( $scriptPath ) ) { 43 42 return; 44 43 } 45 $script = file_get_contents( $scriptPath);46 if ( $script) {44 $script = file_get_contents( $scriptPath ); 45 if ( $script ) { 47 46 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Inline JS from trusted local file. 48 47 echo '<script id="aviflosu-thumbhash-decoder">' . $script . '</script>' . "\n"; … … 50 49 51 50 // Inject CSS for fading if enabled. 52 if ( (bool) get_option('aviflosu_lqip_fade', true)) {51 if ( (bool) get_option( 'aviflosu_lqip_fade', true ) ) { 53 52 // CSS explanation: 54 53 // 1. img[data-thumbhash] starts visible (opacity 1) with a transition. … … 60 59 . 'img.thumbhash-loading[data-thumbhash]{opacity:0;}'; 61 60 // Optionally render placeholders as sharp pixels instead of smooth blur. 62 if ( (bool) get_option('aviflosu_lqip_pixelated', false)) {61 if ( (bool) get_option( 'aviflosu_lqip_pixelated', false ) ) { 63 62 $css .= '.thumbhash-loading,img.thumbhash-loading{image-rendering:pixelated;}'; 64 63 } … … 68 67 } 69 68 70 public function wrapAttachment(string $html, int $attachmentId, $size, bool $icon, array $attr): string 71 { 72 if (str_contains($html, '<picture')) { 69 public function wrapAttachment( string $html, int $attachmentId, $size, bool $icon, array $attr ): string { 70 if ( str_contains( $html, '<picture' ) ) { 73 71 return $html; 74 72 } 75 73 76 $mime = \get_post_mime_type( $attachmentId);77 if ( !\is_string($mime) || !\in_array($mime, array('image/jpeg', 'image/jpg'), true)) {74 $mime = \get_post_mime_type( $attachmentId ); 75 if ( ! \is_string( $mime ) || ! \in_array( $mime, array( 'image/jpeg', 'image/jpg' ), true ) ) { 78 76 return $html; 79 77 } 80 78 81 $imageSrc = \wp_get_attachment_image_src( $attachmentId, $size);82 if ( !$imageSrc || !\is_array($imageSrc) || empty($imageSrc[0])) {79 $imageSrc = \wp_get_attachment_image_src( $attachmentId, $size ); 80 if ( ! $imageSrc || ! \is_array( $imageSrc ) || empty( $imageSrc[0] ) ) { 83 81 return $html; 84 82 } 85 83 86 $avifSrc = $this->avifUrlFor($imageSrc[0]); 87 88 $srcset = \wp_get_attachment_image_srcset($attachmentId, $size); 89 $avifSrcset = ($srcset && $avifSrc) ? $this->convertSrcsetToAvif($srcset) : ''; 90 $sizes = \wp_get_attachment_image_sizes($attachmentId, $size) ?: ''; 84 $avifSrc = $this->avifUrlFor( $imageSrc[0] ); 85 86 $srcset = \wp_get_attachment_image_srcset( $attachmentId, $size ); 87 $avifSrcset = $srcset ? $this->convertSrcsetToAvif( $srcset ) : ''; 88 89 // Ensure single AVIF candidates have width descriptors for responsive selection. 90 if ( '' === $avifSrcset && $avifSrc ) { 91 $w = (int) ( $imageSrc[1] ?? 0 ); 92 $avifSrcset = $w > 0 ? ( $avifSrc . ' ' . $w . 'w' ) : $avifSrc; 93 } 94 95 $sizes = \wp_get_attachment_image_sizes( $attachmentId, $size ) ?: ''; 91 96 92 97 // Get ThumbHash for LQIP if enabled. 93 $sizeName = is_array($size) ? 'full' : (string) $size; 94 $thumbhash = ThumbHash::getForAttachment($attachmentId, $sizeName); 95 96 return $this->pictureMarkup($html, $avifSrc, $avifSrcset, $sizes, $thumbhash); 97 } 98 99 public function wrapContentImages(string $content): string 100 { 101 if (\is_admin() || \wp_doing_ajax() || (\defined('REST_REQUEST') && REST_REQUEST)) { 98 $sizeName = is_array( $size ) ? 'full' : (string) $size; 99 $thumbhash = ThumbHash::getForAttachment( $attachmentId, $sizeName ); 100 101 return $this->pictureMarkup( $html, $avifSrc, $avifSrcset, $sizes, $thumbhash ); 102 } 103 104 public function wrapContentImages( string $content ): string { 105 if ( \is_admin() || \wp_doing_ajax() || ( \defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { 102 106 return $content; 103 107 } 104 if ( !str_contains($content, '<img')) {108 if ( ! str_contains( $content, '<img' ) ) { 105 109 return $content; 106 110 } 107 return $this->wrapHtmlImages($content); 108 } 109 110 public function renderBlock(string $block_content, array $block): string 111 { 112 $name = $block['blockName'] ?? ''; 113 if ('core/image' !== $name && 'core/gallery' !== $name) { 114 return $block_content; 115 } 116 if ('' === $block_content || false === strpos($block_content, '<img')) { 117 return $block_content; 118 } 119 return $this->wrapHtmlImages($block_content); 120 } 121 122 private function avifUrlFor(string $jpegUrl): ?string 123 { 124 if (!$this->isUploadsImage($jpegUrl)) { 125 return null; 126 } 127 $parts = \wp_parse_url($jpegUrl); 128 if (false === $parts || empty($parts['path'])) { 111 return $this->wrapHtmlImages( $content ); 112 } 113 114 private function avifUrlFor( string $jpegUrl ): ?string { 115 if ( ! $this->isUploadsImage( $jpegUrl ) ) { 116 return null; 117 } 118 $parts = \wp_parse_url( $jpegUrl ); 119 if ( false === $parts || empty( $parts['path'] ) ) { 129 120 return null; 130 121 } 131 122 $path = $parts['path']; 132 if (!\preg_match('/\.(jpe?g)$/i', $path)) { 133 return null; 134 } 135 $avifPath = (string) \preg_replace('/\.(jpe?g)$/i', '.avif', $path); 136 $reconstructed = ($parts['scheme'] ?? '') !== '' 137 ? ($parts['scheme'] . '://') 138 : ''; 139 if (!empty($parts['host'])) { 140 $reconstructed .= $parts['host']; 141 } 142 if (!empty($parts['port'])) { 143 $reconstructed .= ':' . $parts['port']; 144 } 145 $reconstructed .= $avifPath; 146 if (!empty($parts['query'])) { 147 $reconstructed .= '?' . $parts['query']; 148 } 149 if (!empty($parts['fragment'])) { 150 $reconstructed .= '#' . $parts['fragment']; 151 } 152 153 $relative = str_replace($this->uploadsInfo['baseurl'] ?? '', '', (string) $reconstructed); 154 $avifLocal = ($this->uploadsInfo['basedir'] ?? '') . $relative; 155 return $this->avifExists($avifLocal) ? $reconstructed : null; 156 } 157 158 private function isUploadsImage(string $src): bool 159 { 123 if ( ! \preg_match( '/\.(jpe?g)$/i', $path ) ) { 124 return null; 125 } 126 $avifPath = (string) \preg_replace( '/\.(jpe?g)$/i', '.avif', $path ); 127 128 // Build local path from path-only (no query/fragment) for file_exists check. 129 $uploadsBasePath = wp_parse_url( $this->uploadsInfo['baseurl'] ?? '', PHP_URL_PATH ) ?: ''; 130 if ( ! str_starts_with( $avifPath, $uploadsBasePath ) ) { 131 return null; 132 } 133 $avifRelative = substr( $avifPath, strlen( $uploadsBasePath ) ); 134 $avifLocal = trailingslashit( $this->uploadsInfo['basedir'] ?? '' ) . ltrim( $avifRelative, '/' ); 135 136 if ( ! $this->avifExists( $avifLocal ) ) { 137 return null; 138 } 139 140 // Reconstruct AVIF URL (preserve query string for cache-busting if present). 141 $avifUrl = ( $parts['scheme'] ?? '' ) !== '' ? ( $parts['scheme'] . '://' ) : ''; 142 $avifUrl .= $parts['host'] ?? ''; 143 $avifUrl .= ! empty( $parts['port'] ) ? ( ':' . $parts['port'] ) : ''; 144 $avifUrl .= $avifPath; 145 if ( ! empty( $parts['query'] ) ) { 146 $avifUrl .= '?' . $parts['query']; 147 } 148 return $avifUrl; 149 } 150 151 private function isUploadsImage( string $src ): bool { 160 152 $uploadsUrl = $this->uploadsInfo['baseurl'] ?? ''; 161 return '' !== $uploadsUrl && str_starts_with($src, $uploadsUrl); 162 } 163 164 private function avifExists(string $filePath): bool 165 { 153 return '' !== $uploadsUrl && str_starts_with( $src, $uploadsUrl ); 154 } 155 156 private function avifExists( string $filePath ): bool { 166 157 // Only cache positive (true) results. Negative results should always be re-checked 167 158 // because files may be converted after the cache was populated. 168 if ( isset($this->fileCache[$filePath]) && true === $this->fileCache[$filePath]) {159 if ( isset( $this->fileCache[ $filePath ] ) && true === $this->fileCache[ $filePath ] ) { 169 160 return true; 170 161 } 171 $exists = file_exists( $filePath);172 if ( $exists) {173 $this->fileCache[ $filePath] = true;162 $exists = file_exists( $filePath ); 163 if ( $exists ) { 164 $this->fileCache[ $filePath ] = true; 174 165 } 175 166 return $exists; 176 167 } 177 168 178 private function convertSrcsetToAvif(string $srcset): string 179 { 180 $parts = array_map('trim', explode(',', $srcset)); 181 $out = array(); 182 foreach ($parts as $part) { 183 if ('' === $part) { 169 private function convertSrcsetToAvif( string $srcset ): string { 170 $parts = array_map( 'trim', explode( ',', $srcset ) ); 171 $out = array(); 172 foreach ( $parts as $part ) { 173 if ( '' === $part ) { 184 174 continue; 185 175 } 186 $pieces = preg_split('/\s+/', trim($part), 2);187 $url = $pieces[0];176 $pieces = preg_split( '/\s+/', trim( $part ), 2 ); 177 $url = $pieces[0]; 188 178 $descriptor = $pieces[1] ?? ''; 189 $avifUrl = $this->avifUrlFor($url); 190 if ($avifUrl) { 191 $out[] = trim($avifUrl . ' ' . $descriptor); 192 } 193 } 194 return implode(', ', $out); 195 } 196 197 private function pictureMarkup(string $originalHtml, ?string $avifSrc, string $avifSrcset = '', string $sizes = '', ?string $thumbhash = null): string 198 { 199 if ((!$avifSrc || '' === $avifSrc) && (!$thumbhash || '' === $thumbhash)) { 179 $avifUrl = $this->avifUrlFor( $url ); 180 if ( $avifUrl ) { 181 $out[] = trim( $avifUrl . ' ' . $descriptor ); 182 } 183 } 184 return implode( ', ', $out ); 185 } 186 187 private function pictureMarkup( string $originalHtml, ?string $avifSrc, string $avifSrcset = '', string $sizes = '', ?string $thumbhash = null ): string { 188 if ( ( ! $avifSrc || '' === $avifSrc ) && ( ! $thumbhash || '' === $thumbhash ) ) { 200 189 return $originalHtml; 201 190 } 202 $srcset = '' !== $avifSrcset ? $avifSrcset : $avifSrc;203 $sizesAttr = '' !== $sizes ? sprintf( ' sizes="%s"', \esc_attr($sizes)) : '';191 $srcset = '' !== $avifSrcset ? $avifSrcset : $avifSrc; 192 $sizesAttr = '' !== $sizes ? sprintf( ' sizes="%s"', \esc_attr( $sizes ) ) : ''; 204 193 205 194 // Add ThumbHash data attribute to img tag if available. 206 195 $imgHtml = $originalHtml; 207 if ( $thumbhash !== null && $thumbhash !== '') {196 if ( $thumbhash !== null && $thumbhash !== '' ) { 208 197 $imgHtml = preg_replace( 209 198 '/<img\s/', 210 '<img data-thumbhash="' . \esc_attr( $thumbhash) . '" ',199 '<img data-thumbhash="' . \esc_attr( $thumbhash ) . '" ', 211 200 $originalHtml, 212 201 1 213 202 ); 214 if ( $imgHtml === null) {203 if ( $imgHtml === null ) { 215 204 $imgHtml = $originalHtml; 216 205 } 217 206 } 218 207 219 if ( !$avifSrc || '' === $avifSrc) {208 if ( ! $avifSrc || '' === $avifSrc ) { 220 209 return $imgHtml; 221 210 } 222 211 223 212 // Only wrap in <picture> if AVIF serving is enabled. 224 $avifEnabled = (bool) \get_option( 'aviflosu_enable_support', true);225 if ( !$avifEnabled) {213 $avifEnabled = (bool) \get_option( 'aviflosu_enable_support', true ); 214 if ( ! $avifEnabled ) { 226 215 return $imgHtml; 227 216 } 228 217 229 return sprintf('<picture><source type="image/avif" srcset="%s"%s>%s</picture>', \esc_attr($srcset), $sizesAttr, $imgHtml); 230 } 231 232 private function isInsidePicture(\DOMNode $node): bool 233 { 218 return sprintf( '<picture><source type="image/avif" srcset="%s"%s>%s</picture>', \esc_attr( $srcset ), $sizesAttr, $imgHtml ); 219 } 220 221 private function isInsidePicture( \DOMNode $node ): bool { 234 222 $parent = $node->parentNode; 235 while ( $parent) {236 if ( $parent instanceof \DOMElement && 'picture' === strtolower($parent->nodeName)) {223 while ( $parent ) { 224 if ( $parent instanceof \DOMElement && 'picture' === strtolower( $parent->nodeName ) ) { 237 225 return true; 238 226 } … … 242 230 } 243 231 244 private function wrapImgNodeToPicture(\DOMDocument $dom, \DOMElement $img, string $avifSrcset, string $sizes, ?string $thumbhash = null): void 245 { 232 private function wrapImgNodeToPicture( \DOMDocument $dom, \DOMElement $img, string $avifSrcset, string $sizes, ?string $thumbhash = null ): void { 246 233 // Add ThumbHash data attribute if available 247 if ( $thumbhash !== null && $thumbhash !== '') {248 $img->setAttribute( 'data-thumbhash', $thumbhash);249 } 250 251 if ( '' === $avifSrcset) {234 if ( $thumbhash !== null && $thumbhash !== '' ) { 235 $img->setAttribute( 'data-thumbhash', $thumbhash ); 236 } 237 238 if ( '' === $avifSrcset ) { 252 239 return; 253 240 } 254 241 255 242 // Only wrap in <picture> if AVIF serving is enabled. 256 $avifEnabled = (bool) \get_option( 'aviflosu_enable_support', true);257 if ( !$avifEnabled) {243 $avifEnabled = (bool) \get_option( 'aviflosu_enable_support', true ); 244 if ( ! $avifEnabled ) { 258 245 return; 259 246 } 260 247 261 $picture = $dom->createElement('picture'); 262 $source = $dom->createElement('source'); 263 $source->setAttribute('type', 'image/avif'); 264 $source->setAttribute('srcset', $avifSrcset); 265 if ('' !== $sizes) { 266 $source->setAttribute('sizes', $sizes); 267 } 268 $picture->appendChild($source); 269 $img->parentNode?->replaceChild($picture, $img); 270 $picture->appendChild($img); 271 } 272 273 private function wrapHtmlImages(string $htmlInput): string 274 { 248 $picture = $dom->createElement( 'picture' ); 249 $source = $dom->createElement( 'source' ); 250 $source->setAttribute( 'type', 'image/avif' ); 251 $source->setAttribute( 'srcset', $avifSrcset ); 252 if ( '' !== $sizes ) { 253 $source->setAttribute( 'sizes', $sizes ); 254 } 255 $picture->appendChild( $source ); 256 $img->parentNode?->replaceChild( $picture, $img ); 257 $picture->appendChild( $img ); 258 } 259 260 private function wrapHtmlImages( string $htmlInput ): string { 275 261 $html = '<?xml encoding="utf-8" ?>' . $htmlInput; 276 $dom = new \DOMDocument();277 \libxml_use_internal_errors( true);278 if ( !$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD)) {262 $dom = new \DOMDocument(); 263 \libxml_use_internal_errors( true ); 264 if ( ! $dom->loadHTML( $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ) ) { 279 265 \libxml_clear_errors(); 280 266 return $htmlInput; … … 282 268 \libxml_clear_errors(); 283 269 284 $imgs = $dom->getElementsByTagName('img');270 $imgs = $dom->getElementsByTagName( 'img' ); 285 271 $toProcess = array(); 286 foreach ( $imgs as $img) {272 foreach ( $imgs as $img ) { 287 273 $toProcess[] = $img; 288 274 } 289 foreach ( $toProcess as $img) {290 if ( !($img instanceof \DOMElement)) {275 foreach ( $toProcess as $img ) { 276 if ( ! ( $img instanceof \DOMElement ) ) { 291 277 continue; 292 278 } 293 if ( $this->isInsidePicture($img)) {279 if ( $this->isInsidePicture( $img ) ) { 294 280 continue; 295 281 } 296 $src = (string) $img->getAttribute('src');297 $avifUrl = $this->avifUrlFor( $src);282 $src = (string) $img->getAttribute( 'src' ); 283 $avifUrl = $this->avifUrlFor( $src ); 298 284 299 285 // Extract attachment ID from wp-image-{ID} class for ThumbHash lookup 300 286 $thumbhash = null; 301 if ( ThumbHash::isEnabled()) {302 $class = (string) $img->getAttribute( 'class');303 if ( preg_match('/wp-image-(\d+)/', $class, $matches)) {287 if ( ThumbHash::isEnabled() ) { 288 $class = (string) $img->getAttribute( 'class' ); 289 if ( preg_match( '/wp-image-(\d+)/', $class, $matches ) ) { 304 290 $attachmentId = (int) $matches[1]; 305 $thumbhash = ThumbHash::getForAttachment($attachmentId, 'full');291 $thumbhash = ThumbHash::getForAttachment( $attachmentId, 'full' ); 306 292 } 307 293 } 308 294 309 if ( !$avifUrl && !$thumbhash) {295 if ( ! $avifUrl && ! $thumbhash ) { 310 296 continue; 311 297 } … … 313 299 // Check if the image is wrapped in a link to a JPEG that also has an AVIF version. 314 300 $parent = $img->parentNode; 315 if ( $parent instanceof \DOMElement && strtolower($parent->nodeName) === 'a') {316 $href = (string) $parent->getAttribute( 'href');301 if ( $parent instanceof \DOMElement && strtolower( $parent->nodeName ) === 'a' ) { 302 $href = (string) $parent->getAttribute( 'href' ); 317 303 // Only process if href looks like a JPEG 318 if ( preg_match('/\.(jpe?g)$/i', $href)) {319 $avifHref = $this->avifUrlFor( $href);320 if ( $avifHref) {321 $parent->setAttribute( 'href', $avifHref);304 if ( preg_match( '/\.(jpe?g)$/i', $href ) ) { 305 $avifHref = $this->avifUrlFor( $href ); 306 if ( $avifHref ) { 307 $parent->setAttribute( 'href', $avifHref ); 322 308 } 323 309 } 324 310 } 325 311 326 $srcset = (string) $img->getAttribute('srcset'); 327 $sizes = (string) $img->getAttribute('sizes'); 328 $avifSrcset = ('' !== $srcset && $avifUrl) ? $this->convertSrcsetToAvif($srcset) : ($avifUrl ?: ''); 329 330 $this->wrapImgNodeToPicture($dom, $img, $avifSrcset, $sizes, $thumbhash); 312 $srcset = (string) $img->getAttribute( 'srcset' ); 313 $sizes = (string) $img->getAttribute( 'sizes' ); 314 $avifSrcset = ( '' !== $srcset ) ? $this->convertSrcsetToAvif( $srcset ) : ( $avifUrl ?: '' ); 315 316 // Ensure single AVIF candidates have width descriptors for responsive selection. 317 if ( '' === $avifSrcset && $avifUrl ) { 318 $w = (int) $img->getAttribute( 'width' ); 319 $avifSrcset = $w > 0 ? ( $avifUrl . ' ' . $w . 'w' ) : $avifUrl; 320 } 321 322 $this->wrapImgNodeToPicture( $dom, $img, $avifSrcset, $sizes, $thumbhash ); 331 323 } 332 324 333 325 // Cleanup: Remove the XML declaration node we added to force UTF-8 334 while ( $dom->firstChild instanceof \DOMProcessingInstruction && $dom->firstChild->nodeName === 'xml') {335 $dom->removeChild( $dom->firstChild);326 while ( $dom->firstChild instanceof \DOMProcessingInstruction && $dom->firstChild->nodeName === 'xml' ) { 327 $dom->removeChild( $dom->firstChild ); 336 328 } 337 329 338 330 $out = $dom->saveHTML(); 339 return \is_string($out) && $out !== '' ? $out : $htmlInput; 340 } 341 342 public function saveCache(): void 343 { 331 return \is_string( $out ) && $out !== '' ? $out : $htmlInput; 332 } 333 334 public function saveCache(): void { 344 335 // Only save positive (true) entries - filter out any false entries that might have snuck in. 345 $positiveOnly = array_filter( $this->fileCache, fn($v) => true === $v);346 set_transient( 'aviflosu_file_cache', $positiveOnly, (int) get_option('aviflosu_cache_duration', 3600));336 $positiveOnly = array_filter( $this->fileCache, fn( $v ) => true === $v ); 337 set_transient( 'aviflosu_file_cache', $positiveOnly, (int) get_option( 'aviflosu_cache_duration', 3600 ) ); 347 338 } 348 339 } -
avif-local-support/tags/0.5.21/readme.txt
r3433195 r3444709 4 4 Requires at least: 6.8 5 5 Tested up to: 6.9 6 Stable tag: 0.5.2 06 Stable tag: 0.5.21 7 7 Requires PHP: 8.3 8 8 License: GPLv2 or later … … 198 198 199 199 == Changelog == 200 201 = 0.5.21 = 202 203 - Enhancement: ThumbHash input increased to 100px for richer LQIP placeholders. 204 - Fix: AVIF srcset now correctly lists all responsive sizes by running after WordPress adds srcset attributes. 205 - Fix: Single AVIF images now include width descriptors for proper responsive selection. 206 - Refactor: Removed redundant `render_block` filter; unified image processing in `the_content` filter. 200 207 201 208 = 0.5.20 = -
avif-local-support/trunk/avif-local-support.php
r3433195 r3444709 7 7 * Plugin URI: https://github.com/ddegner/avif-local-support 8 8 * Description: High-quality AVIF image conversion for WordPress — local, quality-first. 9 * Version: 0.5.2 09 * Version: 0.5.21 10 10 * Author: ddegner 11 11 * Author URI: https://www.daviddegner.com … … 22 22 23 23 // Define constants 24 \define('AVIFLOSU_VERSION', '0.5.2 0');24 \define('AVIFLOSU_VERSION', '0.5.21'); 25 25 \define('AVIFLOSU_PLUGIN_FILE', __FILE__); 26 26 \define('AVIFLOSU_PLUGIN_DIR', plugin_dir_path(__FILE__)); -
avif-local-support/trunk/includes/ThumbHash.php
r3432335 r3444709 8 8 9 9 // Prevent direct access. 10 \defined( 'ABSPATH') || exit;10 \defined( 'ABSPATH' ) || exit; 11 11 12 12 /** … … 16 16 * client-side to smooth placeholders while full images load. 17 17 */ 18 final class ThumbHash 19 { 18 final class ThumbHash { 19 20 20 21 21 … … 27 27 /** 28 28 * Maximum dimension for thumbnail before hashing. 29 * Fixed at 32px to match ThumbHash decoder output resolution. 30 */ 31 private const MAX_DIMENSION = 32; 29 * Set to 100px (ThumbHash maximum) to capture more detail in the DCT encoding. 30 * The decoder outputs 32px, but larger input = more frequency data = richer placeholders. 31 */ 32 private const MAX_DIMENSION = 100; 32 33 33 34 /** … … 35 36 * Fixed at 32px to match the decoder output. 36 37 */ 37 public static function getMaxDimension(): int 38 { 38 public static function getMaxDimension(): int { 39 39 return self::MAX_DIMENSION; 40 40 } … … 43 43 * Check if ThumbHash feature is enabled. 44 44 */ 45 public static function isEnabled(): bool 46 { 47 return (bool) \get_option('aviflosu_thumbhash_enabled', false); 45 public static function isEnabled(): bool { 46 return (bool) \get_option( 'aviflosu_thumbhash_enabled', false ); 48 47 } 49 48 … … 53 52 * @return bool True if the library class exists, false otherwise. 54 53 */ 55 public static function isLibraryAvailable(): bool 56 { 57 return class_exists('Thumbhash\Thumbhash'); 54 public static function isLibraryAvailable(): bool { 55 return class_exists( 'Thumbhash\Thumbhash' ); 58 56 } 59 57 … … 64 62 * @return string|null Base64-encoded ThumbHash or null on failure. 65 63 */ 66 public static function generate(string $imagePath): ?string 67 { 64 public static function generate( string $imagePath ): ?string { 68 65 // Check if the ThumbHash library is available 69 if ( !self::isLibraryAvailable()) {66 if ( ! self::isLibraryAvailable() ) { 70 67 self::$lastError = 'ThumbHash library not found. Please run "composer install" in the plugin directory to install dependencies.'; 71 if ( class_exists(Logger::class)) {72 ( new Logger())->addLog(68 if ( class_exists( Logger::class ) ) { 69 ( new Logger() )->addLog( 73 70 'error', 74 71 'ThumbHash library not available', 75 72 array( 76 'path' => $imagePath,73 'path' => $imagePath, 77 74 'error' => 'Thumbhash\Thumbhash class not found. Composer dependencies may not be installed.', 78 75 ) … … 82 79 } 83 80 84 if ( !file_exists($imagePath) || !is_readable($imagePath)) {81 if ( ! file_exists( $imagePath ) || ! is_readable( $imagePath ) ) { 85 82 self::$lastError = "File not found or unreadable: $imagePath"; 86 if ( class_exists(Logger::class)) {87 ( new Logger())->addLog('error', 'ThumbHash failed: File not found', array('path' => $imagePath));83 if ( class_exists( Logger::class ) ) { 84 ( new Logger() )->addLog( 'error', 'ThumbHash failed: File not found', array( 'path' => $imagePath ) ); 88 85 } 89 86 return null; … … 92 89 try { 93 90 // Try Imagick first (better alpha support) 94 if ( extension_loaded('imagick') && class_exists(\Imagick::class)) {95 return self::generateWithImagick( $imagePath);91 if ( extension_loaded( 'imagick' ) && class_exists( \Imagick::class ) ) { 92 return self::generateWithImagick( $imagePath ); 96 93 } 97 94 98 95 // Fall back to GD 99 if ( extension_loaded('gd')) {100 return self::generateWithGd( $imagePath);96 if ( extension_loaded( 'gd' ) ) { 97 return self::generateWithGd( $imagePath ); 101 98 } 102 99 103 100 self::$lastError = 'No supported image library (Imagick or GD) found.'; 104 101 return null; 105 } catch ( \Throwable $e) {102 } catch ( \Throwable $e ) { 106 103 // Log error but don't block conversion 107 104 self::$lastError = $e->getMessage(); 108 105 109 if ( defined('WP_DEBUG') && WP_DEBUG) {106 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { 110 107 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log 111 error_log( 'ThumbHash generation failed for ' . $imagePath . ': ' . $e->getMessage());112 } 113 114 if ( class_exists(Logger::class)) {115 ( new Logger())->addLog(108 error_log( 'ThumbHash generation failed for ' . $imagePath . ': ' . $e->getMessage() ); 109 } 110 111 if ( class_exists( Logger::class ) ) { 112 ( new Logger() )->addLog( 116 113 'error', 117 114 'ThumbHash generation exception', 118 115 array( 119 'path' => $imagePath,116 'path' => $imagePath, 120 117 'error' => $e->getMessage(), 121 118 ) … … 135 132 * Get the last error that occurred during generation. 136 133 */ 137 public static function getLastError(): ?string 138 { 134 public static function getLastError(): ?string { 139 135 return self::$lastError; 140 136 } … … 143 139 * Generate ThumbHash using Imagick. 144 140 */ 145 private static function generateWithImagick(string $imagePath): ?string 146 { 141 private static function generateWithImagick( string $imagePath ): ?string { 147 142 $imagick = new \Imagick(); 148 143 // Optimization: Hint to libjpeg to load a smaller version (downscale) to save memory. … … 150 145 // sufficient source data for smooth downscaling while still significantly reducing memory for large JPEGs. 151 146 try { 152 $imagick->setOption( 'jpeg:size', '200x200');153 } catch ( \Throwable $e) {147 $imagick->setOption( 'jpeg:size', '200x200' ); 148 } catch ( \Throwable $e ) { 154 149 // Ignore if setOption fails (e.g. older ImageMagick versions) 155 150 } 156 $imagick->readImage( $imagePath);151 $imagick->readImage( $imagePath ); 157 152 158 153 // Get original dimensions 159 $width = $imagick->getImageWidth();154 $width = $imagick->getImageWidth(); 160 155 $height = $imagick->getImageHeight(); 161 156 162 157 // Calculate thumbnail dimensions maintaining aspect ratio 163 if ( $width > self::getMaxDimension() || $height > self::getMaxDimension()) {164 if ( $width >= $height) {165 $newWidth = self::getMaxDimension();166 $newHeight = (int) round( $height * (self::getMaxDimension() / $width));158 if ( $width > self::getMaxDimension() || $height > self::getMaxDimension() ) { 159 if ( $width >= $height ) { 160 $newWidth = self::getMaxDimension(); 161 $newHeight = (int) round( $height * ( self::getMaxDimension() / $width ) ); 167 162 } else { 168 163 $newHeight = self::getMaxDimension(); 169 $newWidth = (int) round($width * (self::getMaxDimension() / $height));164 $newWidth = (int) round( $width * ( self::getMaxDimension() / $height ) ); 170 165 } 171 166 // Ensure minimum of 1px 172 $newWidth = max(1, $newWidth);173 $newHeight = max( 1, $newHeight);174 $imagick->thumbnailImage( $newWidth, $newHeight);167 $newWidth = max( 1, $newWidth ); 168 $newHeight = max( 1, $newHeight ); 169 $imagick->thumbnailImage( $newWidth, $newHeight ); 175 170 } else { 176 $newWidth = $width; 171 $newWidth = $width; 172 $newHeight = $height; 173 } 174 175 // Extract RGBA pixels 176 $pixels = array(); 177 $iterator = $imagick->getPixelIterator(); 178 179 foreach ( $iterator as $row ) { 180 foreach ( $row as $pixel ) { 181 /** @var \ImagickPixel $pixel */ 182 // Use getColorValue() for compatibility across Imagick versions 183 $pixels[] = (int) round( $pixel->getColorValue( \Imagick::COLOR_RED ) * 255 ); 184 $pixels[] = (int) round( $pixel->getColorValue( \Imagick::COLOR_GREEN ) * 255 ); 185 $pixels[] = (int) round( $pixel->getColorValue( \Imagick::COLOR_BLUE ) * 255 ); 186 $pixels[] = (int) round( $pixel->getColorValue( \Imagick::COLOR_ALPHA ) * 255 ); 187 } 188 $iterator->syncIterator(); 189 } 190 191 $imagick->destroy(); 192 193 // Generate hash 194 $hash = ThumbhashLib::RGBAToHash( $newWidth, $newHeight, $pixels ); 195 196 return ThumbhashLib::convertHashToString( $hash ); 197 } 198 199 /** 200 * Generate ThumbHash using GD. 201 */ 202 private static function generateWithGd( string $imagePath ): ?string { 203 $imageInfo = @getimagesize( $imagePath ); 204 if ( ! $imageInfo ) { 205 return null; 206 } 207 208 $mimeType = $imageInfo['mime'] ?? ''; 209 $image = match ( $mimeType ) { 210 'image/jpeg', 'image/jpg' => @imagecreatefromjpeg( $imagePath ), 211 'image/png' => @imagecreatefrompng( $imagePath ), 212 'image/gif' => @imagecreatefromgif( $imagePath ), 213 'image/webp' => @imagecreatefromwebp( $imagePath ), 214 default => false, 215 }; 216 217 if ( ! $image ) { 218 return null; 219 } 220 221 $width = imagesx( $image ); 222 $height = imagesy( $image ); 223 224 // Calculate thumbnail dimensions maintaining aspect ratio 225 if ( $width > self::getMaxDimension() || $height > self::getMaxDimension() ) { 226 if ( $width >= $height ) { 227 $newWidth = self::getMaxDimension(); 228 $newHeight = (int) round( $height * ( self::getMaxDimension() / $width ) ); 229 } else { 230 $newHeight = self::getMaxDimension(); 231 $newWidth = (int) round( $width * ( self::getMaxDimension() / $height ) ); 232 } 233 $newWidth = max( 1, $newWidth ); 234 $newHeight = max( 1, $newHeight ); 235 236 $resized = imagecreatetruecolor( $newWidth, $newHeight ); 237 if ( ! $resized ) { 238 return null; 239 } 240 241 // Preserve alpha channel 242 imagealphablending( $resized, false ); 243 imagesavealpha( $resized, true ); 244 245 imagecopyresampled( $resized, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height ); 246 $image = $resized; 247 } else { 248 $newWidth = $width; 177 249 $newHeight = $height; 178 250 } … … 180 252 // Extract RGBA pixels 181 253 $pixels = array(); 182 $iterator = $imagick->getPixelIterator(); 183 184 foreach ($iterator as $row) { 185 foreach ($row as $pixel) { 186 /** @var \ImagickPixel $pixel */ 187 // Use getColorValue() for compatibility across Imagick versions 188 $pixels[] = (int) round($pixel->getColorValue(\Imagick::COLOR_RED) * 255); 189 $pixels[] = (int) round($pixel->getColorValue(\Imagick::COLOR_GREEN) * 255); 190 $pixels[] = (int) round($pixel->getColorValue(\Imagick::COLOR_BLUE) * 255); 191 $pixels[] = (int) round($pixel->getColorValue(\Imagick::COLOR_ALPHA) * 255); 192 } 193 $iterator->syncIterator(); 194 } 195 196 $imagick->destroy(); 197 198 // Generate hash 199 $hash = ThumbhashLib::RGBAToHash($newWidth, $newHeight, $pixels); 200 201 return ThumbhashLib::convertHashToString($hash); 202 } 203 204 /** 205 * Generate ThumbHash using GD. 206 */ 207 private static function generateWithGd(string $imagePath): ?string 208 { 209 $imageInfo = @getimagesize($imagePath); 210 if (!$imageInfo) { 211 return null; 212 } 213 214 $mimeType = $imageInfo['mime'] ?? ''; 215 $image = match ($mimeType) { 216 'image/jpeg', 'image/jpg' => @imagecreatefromjpeg($imagePath), 217 'image/png' => @imagecreatefrompng($imagePath), 218 'image/gif' => @imagecreatefromgif($imagePath), 219 'image/webp' => @imagecreatefromwebp($imagePath), 220 default => false, 221 }; 222 223 if (!$image) { 224 return null; 225 } 226 227 $width = imagesx($image); 228 $height = imagesy($image); 229 230 // Calculate thumbnail dimensions maintaining aspect ratio 231 if ($width > self::getMaxDimension() || $height > self::getMaxDimension()) { 232 if ($width >= $height) { 233 $newWidth = self::getMaxDimension(); 234 $newHeight = (int) round($height * (self::getMaxDimension() / $width)); 235 } else { 236 $newHeight = self::getMaxDimension(); 237 $newWidth = (int) round($width * (self::getMaxDimension() / $height)); 238 } 239 $newWidth = max(1, $newWidth); 240 $newHeight = max(1, $newHeight); 241 242 $resized = imagecreatetruecolor($newWidth, $newHeight); 243 if (!$resized) { 244 return null; 245 } 246 247 // Preserve alpha channel 248 imagealphablending($resized, false); 249 imagesavealpha($resized, true); 250 251 imagecopyresampled($resized, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height); 252 $image = $resized; 253 } else { 254 $newWidth = $width; 255 $newHeight = $height; 256 } 257 258 // Extract RGBA pixels 259 $pixels = array(); 260 for ($y = 0; $y < $newHeight; $y++) { 261 for ($x = 0; $x < $newWidth; $x++) { 262 $rgba = imagecolorat($image, $x, $y); 263 $pixels[] = ($rgba >> 16) & 0xFF; // R 264 $pixels[] = ($rgba >> 8) & 0xFF; // G 254 for ( $y = 0; $y < $newHeight; $y++ ) { 255 for ( $x = 0; $x < $newWidth; $x++ ) { 256 $rgba = imagecolorat( $image, $x, $y ); 257 $pixels[] = ( $rgba >> 16 ) & 0xFF; // R 258 $pixels[] = ( $rgba >> 8 ) & 0xFF; // G 265 259 $pixels[] = $rgba & 0xFF; // B 266 260 // GD alpha: 127 = transparent, 0 = opaque (invert for ThumbHash) 267 $alpha = ($rgba >> 24) & 0x7F; 268 $pixels[] = (int) round((127 - $alpha) * (255 / 127)); 269 } 270 } 271 272 261 $alpha = ( $rgba >> 24 ) & 0x7F; 262 $pixels[] = (int) round( ( 127 - $alpha ) * ( 255 / 127 ) ); 263 } 264 } 273 265 274 266 // Generate hash 275 $hash = ThumbhashLib::RGBAToHash( $newWidth, $newHeight, $pixels);276 277 return ThumbhashLib::convertHashToString( $hash);267 $hash = ThumbhashLib::RGBAToHash( $newWidth, $newHeight, $pixels ); 268 269 return ThumbhashLib::convertHashToString( $hash ); 278 270 } 279 271 … … 285 277 * @return string|null Base64-encoded ThumbHash or null if not available. 286 278 */ 287 public static function getForAttachment(int $attachmentId, string $size = 'full'): ?string 288 { 289 if (!self::isEnabled()) { 290 return null; 291 } 292 293 $meta = \get_post_meta($attachmentId, self::META_KEY, true); 294 if (!is_array($meta)) { 295 return null; 296 } 297 298 return $meta[$size] ?? $meta['full'] ?? null; 279 public static function getForAttachment( int $attachmentId, string $size = 'full' ): ?string { 280 if ( ! self::isEnabled() ) { 281 return null; 282 } 283 284 $meta = \get_post_meta( $attachmentId, self::META_KEY, true ); 285 if ( ! is_array( $meta ) ) { 286 return null; 287 } 288 289 return $meta[ $size ] ?? $meta['full'] ?? null; 299 290 } 300 291 … … 305 296 * @return array<string, string>|null Hash array keyed by size name, or null on failure. 306 297 */ 307 public static function generateForAttachment(int $attachmentId): ?array 308 { 309 if (!self::isEnabled()) { 310 return null; 311 } 312 313 return self::doGenerateForAttachment($attachmentId); 298 public static function generateForAttachment( int $attachmentId ): ?array { 299 if ( ! self::isEnabled() ) { 300 return null; 301 } 302 303 return self::doGenerateForAttachment( $attachmentId ); 314 304 } 315 305 … … 319 309 * @param int $attachmentId WordPress attachment ID. 320 310 */ 321 public static function deleteForAttachment(int $attachmentId): void 322 { 323 \delete_post_meta($attachmentId, self::META_KEY); 311 public static function deleteForAttachment( int $attachmentId ): void { 312 \delete_post_meta( $attachmentId, self::META_KEY ); 324 313 } 325 314 … … 328 317 * Used by uninstall.php for cleanup. 329 318 */ 330 public static function getMetaKey(): string 331 { 319 public static function getMetaKey(): string { 332 320 return self::META_KEY; 333 321 } … … 339 327 * @return array{generated: int, skipped: int, failed: int} 340 328 */ 341 public static function generateAll(bool $force = false): array 342 { 329 public static function generateAll( bool $force = false ): array { 343 330 $result = array( 344 331 'generated' => 0, 345 'skipped' => 0,346 'failed' => 0,332 'skipped' => 0, 333 'failed' => 0, 347 334 ); 348 335 349 336 $query = new \WP_Query( 350 337 array( 351 'post_type' => 'attachment',352 'post_mime_type' => array('image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'),353 'post_status' => 'inherit',354 'posts_per_page' => -1,355 'fields' => 'ids',356 'no_found_rows' => true,338 'post_type' => 'attachment', 339 'post_mime_type' => array( 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp' ), 340 'post_status' => 'inherit', 341 'posts_per_page' => -1, 342 'fields' => 'ids', 343 'no_found_rows' => true, 357 344 'update_post_meta_cache' => false, // Avoid potential caching issues 358 345 'update_post_term_cache' => false, … … 360 347 ); 361 348 362 $logger = class_exists( Logger::class) ? new Logger() : null;363 364 foreach ( $query->posts as $attachmentId) {349 $logger = class_exists( Logger::class ) ? new Logger() : null; 350 351 foreach ( $query->posts as $attachmentId ) { 365 352 // Clear object cache for this post to ensure fresh meta data 366 353 // This prevents stale data from persistent object caching (Redis/Memcached) 367 \clean_post_cache( (int) $attachmentId);354 \clean_post_cache( (int) $attachmentId ); 368 355 369 356 // Skip if already has valid ThumbHash (unless forcing regeneration) 370 if ( !$force) {371 $existing = \get_post_meta( $attachmentId, self::META_KEY, true);357 if ( ! $force ) { 358 $existing = \get_post_meta( $attachmentId, self::META_KEY, true ); 372 359 // Verify it's a valid ThumbHash array with at least a 'full' entry 373 if ( is_array($existing) && isset($existing['full']) && is_string($existing['full']) && strlen($existing['full']) > 10) {360 if ( is_array( $existing ) && isset( $existing['full'] ) && is_string( $existing['full'] ) && strlen( $existing['full'] ) > 10 ) { 374 361 ++$result['skipped']; 375 362 // Log individual skip 376 if ( $logger) {363 if ( $logger ) { 377 364 $logger->addLog( 378 365 'info', 379 sprintf( 'LQIP skipped for attachment ID %d (already exists)', $attachmentId),380 array( 'attachment_id' => $attachmentId)366 sprintf( 'LQIP skipped for attachment ID %d (already exists)', $attachmentId ), 367 array( 'attachment_id' => $attachmentId ) 381 368 ); 382 369 } … … 386 373 387 374 // Use private helper to generate (bypasses isEnabled check for bulk operations) 388 $hashes = self::doGenerateForAttachment( (int) $attachmentId);389 390 if ( is_array($hashes) && !empty($hashes['full'])) {375 $hashes = self::doGenerateForAttachment( (int) $attachmentId ); 376 377 if ( is_array( $hashes ) && ! empty( $hashes['full'] ) ) { 391 378 ++$result['generated']; 392 379 // Log individual success 393 if ( $logger) {380 if ( $logger ) { 394 381 $logger->addLog( 395 382 'success', 396 sprintf( 'LQIP generated for attachment ID %d', $attachmentId),383 sprintf( 'LQIP generated for attachment ID %d', $attachmentId ), 397 384 array( 398 'attachment_id' => $attachmentId,399 'sizes_generated' => count( $hashes),385 'attachment_id' => $attachmentId, 386 'sizes_generated' => count( $hashes ), 400 387 ) 401 388 ); … … 404 391 ++$result['failed']; 405 392 // Capture the last error for debugging 406 if ( !isset($result['last_error']) && self::$lastError) {393 if ( ! isset( $result['last_error'] ) && self::$lastError ) { 407 394 $result['last_error'] = self::$lastError; 408 395 } 409 396 // Log individual failure 410 if ( $logger) {397 if ( $logger ) { 411 398 $logger->addLog( 412 399 'error', 413 sprintf( 'LQIP generation failed for attachment ID %d', $attachmentId),400 sprintf( 'LQIP generation failed for attachment ID %d', $attachmentId ), 414 401 array( 415 402 'attachment_id' => $attachmentId, 416 'error' => self::$lastError ?? 'Unknown error',403 'error' => self::$lastError ?? 'Unknown error', 417 404 ) 418 405 ); … … 422 409 423 410 // Log summary of bulk operation 424 if ( $logger && ($result['generated'] > 0 || $result['failed'] > 0 || $result['skipped'] > 0)) {411 if ( $logger && ( $result['generated'] > 0 || $result['failed'] > 0 || $result['skipped'] > 0 ) ) { 425 412 $logger->addLog( 426 413 $result['failed'] > 0 ? 'warning' : 'success', … … 433 420 array( 434 421 'generated' => $result['generated'], 435 'skipped' => $result['skipped'],436 'failed' => $result['failed'],422 'skipped' => $result['skipped'], 423 'failed' => $result['failed'], 437 424 ) 438 425 ); … … 449 436 * @return array<string, string>|null Hash array or null on failure. 450 437 */ 451 private static function doGenerateForAttachment(int $attachmentId): ?array 452 { 453 $metadata = \wp_get_attachment_metadata($attachmentId); 454 if (!is_array($metadata) || empty($metadata['file'])) { 438 private static function doGenerateForAttachment( int $attachmentId ): ?array { 439 $metadata = \wp_get_attachment_metadata( $attachmentId ); 440 if ( ! is_array( $metadata ) || empty( $metadata['file'] ) ) { 455 441 self::$lastError = "Invalid or missing metadata for attachment $attachmentId"; 456 442 return null; … … 458 444 459 445 $uploadDir = \wp_upload_dir(); 460 $baseDir = $uploadDir['basedir'] ?? '';461 if ( !$baseDir) {446 $baseDir = $uploadDir['basedir'] ?? ''; 447 if ( ! $baseDir ) { 462 448 self::$lastError = 'Upload basedir not found.'; 463 449 return null; 464 450 } 465 451 466 $hashes = array();467 $file = $metadata['file'];468 $fileDir = dirname( $file);452 $hashes = array(); 453 $file = $metadata['file']; 454 $fileDir = dirname( $file ); 469 455 470 456 // Generate for original/full image 471 457 $fullPath = $baseDir . '/' . $file; 472 458 // Some setups have 'file' as absolute path (rare but possible in offload plugins) 473 if ( !file_exists($fullPath)) {474 if ( file_exists($file)) {459 if ( ! file_exists( $fullPath ) ) { 460 if ( file_exists( $file ) ) { 475 461 $fullPath = $file; 476 462 } else { 477 463 self::$lastError = "File not found: $fullPath"; 478 if ( class_exists(Logger::class)) {479 ( new Logger())->addLog('warning', "ThumbHash skipped: Source file missing for ID $attachmentId", array('path' => $fullPath));464 if ( class_exists( Logger::class ) ) { 465 ( new Logger() )->addLog( 'warning', "ThumbHash skipped: Source file missing for ID $attachmentId", array( 'path' => $fullPath ) ); 480 466 } 481 467 } 482 468 } 483 469 484 if ( file_exists($fullPath)) {485 $hash = self::generate( $fullPath);486 if ( $hash) {470 if ( file_exists( $fullPath ) ) { 471 $hash = self::generate( $fullPath ); 472 if ( $hash ) { 487 473 $hashes['full'] = $hash; 488 474 } … … 491 477 // Generate for each registered size 492 478 $sizes = $metadata['sizes'] ?? array(); 493 foreach ( $sizes as $sizeName => $sizeData) {479 foreach ( $sizes as $sizeName => $sizeData ) { 494 480 $sizeFile = $sizeData['file'] ?? ''; 495 if ( !$sizeFile) {481 if ( ! $sizeFile ) { 496 482 continue; 497 483 } 498 484 499 485 $sizePath = $baseDir . '/' . $fileDir . '/' . $sizeFile; 500 if ( file_exists($sizePath)) {501 $hash = self::generate( $sizePath);502 if ( $hash) {503 $hashes[ $sizeName] = $hash;486 if ( file_exists( $sizePath ) ) { 487 $hash = self::generate( $sizePath ); 488 if ( $hash ) { 489 $hashes[ $sizeName ] = $hash; 504 490 } 505 491 } 506 492 } 507 493 508 if ( !empty($hashes)) {509 \update_post_meta( $attachmentId, self::META_KEY, $hashes);494 if ( ! empty( $hashes ) ) { 495 \update_post_meta( $attachmentId, self::META_KEY, $hashes ); 510 496 return $hashes; 511 497 } … … 519 505 * @return int Number of meta entries deleted. 520 506 */ 521 public static function deleteAll(): int 522 { 507 public static function deleteAll(): int { 523 508 global $wpdb; 524 509 … … 532 517 ); 533 518 534 if ( empty($postIds)) {519 if ( empty( $postIds ) ) { 535 520 return 0; 536 521 } … … 546 531 547 532 // Clear object cache for affected posts to prevent stale data in get_post_meta 548 foreach ( $postIds as $postId) {549 \clean_post_cache( (int) $postId);533 foreach ( $postIds as $postId ) { 534 \clean_post_cache( (int) $postId ); 550 535 } 551 536 552 537 // Log the deletion 553 if ( class_exists(Logger::class)) {554 ( new Logger())->addLog(538 if ( class_exists( Logger::class ) ) { 539 ( new Logger() )->addLog( 555 540 'info', 556 sprintf( 'LQIP bulk delete: %d entries deleted', $deleted),557 array( 'deleted' => (int) $deleted)541 sprintf( 'LQIP bulk delete: %d entries deleted', $deleted ), 542 array( 'deleted' => (int) $deleted ) 558 543 ); 559 544 } … … 570 555 * @return array{with_hash: int, without_hash: int, total: int} 571 556 */ 572 public static function getStats(): array 573 { 557 public static function getStats(): array { 574 558 global $wpdb; 575 559 … … 590 574 ); 591 575 592 $withHash = 0;576 $withHash = 0; 593 577 $seenPostIds = array(); 594 578 595 foreach ( $rows as $row) {579 foreach ( $rows as $row ) { 596 580 // Skip if we've already counted this post 597 if ( isset($seenPostIds[$row->post_id])) {581 if ( isset( $seenPostIds[ $row->post_id ] ) ) { 598 582 continue; 599 583 } 600 584 601 585 // Validate the structure matches what generateAll() skip logic expects 602 $meta = maybe_unserialize( $row->meta_value);603 if ( is_array($meta) && isset($meta['full']) && is_string($meta['full']) && strlen($meta['full']) > 10) {586 $meta = maybe_unserialize( $row->meta_value ); 587 if ( is_array( $meta ) && isset( $meta['full'] ) && is_string( $meta['full'] ) && strlen( $meta['full'] ) > 10 ) { 604 588 ++$withHash; 605 $seenPostIds[ $row->post_id] = true;589 $seenPostIds[ $row->post_id ] = true; 606 590 } 607 591 } 608 592 609 593 return array( 610 'with_hash' => $withHash,594 'with_hash' => $withHash, 611 595 'without_hash' => $total - $withHash, 612 'total' => $total,596 'total' => $total, 613 597 ); 614 598 } -
avif-local-support/trunk/includes/class-support.php
r3432774 r3444709 6 6 7 7 // Prevent direct access. 8 \defined( 'ABSPATH') || exit;9 10 final class Support 11 { 12 13 14 15 16 private array $fileCache = array();8 \defined( 'ABSPATH' ) || exit; 9 10 final class Support { 11 12 13 14 15 16 private array $fileCache = array(); 17 17 private array $uploadsInfo = array(); 18 18 19 public function init(): void 20 { 19 public function init(): void { 20 21 21 $this->uploadsInfo = \wp_upload_dir(); 22 $this->fileCache = \get_transient('aviflosu_file_cache') ?: array();23 add_filter( 'wp_get_attachment_image', array($this, 'wrapAttachment'), 10, 5);24 add_filter('the_content', array($this, 'wrapContentImages'));25 add_filter( 'post_thumbnail_html', array($this, 'wrapContentImages'));26 add_filter( 'render_block', array($this, 'renderBlock'), 10, 2);27 add_action( 'shutdown', array($this, 'saveCache'));22 $this->fileCache = \get_transient( 'aviflosu_file_cache' ) ?: array(); 23 add_filter( 'wp_get_attachment_image', array( $this, 'wrapAttachment' ), 10, 5 ); 24 // Priority 15 runs AFTER WordPress adds srcset via wp_filter_content_tags at priority 10. 25 add_filter( 'the_content', array( $this, 'wrapContentImages' ), 15 ); 26 add_filter( 'post_thumbnail_html', array( $this, 'wrapContentImages' ), 15 ); 27 add_action( 'shutdown', array( $this, 'saveCache' ) ); 28 28 29 29 // Enqueue ThumbHash decoder if enabled - inline in head for early execution. 30 if ( ThumbHash::isEnabled() && !\is_admin()) {31 add_action( 'wp_head', array($this, 'inlineThumbHashDecoder'), 1);30 if ( ThumbHash::isEnabled() && ! \is_admin() ) { 31 add_action( 'wp_head', array( $this, 'inlineThumbHashDecoder' ), 1 ); 32 32 } 33 33 } … … 37 37 * This ensures placeholders appear before images start loading. 38 38 */ 39 public function inlineThumbHashDecoder(): void 40 { 39 public function inlineThumbHashDecoder(): void { 41 40 $scriptPath = AVIFLOSU_PLUGIN_DIR . 'assets/thumbhash-decoder.min.js'; 42 if ( !file_exists($scriptPath)) {41 if ( ! file_exists( $scriptPath ) ) { 43 42 return; 44 43 } 45 $script = file_get_contents( $scriptPath);46 if ( $script) {44 $script = file_get_contents( $scriptPath ); 45 if ( $script ) { 47 46 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Inline JS from trusted local file. 48 47 echo '<script id="aviflosu-thumbhash-decoder">' . $script . '</script>' . "\n"; … … 50 49 51 50 // Inject CSS for fading if enabled. 52 if ( (bool) get_option('aviflosu_lqip_fade', true)) {51 if ( (bool) get_option( 'aviflosu_lqip_fade', true ) ) { 53 52 // CSS explanation: 54 53 // 1. img[data-thumbhash] starts visible (opacity 1) with a transition. … … 60 59 . 'img.thumbhash-loading[data-thumbhash]{opacity:0;}'; 61 60 // Optionally render placeholders as sharp pixels instead of smooth blur. 62 if ( (bool) get_option('aviflosu_lqip_pixelated', false)) {61 if ( (bool) get_option( 'aviflosu_lqip_pixelated', false ) ) { 63 62 $css .= '.thumbhash-loading,img.thumbhash-loading{image-rendering:pixelated;}'; 64 63 } … … 68 67 } 69 68 70 public function wrapAttachment(string $html, int $attachmentId, $size, bool $icon, array $attr): string 71 { 72 if (str_contains($html, '<picture')) { 69 public function wrapAttachment( string $html, int $attachmentId, $size, bool $icon, array $attr ): string { 70 if ( str_contains( $html, '<picture' ) ) { 73 71 return $html; 74 72 } 75 73 76 $mime = \get_post_mime_type( $attachmentId);77 if ( !\is_string($mime) || !\in_array($mime, array('image/jpeg', 'image/jpg'), true)) {74 $mime = \get_post_mime_type( $attachmentId ); 75 if ( ! \is_string( $mime ) || ! \in_array( $mime, array( 'image/jpeg', 'image/jpg' ), true ) ) { 78 76 return $html; 79 77 } 80 78 81 $imageSrc = \wp_get_attachment_image_src( $attachmentId, $size);82 if ( !$imageSrc || !\is_array($imageSrc) || empty($imageSrc[0])) {79 $imageSrc = \wp_get_attachment_image_src( $attachmentId, $size ); 80 if ( ! $imageSrc || ! \is_array( $imageSrc ) || empty( $imageSrc[0] ) ) { 83 81 return $html; 84 82 } 85 83 86 $avifSrc = $this->avifUrlFor($imageSrc[0]); 87 88 $srcset = \wp_get_attachment_image_srcset($attachmentId, $size); 89 $avifSrcset = ($srcset && $avifSrc) ? $this->convertSrcsetToAvif($srcset) : ''; 90 $sizes = \wp_get_attachment_image_sizes($attachmentId, $size) ?: ''; 84 $avifSrc = $this->avifUrlFor( $imageSrc[0] ); 85 86 $srcset = \wp_get_attachment_image_srcset( $attachmentId, $size ); 87 $avifSrcset = $srcset ? $this->convertSrcsetToAvif( $srcset ) : ''; 88 89 // Ensure single AVIF candidates have width descriptors for responsive selection. 90 if ( '' === $avifSrcset && $avifSrc ) { 91 $w = (int) ( $imageSrc[1] ?? 0 ); 92 $avifSrcset = $w > 0 ? ( $avifSrc . ' ' . $w . 'w' ) : $avifSrc; 93 } 94 95 $sizes = \wp_get_attachment_image_sizes( $attachmentId, $size ) ?: ''; 91 96 92 97 // Get ThumbHash for LQIP if enabled. 93 $sizeName = is_array($size) ? 'full' : (string) $size; 94 $thumbhash = ThumbHash::getForAttachment($attachmentId, $sizeName); 95 96 return $this->pictureMarkup($html, $avifSrc, $avifSrcset, $sizes, $thumbhash); 97 } 98 99 public function wrapContentImages(string $content): string 100 { 101 if (\is_admin() || \wp_doing_ajax() || (\defined('REST_REQUEST') && REST_REQUEST)) { 98 $sizeName = is_array( $size ) ? 'full' : (string) $size; 99 $thumbhash = ThumbHash::getForAttachment( $attachmentId, $sizeName ); 100 101 return $this->pictureMarkup( $html, $avifSrc, $avifSrcset, $sizes, $thumbhash ); 102 } 103 104 public function wrapContentImages( string $content ): string { 105 if ( \is_admin() || \wp_doing_ajax() || ( \defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { 102 106 return $content; 103 107 } 104 if ( !str_contains($content, '<img')) {108 if ( ! str_contains( $content, '<img' ) ) { 105 109 return $content; 106 110 } 107 return $this->wrapHtmlImages($content); 108 } 109 110 public function renderBlock(string $block_content, array $block): string 111 { 112 $name = $block['blockName'] ?? ''; 113 if ('core/image' !== $name && 'core/gallery' !== $name) { 114 return $block_content; 115 } 116 if ('' === $block_content || false === strpos($block_content, '<img')) { 117 return $block_content; 118 } 119 return $this->wrapHtmlImages($block_content); 120 } 121 122 private function avifUrlFor(string $jpegUrl): ?string 123 { 124 if (!$this->isUploadsImage($jpegUrl)) { 125 return null; 126 } 127 $parts = \wp_parse_url($jpegUrl); 128 if (false === $parts || empty($parts['path'])) { 111 return $this->wrapHtmlImages( $content ); 112 } 113 114 private function avifUrlFor( string $jpegUrl ): ?string { 115 if ( ! $this->isUploadsImage( $jpegUrl ) ) { 116 return null; 117 } 118 $parts = \wp_parse_url( $jpegUrl ); 119 if ( false === $parts || empty( $parts['path'] ) ) { 129 120 return null; 130 121 } 131 122 $path = $parts['path']; 132 if (!\preg_match('/\.(jpe?g)$/i', $path)) { 133 return null; 134 } 135 $avifPath = (string) \preg_replace('/\.(jpe?g)$/i', '.avif', $path); 136 $reconstructed = ($parts['scheme'] ?? '') !== '' 137 ? ($parts['scheme'] . '://') 138 : ''; 139 if (!empty($parts['host'])) { 140 $reconstructed .= $parts['host']; 141 } 142 if (!empty($parts['port'])) { 143 $reconstructed .= ':' . $parts['port']; 144 } 145 $reconstructed .= $avifPath; 146 if (!empty($parts['query'])) { 147 $reconstructed .= '?' . $parts['query']; 148 } 149 if (!empty($parts['fragment'])) { 150 $reconstructed .= '#' . $parts['fragment']; 151 } 152 153 $relative = str_replace($this->uploadsInfo['baseurl'] ?? '', '', (string) $reconstructed); 154 $avifLocal = ($this->uploadsInfo['basedir'] ?? '') . $relative; 155 return $this->avifExists($avifLocal) ? $reconstructed : null; 156 } 157 158 private function isUploadsImage(string $src): bool 159 { 123 if ( ! \preg_match( '/\.(jpe?g)$/i', $path ) ) { 124 return null; 125 } 126 $avifPath = (string) \preg_replace( '/\.(jpe?g)$/i', '.avif', $path ); 127 128 // Build local path from path-only (no query/fragment) for file_exists check. 129 $uploadsBasePath = wp_parse_url( $this->uploadsInfo['baseurl'] ?? '', PHP_URL_PATH ) ?: ''; 130 if ( ! str_starts_with( $avifPath, $uploadsBasePath ) ) { 131 return null; 132 } 133 $avifRelative = substr( $avifPath, strlen( $uploadsBasePath ) ); 134 $avifLocal = trailingslashit( $this->uploadsInfo['basedir'] ?? '' ) . ltrim( $avifRelative, '/' ); 135 136 if ( ! $this->avifExists( $avifLocal ) ) { 137 return null; 138 } 139 140 // Reconstruct AVIF URL (preserve query string for cache-busting if present). 141 $avifUrl = ( $parts['scheme'] ?? '' ) !== '' ? ( $parts['scheme'] . '://' ) : ''; 142 $avifUrl .= $parts['host'] ?? ''; 143 $avifUrl .= ! empty( $parts['port'] ) ? ( ':' . $parts['port'] ) : ''; 144 $avifUrl .= $avifPath; 145 if ( ! empty( $parts['query'] ) ) { 146 $avifUrl .= '?' . $parts['query']; 147 } 148 return $avifUrl; 149 } 150 151 private function isUploadsImage( string $src ): bool { 160 152 $uploadsUrl = $this->uploadsInfo['baseurl'] ?? ''; 161 return '' !== $uploadsUrl && str_starts_with($src, $uploadsUrl); 162 } 163 164 private function avifExists(string $filePath): bool 165 { 153 return '' !== $uploadsUrl && str_starts_with( $src, $uploadsUrl ); 154 } 155 156 private function avifExists( string $filePath ): bool { 166 157 // Only cache positive (true) results. Negative results should always be re-checked 167 158 // because files may be converted after the cache was populated. 168 if ( isset($this->fileCache[$filePath]) && true === $this->fileCache[$filePath]) {159 if ( isset( $this->fileCache[ $filePath ] ) && true === $this->fileCache[ $filePath ] ) { 169 160 return true; 170 161 } 171 $exists = file_exists( $filePath);172 if ( $exists) {173 $this->fileCache[ $filePath] = true;162 $exists = file_exists( $filePath ); 163 if ( $exists ) { 164 $this->fileCache[ $filePath ] = true; 174 165 } 175 166 return $exists; 176 167 } 177 168 178 private function convertSrcsetToAvif(string $srcset): string 179 { 180 $parts = array_map('trim', explode(',', $srcset)); 181 $out = array(); 182 foreach ($parts as $part) { 183 if ('' === $part) { 169 private function convertSrcsetToAvif( string $srcset ): string { 170 $parts = array_map( 'trim', explode( ',', $srcset ) ); 171 $out = array(); 172 foreach ( $parts as $part ) { 173 if ( '' === $part ) { 184 174 continue; 185 175 } 186 $pieces = preg_split('/\s+/', trim($part), 2);187 $url = $pieces[0];176 $pieces = preg_split( '/\s+/', trim( $part ), 2 ); 177 $url = $pieces[0]; 188 178 $descriptor = $pieces[1] ?? ''; 189 $avifUrl = $this->avifUrlFor($url); 190 if ($avifUrl) { 191 $out[] = trim($avifUrl . ' ' . $descriptor); 192 } 193 } 194 return implode(', ', $out); 195 } 196 197 private function pictureMarkup(string $originalHtml, ?string $avifSrc, string $avifSrcset = '', string $sizes = '', ?string $thumbhash = null): string 198 { 199 if ((!$avifSrc || '' === $avifSrc) && (!$thumbhash || '' === $thumbhash)) { 179 $avifUrl = $this->avifUrlFor( $url ); 180 if ( $avifUrl ) { 181 $out[] = trim( $avifUrl . ' ' . $descriptor ); 182 } 183 } 184 return implode( ', ', $out ); 185 } 186 187 private function pictureMarkup( string $originalHtml, ?string $avifSrc, string $avifSrcset = '', string $sizes = '', ?string $thumbhash = null ): string { 188 if ( ( ! $avifSrc || '' === $avifSrc ) && ( ! $thumbhash || '' === $thumbhash ) ) { 200 189 return $originalHtml; 201 190 } 202 $srcset = '' !== $avifSrcset ? $avifSrcset : $avifSrc;203 $sizesAttr = '' !== $sizes ? sprintf( ' sizes="%s"', \esc_attr($sizes)) : '';191 $srcset = '' !== $avifSrcset ? $avifSrcset : $avifSrc; 192 $sizesAttr = '' !== $sizes ? sprintf( ' sizes="%s"', \esc_attr( $sizes ) ) : ''; 204 193 205 194 // Add ThumbHash data attribute to img tag if available. 206 195 $imgHtml = $originalHtml; 207 if ( $thumbhash !== null && $thumbhash !== '') {196 if ( $thumbhash !== null && $thumbhash !== '' ) { 208 197 $imgHtml = preg_replace( 209 198 '/<img\s/', 210 '<img data-thumbhash="' . \esc_attr( $thumbhash) . '" ',199 '<img data-thumbhash="' . \esc_attr( $thumbhash ) . '" ', 211 200 $originalHtml, 212 201 1 213 202 ); 214 if ( $imgHtml === null) {203 if ( $imgHtml === null ) { 215 204 $imgHtml = $originalHtml; 216 205 } 217 206 } 218 207 219 if ( !$avifSrc || '' === $avifSrc) {208 if ( ! $avifSrc || '' === $avifSrc ) { 220 209 return $imgHtml; 221 210 } 222 211 223 212 // Only wrap in <picture> if AVIF serving is enabled. 224 $avifEnabled = (bool) \get_option( 'aviflosu_enable_support', true);225 if ( !$avifEnabled) {213 $avifEnabled = (bool) \get_option( 'aviflosu_enable_support', true ); 214 if ( ! $avifEnabled ) { 226 215 return $imgHtml; 227 216 } 228 217 229 return sprintf('<picture><source type="image/avif" srcset="%s"%s>%s</picture>', \esc_attr($srcset), $sizesAttr, $imgHtml); 230 } 231 232 private function isInsidePicture(\DOMNode $node): bool 233 { 218 return sprintf( '<picture><source type="image/avif" srcset="%s"%s>%s</picture>', \esc_attr( $srcset ), $sizesAttr, $imgHtml ); 219 } 220 221 private function isInsidePicture( \DOMNode $node ): bool { 234 222 $parent = $node->parentNode; 235 while ( $parent) {236 if ( $parent instanceof \DOMElement && 'picture' === strtolower($parent->nodeName)) {223 while ( $parent ) { 224 if ( $parent instanceof \DOMElement && 'picture' === strtolower( $parent->nodeName ) ) { 237 225 return true; 238 226 } … … 242 230 } 243 231 244 private function wrapImgNodeToPicture(\DOMDocument $dom, \DOMElement $img, string $avifSrcset, string $sizes, ?string $thumbhash = null): void 245 { 232 private function wrapImgNodeToPicture( \DOMDocument $dom, \DOMElement $img, string $avifSrcset, string $sizes, ?string $thumbhash = null ): void { 246 233 // Add ThumbHash data attribute if available 247 if ( $thumbhash !== null && $thumbhash !== '') {248 $img->setAttribute( 'data-thumbhash', $thumbhash);249 } 250 251 if ( '' === $avifSrcset) {234 if ( $thumbhash !== null && $thumbhash !== '' ) { 235 $img->setAttribute( 'data-thumbhash', $thumbhash ); 236 } 237 238 if ( '' === $avifSrcset ) { 252 239 return; 253 240 } 254 241 255 242 // Only wrap in <picture> if AVIF serving is enabled. 256 $avifEnabled = (bool) \get_option( 'aviflosu_enable_support', true);257 if ( !$avifEnabled) {243 $avifEnabled = (bool) \get_option( 'aviflosu_enable_support', true ); 244 if ( ! $avifEnabled ) { 258 245 return; 259 246 } 260 247 261 $picture = $dom->createElement('picture'); 262 $source = $dom->createElement('source'); 263 $source->setAttribute('type', 'image/avif'); 264 $source->setAttribute('srcset', $avifSrcset); 265 if ('' !== $sizes) { 266 $source->setAttribute('sizes', $sizes); 267 } 268 $picture->appendChild($source); 269 $img->parentNode?->replaceChild($picture, $img); 270 $picture->appendChild($img); 271 } 272 273 private function wrapHtmlImages(string $htmlInput): string 274 { 248 $picture = $dom->createElement( 'picture' ); 249 $source = $dom->createElement( 'source' ); 250 $source->setAttribute( 'type', 'image/avif' ); 251 $source->setAttribute( 'srcset', $avifSrcset ); 252 if ( '' !== $sizes ) { 253 $source->setAttribute( 'sizes', $sizes ); 254 } 255 $picture->appendChild( $source ); 256 $img->parentNode?->replaceChild( $picture, $img ); 257 $picture->appendChild( $img ); 258 } 259 260 private function wrapHtmlImages( string $htmlInput ): string { 275 261 $html = '<?xml encoding="utf-8" ?>' . $htmlInput; 276 $dom = new \DOMDocument();277 \libxml_use_internal_errors( true);278 if ( !$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD)) {262 $dom = new \DOMDocument(); 263 \libxml_use_internal_errors( true ); 264 if ( ! $dom->loadHTML( $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ) ) { 279 265 \libxml_clear_errors(); 280 266 return $htmlInput; … … 282 268 \libxml_clear_errors(); 283 269 284 $imgs = $dom->getElementsByTagName('img');270 $imgs = $dom->getElementsByTagName( 'img' ); 285 271 $toProcess = array(); 286 foreach ( $imgs as $img) {272 foreach ( $imgs as $img ) { 287 273 $toProcess[] = $img; 288 274 } 289 foreach ( $toProcess as $img) {290 if ( !($img instanceof \DOMElement)) {275 foreach ( $toProcess as $img ) { 276 if ( ! ( $img instanceof \DOMElement ) ) { 291 277 continue; 292 278 } 293 if ( $this->isInsidePicture($img)) {279 if ( $this->isInsidePicture( $img ) ) { 294 280 continue; 295 281 } 296 $src = (string) $img->getAttribute('src');297 $avifUrl = $this->avifUrlFor( $src);282 $src = (string) $img->getAttribute( 'src' ); 283 $avifUrl = $this->avifUrlFor( $src ); 298 284 299 285 // Extract attachment ID from wp-image-{ID} class for ThumbHash lookup 300 286 $thumbhash = null; 301 if ( ThumbHash::isEnabled()) {302 $class = (string) $img->getAttribute( 'class');303 if ( preg_match('/wp-image-(\d+)/', $class, $matches)) {287 if ( ThumbHash::isEnabled() ) { 288 $class = (string) $img->getAttribute( 'class' ); 289 if ( preg_match( '/wp-image-(\d+)/', $class, $matches ) ) { 304 290 $attachmentId = (int) $matches[1]; 305 $thumbhash = ThumbHash::getForAttachment($attachmentId, 'full');291 $thumbhash = ThumbHash::getForAttachment( $attachmentId, 'full' ); 306 292 } 307 293 } 308 294 309 if ( !$avifUrl && !$thumbhash) {295 if ( ! $avifUrl && ! $thumbhash ) { 310 296 continue; 311 297 } … … 313 299 // Check if the image is wrapped in a link to a JPEG that also has an AVIF version. 314 300 $parent = $img->parentNode; 315 if ( $parent instanceof \DOMElement && strtolower($parent->nodeName) === 'a') {316 $href = (string) $parent->getAttribute( 'href');301 if ( $parent instanceof \DOMElement && strtolower( $parent->nodeName ) === 'a' ) { 302 $href = (string) $parent->getAttribute( 'href' ); 317 303 // Only process if href looks like a JPEG 318 if ( preg_match('/\.(jpe?g)$/i', $href)) {319 $avifHref = $this->avifUrlFor( $href);320 if ( $avifHref) {321 $parent->setAttribute( 'href', $avifHref);304 if ( preg_match( '/\.(jpe?g)$/i', $href ) ) { 305 $avifHref = $this->avifUrlFor( $href ); 306 if ( $avifHref ) { 307 $parent->setAttribute( 'href', $avifHref ); 322 308 } 323 309 } 324 310 } 325 311 326 $srcset = (string) $img->getAttribute('srcset'); 327 $sizes = (string) $img->getAttribute('sizes'); 328 $avifSrcset = ('' !== $srcset && $avifUrl) ? $this->convertSrcsetToAvif($srcset) : ($avifUrl ?: ''); 329 330 $this->wrapImgNodeToPicture($dom, $img, $avifSrcset, $sizes, $thumbhash); 312 $srcset = (string) $img->getAttribute( 'srcset' ); 313 $sizes = (string) $img->getAttribute( 'sizes' ); 314 $avifSrcset = ( '' !== $srcset ) ? $this->convertSrcsetToAvif( $srcset ) : ( $avifUrl ?: '' ); 315 316 // Ensure single AVIF candidates have width descriptors for responsive selection. 317 if ( '' === $avifSrcset && $avifUrl ) { 318 $w = (int) $img->getAttribute( 'width' ); 319 $avifSrcset = $w > 0 ? ( $avifUrl . ' ' . $w . 'w' ) : $avifUrl; 320 } 321 322 $this->wrapImgNodeToPicture( $dom, $img, $avifSrcset, $sizes, $thumbhash ); 331 323 } 332 324 333 325 // Cleanup: Remove the XML declaration node we added to force UTF-8 334 while ( $dom->firstChild instanceof \DOMProcessingInstruction && $dom->firstChild->nodeName === 'xml') {335 $dom->removeChild( $dom->firstChild);326 while ( $dom->firstChild instanceof \DOMProcessingInstruction && $dom->firstChild->nodeName === 'xml' ) { 327 $dom->removeChild( $dom->firstChild ); 336 328 } 337 329 338 330 $out = $dom->saveHTML(); 339 return \is_string($out) && $out !== '' ? $out : $htmlInput; 340 } 341 342 public function saveCache(): void 343 { 331 return \is_string( $out ) && $out !== '' ? $out : $htmlInput; 332 } 333 334 public function saveCache(): void { 344 335 // Only save positive (true) entries - filter out any false entries that might have snuck in. 345 $positiveOnly = array_filter( $this->fileCache, fn($v) => true === $v);346 set_transient( 'aviflosu_file_cache', $positiveOnly, (int) get_option('aviflosu_cache_duration', 3600));336 $positiveOnly = array_filter( $this->fileCache, fn( $v ) => true === $v ); 337 set_transient( 'aviflosu_file_cache', $positiveOnly, (int) get_option( 'aviflosu_cache_duration', 3600 ) ); 347 338 } 348 339 } -
avif-local-support/trunk/readme.txt
r3433195 r3444709 4 4 Requires at least: 6.8 5 5 Tested up to: 6.9 6 Stable tag: 0.5.2 06 Stable tag: 0.5.21 7 7 Requires PHP: 8.3 8 8 License: GPLv2 or later … … 198 198 199 199 == Changelog == 200 201 = 0.5.21 = 202 203 - Enhancement: ThumbHash input increased to 100px for richer LQIP placeholders. 204 - Fix: AVIF srcset now correctly lists all responsive sizes by running after WordPress adds srcset attributes. 205 - Fix: Single AVIF images now include width descriptors for proper responsive selection. 206 - Refactor: Removed redundant `render_block` filter; unified image processing in `the_content` filter. 200 207 201 208 = 0.5.20 =
Note: See TracChangeset
for help on using the changeset viewer.