| 1 |
<?php |
| 2 |
|
| 3 |
/** |
| 4 |
* Image processing for AI-generated images: pending storage, quality adjustment, promote/discard. |
| 5 |
* Stores files in uploads dir and tracks with transients. |
| 6 |
* User can apply (promote to attachment, set as featured) or discard (delete file + transient). |
| 7 |
* |
| 8 |
* @package SheetsPilot |
| 9 |
* @author Unlimited Elements |
| 10 |
* @copyright (C) 2026 Unlimited Elements, All Rights Reserved. |
| 11 |
* @license GNU/GPLv3 http://www.gnu.org/licenses/gpl-3.0.html |
| 12 |
**/ |
| 13 |
if (! defined('ABSPATH')) exit; |
| 14 |
if (! defined('SHEETSPILOT_INC')) { |
| 15 |
die('restricted access'); |
| 16 |
} |
| 17 |
|
| 18 |
class SheetsPilot_ImageProcessing |
| 19 |
{ |
| 20 |
|
| 21 |
const PENDING_DIR = 'unlimited-ai-pending'; |
| 22 |
const TRANSIENT_PREFIX = 'sheetspilot_pending_image_'; |
| 23 |
const PENDING_CONTEXT_PREFIX = 'sheetspilot_pending_image_ctx_'; |
| 24 |
const PENDING_RETRIED_PREFIX = 'sheetspilot_pending_image_retried_'; |
| 25 |
const TRANSIENT_TTL = 86400; // 24 hours |
| 26 |
const THUMB_MAX_WIDTH = 400; |
| 27 |
const LARGE_PNG_TO_JPEG_BYTES = 819200; // 800 KB |
| 28 |
const LOSSLESS_JPEG_QUALITY = 95; |
| 29 |
const PNG_COMPRESSION_LEVEL = 9; |
| 30 |
|
| 31 |
/** @var string Last compression engine used (imagick|gd|wp_image_editor). */ |
| 32 |
private static $lastCompressEngine = ''; |
| 33 |
|
| 34 |
/** @var string Preferred engine for current compress run (gd|imagick|'' = auto). */ |
| 35 |
private static $compressPreferredEngine = ''; |
| 36 |
|
| 37 |
|
| 38 |
private static function a_______IMAGE_GENERATION_SETTINGS________(){} |
| 39 |
|
| 40 |
|
| 41 |
/** |
| 42 |
* Default image sidebar keys when the client omits them. |
| 43 |
* |
| 44 |
* @return array<string,string> |
| 45 |
*/ |
| 46 |
public static function getDefaultImageSettings() { |
| 47 |
return array( |
| 48 |
'ratio' => SheetsPilotGlobals::OPENAI_IMAGE_SIZE, |
| 49 |
'quality' => SheetsPilotGlobals::DEFAULT_IMAGE_QUALITY, |
| 50 |
'format' => SheetsPilotGlobals::DEFAULT_IMAGE_FORMAT, |
| 51 |
'resolution' => SheetsPilotGlobals::DEFAULT_IMAGE_RESOLUTION, |
| 52 |
); |
| 53 |
} |
| 54 |
|
| 55 |
/** |
| 56 |
* Canonical aspect ratios, API pixel sizes, and display names (keep in sync with editor ratio UI). |
| 57 |
* |
| 58 |
* @return array<int,array{ratio:string,size:string,name:string}> |
| 59 |
*/ |
| 60 |
public static function getImageSizeDefinitions() { |
| 61 |
// Order: square, then horizontal (landscape), then vertical (portrait). Dropdown also prepends "auto". |
| 62 |
return array( |
| 63 |
array( |
| 64 |
'ratio' => '1:1', |
| 65 |
'size' => '1024x1024', |
| 66 |
'name' => __( 'Square', 'sheetspilot' ), |
| 67 |
), |
| 68 |
array( |
| 69 |
'ratio' => '2:1', |
| 70 |
'size' => '2048x1024', |
| 71 |
'name' => __( 'Horizontal', 'sheetspilot' ), |
| 72 |
), |
| 73 |
array( |
| 74 |
'ratio' => '3:1', |
| 75 |
'size' => '1536x512', |
| 76 |
'name' => __( 'Banner', 'sheetspilot' ), |
| 77 |
), |
| 78 |
array( |
| 79 |
'ratio' => '3:2', |
| 80 |
'size' => '1536x1024', |
| 81 |
'name' => __( 'Standard', 'sheetspilot' ), |
| 82 |
), |
| 83 |
array( |
| 84 |
'ratio' => '4:3', |
| 85 |
'size' => '1024x768', |
| 86 |
'name' => __( 'Classic', 'sheetspilot' ), |
| 87 |
), |
| 88 |
array( |
| 89 |
'ratio' => '16:9', |
| 90 |
'size' => '1792x1024', |
| 91 |
'name' => __( 'Widescreen', 'sheetspilot' ), |
| 92 |
), |
| 93 |
array( |
| 94 |
'ratio' => '21:9', |
| 95 |
'size' => '2352x1008', |
| 96 |
'name' => __( 'Ultrawide', 'sheetspilot' ), |
| 97 |
), |
| 98 |
array( |
| 99 |
'ratio' => '2:3', |
| 100 |
'size' => '1024x1536', |
| 101 |
'name' => __( 'Portrait', 'sheetspilot' ), |
| 102 |
), |
| 103 |
array( |
| 104 |
'ratio' => '3:4', |
| 105 |
'size' => '768x1024', |
| 106 |
'name' => __( 'Traditional', 'sheetspilot' ), |
| 107 |
), |
| 108 |
array( |
| 109 |
'ratio' => '9:16', |
| 110 |
'size' => '1024x1792', |
| 111 |
'name' => __( 'Social Story', 'sheetspilot' ), |
| 112 |
), |
| 113 |
); |
| 114 |
} |
| 115 |
|
| 116 |
/** |
| 117 |
* Aspect ratio => OpenAI Images API size (excludes "auto"). |
| 118 |
* |
| 119 |
* @return array<string,string> |
| 120 |
*/ |
| 121 |
public static function getAspectRatioToSizeMap() { |
| 122 |
$map = array(); |
| 123 |
foreach ( self::getImageSizeDefinitions() as $def ) { |
| 124 |
$map[ $def['ratio'] ] = $def['size']; |
| 125 |
} |
| 126 |
return $map; |
| 127 |
} |
| 128 |
|
| 129 |
/** |
| 130 |
* Allowed aspect ratio keys for cell rules and the image sidebar (includes "auto"). |
| 131 |
* |
| 132 |
* @return array<int,string> |
| 133 |
*/ |
| 134 |
public static function getAllowedAspectRatios() { |
| 135 |
$ratios = array( 'auto' ); |
| 136 |
foreach ( self::getImageSizeDefinitions() as $def ) { |
| 137 |
$ratios[] = $def['ratio']; |
| 138 |
} |
| 139 |
return $ratios; |
| 140 |
} |
| 141 |
|
| 142 |
/** |
| 143 |
* Context-menu action slug for a given aspect ratio (e.g. 16:9 → change-image-ratio-16-9). |
| 144 |
* |
| 145 |
* @param string $ratio Aspect ratio key. |
| 146 |
* @return string |
| 147 |
*/ |
| 148 |
public static function getChangeImageRatioContextAction( $ratio ) { |
| 149 |
$ratio = trim( (string) $ratio ); |
| 150 |
if ( $ratio === '' || $ratio === 'auto' ) { |
| 151 |
return ''; |
| 152 |
} |
| 153 |
return 'change-image-ratio-' . str_replace( ':', '-', $ratio ); |
| 154 |
} |
| 155 |
|
| 156 |
/** |
| 157 |
* Parse aspect ratio from a change-image-ratio-* context menu action. |
| 158 |
* |
| 159 |
* @param string $action Context menu data-action value. |
| 160 |
* @return string Ratio key (e.g. 16:9) or empty string. |
| 161 |
*/ |
| 162 |
public static function parseRatioFromChangeImageRatioAction( $action ) { |
| 163 |
$prefix = 'change-image-ratio-'; |
| 164 |
$action = trim( (string) $action ); |
| 165 |
if ( strpos( $action, $prefix ) !== 0 ) { |
| 166 |
return ''; |
| 167 |
} |
| 168 |
$slug = substr( $action, strlen( $prefix ) ); |
| 169 |
if ( $slug === '' ) { |
| 170 |
return ''; |
| 171 |
} |
| 172 |
foreach ( self::getImageSizeDefinitions() as $def ) { |
| 173 |
$ratio = isset( $def['ratio'] ) ? (string) $def['ratio'] : ''; |
| 174 |
if ( $ratio !== '' && str_replace( ':', '-', $ratio ) === $slug ) { |
| 175 |
return $ratio; |
| 176 |
} |
| 177 |
} |
| 178 |
return ''; |
| 179 |
} |
| 180 |
|
| 181 |
/** |
| 182 |
* Display size for ratio-change prompts (1024x768 → 1024×768). |
| 183 |
* |
| 184 |
* @param string $size API size string (e.g. 768x1024). |
| 185 |
* @return string |
| 186 |
*/ |
| 187 |
public static function formatImageSizeForPrompt( $size ) { |
| 188 |
$size = strtolower( trim( (string) $size ) ); |
| 189 |
if ( $size === '' ) { |
| 190 |
return ''; |
| 191 |
} |
| 192 |
return str_replace( 'x', '×', $size ); |
| 193 |
} |
| 194 |
|
| 195 |
/** |
| 196 |
* Portrait / landscape / square label from pixel dimensions. |
| 197 |
* |
| 198 |
* @param string $size API size string (e.g. 768x1024). |
| 199 |
* @return string |
| 200 |
*/ |
| 201 |
public static function getAspectRatioOrientationLabel( $size ) { |
| 202 |
$size = strtolower( trim( (string) $size ) ); |
| 203 |
if ( preg_match( '/^(\d+)x(\d+)$/', $size, $m ) ) { |
| 204 |
$w = (int) $m[1]; |
| 205 |
$h = (int) $m[2]; |
| 206 |
if ( $w === $h ) { |
| 207 |
return __( 'square', 'sheetspilot' ); |
| 208 |
} |
| 209 |
if ( $h > $w ) { |
| 210 |
return __( 'portrait', 'sheetspilot' ); |
| 211 |
} |
| 212 |
return __( 'landscape', 'sheetspilot' ); |
| 213 |
} |
| 214 |
return __( 'landscape', 'sheetspilot' ); |
| 215 |
} |
| 216 |
|
| 217 |
/** |
| 218 |
* Full outpaint-style prompt for context-menu ratio change (includes target pixel size). |
| 219 |
* |
| 220 |
* @param string $ratio Aspect ratio key (e.g. 3:4). |
| 221 |
* @return string |
| 222 |
*/ |
| 223 |
public static function buildChangeImageRatioPrompt( $ratio ) { |
| 224 |
$ratio = trim( (string) $ratio ); |
| 225 |
if ( $ratio === '' || $ratio === 'auto' ) { |
| 226 |
return ''; |
| 227 |
} |
| 228 |
$size = self::mapAspectRatioToImageSize( $ratio ); |
| 229 |
if ( $size === '' || $size === 'auto' ) { |
| 230 |
return ''; |
| 231 |
} |
| 232 |
$orientation = self::getAspectRatioOrientationLabel( $size ); |
| 233 |
$size_display = self::formatImageSizeForPrompt( $size ); |
| 234 |
|
| 235 |
return sprintf( |
| 236 |
/* translators: 1: aspect ratio (e.g. 3:4), 2: orientation (portrait/landscape/square), 3: pixel size (e.g. 768×1024) */ |
| 237 |
__( |
| 238 |
"Convert this image to a %1\$s %2\$s aspect ratio (%3\$s).\n\nKeep the original image unchanged.\nOnly expand the missing areas beyond the current borders.\nMatch the existing lighting, perspective, colors, and style.\nDo not alter the main subject or existing content.", |
| 239 |
'sheetspilot' |
| 240 |
), |
| 241 |
$ratio, |
| 242 |
$orientation, |
| 243 |
$size_display |
| 244 |
); |
| 245 |
} |
| 246 |
|
| 247 |
/** |
| 248 |
* Sub-menu items for "Change Image Ratio" on the image cell context menu. |
| 249 |
* |
| 250 |
* @return array<int,array{action:string,text:string,prompt:string,visible_for_cell_types:string}> |
| 251 |
*/ |
| 252 |
public static function getChangeImageRatioContextSubItems() { |
| 253 |
$items = array(); |
| 254 |
foreach ( self::getImageSizeDefinitions() as $def ) { |
| 255 |
$ratio = isset( $def['ratio'] ) ? trim( (string) $def['ratio'] ) : ''; |
| 256 |
if ( $ratio === '' ) { |
| 257 |
continue; |
| 258 |
} |
| 259 |
$action = self::getChangeImageRatioContextAction( $ratio ); |
| 260 |
if ( $action === '' ) { |
| 261 |
continue; |
| 262 |
} |
| 263 |
$name = isset( $def['name'] ) ? trim( (string) $def['name'] ) : ''; |
| 264 |
$text = $ratio; |
| 265 |
if ( $name !== '' ) { |
| 266 |
$text = $ratio . ' — ' . $name; |
| 267 |
} |
| 268 |
$items[] = array( |
| 269 |
'action' => $action, |
| 270 |
'text' => $text, |
| 271 |
'prompt' => self::buildChangeImageRatioPrompt( $ratio ), |
| 272 |
'visible_for_cell_types' => 'image', |
| 273 |
); |
| 274 |
} |
| 275 |
return $items; |
| 276 |
} |
| 277 |
|
| 278 |
/** |
| 279 |
* Map editor aspect ratio to OpenAI Images API `size` (or "auto"). |
| 280 |
* |
| 281 |
* @param string $ratio Aspect ratio key. |
| 282 |
* @return string |
| 283 |
*/ |
| 284 |
public static function mapAspectRatioToImageSize( $ratio ) { |
| 285 |
$ratio = strtolower( trim( (string) $ratio ) ); |
| 286 |
if ( $ratio === '' || $ratio === 'auto' ) { |
| 287 |
return 'auto'; |
| 288 |
} |
| 289 |
$map = self::getAspectRatioToSizeMap(); |
| 290 |
if ( isset( $map[ $ratio ] ) ) { |
| 291 |
return $map[ $ratio ]; |
| 292 |
} |
| 293 |
return '1024x1024'; |
| 294 |
} |
| 295 |
|
| 296 |
|
| 297 |
/** |
| 298 |
* Resolve sidebar/API quality (default uses plugin constant, not general settings). |
| 299 |
* |
| 300 |
* @param string $value Raw UI value. |
| 301 |
* @return string low|medium|high|auto |
| 302 |
*/ |
| 303 |
public static function resolveApiImageQuality( $value ) { |
| 304 |
$v = strtolower( trim( (string) $value ) ); |
| 305 |
if ( $v === '' || $v === 'default' ) { |
| 306 |
return SheetsPilotGlobals::DEFAULT_IMAGE_QUALITY; |
| 307 |
} |
| 308 |
$legacy_map = array( |
| 309 |
'0.5k' => 'low', |
| 310 |
'1k' => 'medium', |
| 311 |
'1.5k' => 'high', |
| 312 |
'2k' => 'high', |
| 313 |
); |
| 314 |
if ( isset( $legacy_map[ $v ] ) ) { |
| 315 |
$v = $legacy_map[ $v ]; |
| 316 |
} |
| 317 |
if ( in_array( $v, array( 'low', 'medium', 'high', 'auto' ), true ) ) { |
| 318 |
return $v; |
| 319 |
} |
| 320 |
return SheetsPilotGlobals::DEFAULT_IMAGE_QUALITY; |
| 321 |
} |
| 322 |
|
| 323 |
/** |
| 324 |
* Resolve sidebar/API format (default uses plugin constant, not general settings). |
| 325 |
* |
| 326 |
* @param string $value Raw UI value. |
| 327 |
* @return string png|jpeg|webp |
| 328 |
*/ |
| 329 |
public static function resolveApiImageFormat( $value ) { |
| 330 |
$v = strtolower( trim( (string) $value ) ); |
| 331 |
if ( $v === 'jpg' ) { |
| 332 |
$v = 'jpeg'; |
| 333 |
} |
| 334 |
if ( $v === '' || $v === 'default' ) { |
| 335 |
return SheetsPilotGlobals::DEFAULT_IMAGE_FORMAT; |
| 336 |
} |
| 337 |
if ( in_array( $v, array( 'png', 'jpeg', 'webp' ), true ) ) { |
| 338 |
return $v; |
| 339 |
} |
| 340 |
return SheetsPilotGlobals::DEFAULT_IMAGE_FORMAT; |
| 341 |
} |
| 342 |
|
| 343 |
/** |
| 344 |
* Resolve sidebar/API resolution tier (default uses plugin constant). |
| 345 |
* |
| 346 |
* @param string $value Raw UI value. |
| 347 |
* @return string 1k|2k|3k|4k |
| 348 |
*/ |
| 349 |
public static function resolveApiImageResolution( $value ) { |
| 350 |
$r = strtolower( trim( (string) $value ) ); |
| 351 |
if ( $r === '' || $r === 'default' ) { |
| 352 |
return SheetsPilotGlobals::DEFAULT_IMAGE_RESOLUTION; |
| 353 |
} |
| 354 |
if ( in_array( $r, array( '1k', '2k', '3k', '4k' ), true ) ) { |
| 355 |
return $r; |
| 356 |
} |
| 357 |
return SheetsPilotGlobals::DEFAULT_IMAGE_RESOLUTION; |
| 358 |
} |
| 359 |
|
| 360 |
/** |
| 361 |
* Allowed resolution tiers for the image sidebar / cell rules UI. |
| 362 |
* |
| 363 |
* @return array<int,string> |
| 364 |
*/ |
| 365 |
public static function getAllowedImageResolutions() { |
| 366 |
return array( 'default', '1k', '2k', '3k', '4k' ); |
| 367 |
} |
| 368 |
|
| 369 |
/** |
| 370 |
* Linear scale multiplier for a resolution tier. |
| 371 |
* |
| 372 |
* @param string $resolution 1k|2k|3k|4k or UI value (normalized internally). |
| 373 |
* @return int |
| 374 |
*/ |
| 375 |
public static function getImageResolutionMultiplier( $resolution ) { |
| 376 |
$map = array( |
| 377 |
'1k' => 1, |
| 378 |
'2k' => 2, |
| 379 |
'3k' => 3, |
| 380 |
'4k' => 4, |
| 381 |
); |
| 382 |
$r = self::resolveApiImageResolution( $resolution ); |
| 383 |
return isset( $map[ $r ] ) ? (int) $map[ $r ] : 1; |
| 384 |
} |
| 385 |
|
| 386 |
/** |
| 387 |
* Scale a WxH size string by the resolution tier (1K = base, 2K = 2×, etc.). |
| 388 |
* |
| 389 |
* @param string $size e.g. 1024x1024 or "auto". |
| 390 |
* @param string $resolution 1k|2k|3k|4k. |
| 391 |
* @return string |
| 392 |
*/ |
| 393 |
public static function scaleImageSizeByResolution( $size, $resolution ) { |
| 394 |
|
| 395 |
$size = trim( (string) $size ); |
| 396 |
if ( $size === '' || strtolower( $size ) === 'auto' ) { |
| 397 |
return $size; |
| 398 |
} |
| 399 |
if ( ! preg_match( '/^(\d+)x(\d+)$/i', $size, $m ) ) { |
| 400 |
return $size; |
| 401 |
} |
| 402 |
$mult = self::getImageResolutionMultiplier( $resolution ); |
| 403 |
if ( $mult <= 1 ) { |
| 404 |
return strtolower( $size ); |
| 405 |
} |
| 406 |
$w = (int) $m[1] * $mult; |
| 407 |
$h = (int) $m[2] * $mult; |
| 408 |
$w = max( 64, (int) round( $w / 2 ) * 2 ); |
| 409 |
$h = max( 64, (int) round( $h / 2 ) * 2 ); |
| 410 |
return $w . 'x' . $h; |
| 411 |
} |
| 412 |
|
| 413 |
|
| 414 |
|
| 415 |
/** |
| 416 |
* Read image option values from cell rules for the given column (when include_rules is enabled). |
| 417 |
* |
| 418 |
* @param array<string,mixed> $table_data Table/cell data (post_type, include_rules). |
| 419 |
* @param string $column_key Normalized column key (e.g. post_image). |
| 420 |
* @return array<string,string> Only keys set in cell rules (ratio, quality, format, resolution). |
| 421 |
*/ |
| 422 |
public static function getImageSettingsFromRules( $table_data, $column_key ) { |
| 423 |
$rules_settings = array(); |
| 424 |
|
| 425 |
$post_type_key = sanitize_key( (string) SheetsPilotFunctions::getVal( $table_data, 'post_type', '' ) ); |
| 426 |
$column_key = (string) $column_key; |
| 427 |
|
| 428 |
$include_rules = SheetsPilot_Prompts::isApplyPromptTableOptionEnabled( $table_data, 'include_rules' ); |
| 429 |
if ( ! $include_rules || $post_type_key === '' || $column_key === '' || SheetsPilotGlobals::$isPro != true ) { |
| 430 |
return $rules_settings; |
| 431 |
} |
| 432 |
|
| 433 |
$cell_rules = SheetsPilot_PromptsUI::get_cell_rules( $post_type_key ); |
| 434 |
$ar_key = $column_key . '__aspect_ratio'; |
| 435 |
$q_key = $column_key . '__quality'; |
| 436 |
$f_key = $column_key . '__format'; |
| 437 |
$res_key = $column_key . '__resolution'; |
| 438 |
|
| 439 |
$rule_ratio = strtolower( trim( (string) SheetsPilotFunctions::getVal( $cell_rules, $ar_key, '' ) ) ); |
| 440 |
$rule_quality = trim( (string) SheetsPilotFunctions::getVal( $cell_rules, $q_key, '' ) ); |
| 441 |
$rule_format = trim( (string) SheetsPilotFunctions::getVal( $cell_rules, $f_key, '' ) ); |
| 442 |
$rule_resolution = trim( (string) SheetsPilotFunctions::getVal( $cell_rules, $res_key, '' ) ); |
| 443 |
|
| 444 |
if ( $rule_ratio !== '' ) { |
| 445 |
$rules_settings['ratio'] = $rule_ratio; |
| 446 |
} |
| 447 |
if ( $rule_quality !== '' && strtolower( $rule_quality ) !== 'default' ) { |
| 448 |
$rules_settings['quality'] = $rule_quality; |
| 449 |
} |
| 450 |
if ( $rule_format !== '' && strtolower( $rule_format ) !== 'default' ) { |
| 451 |
$rules_settings['format'] = $rule_format; |
| 452 |
} |
| 453 |
if ( $rule_resolution !== '' && strtolower( $rule_resolution ) !== 'default' ) { |
| 454 |
$rules_settings['resolution'] = strtolower( $rule_resolution ); |
| 455 |
} |
| 456 |
|
| 457 |
return $rules_settings; |
| 458 |
} |
| 459 |
|
| 460 |
/** |
| 461 |
* Whether an image setting value means "use rules or plugin default" (sidebar ---). |
| 462 |
* |
| 463 |
* @param mixed $value Raw value. |
| 464 |
* @return bool |
| 465 |
*/ |
| 466 |
private static function isImageSettingDefault( $value ) { |
| 467 |
return trim( (string) $value ) === '' || strtolower( trim( (string) $value ) ) === 'default'; |
| 468 |
} |
| 469 |
|
| 470 |
/** |
| 471 |
* Merge defaults, cell rules, then client: explicit sidebar values override rules and defaults. |
| 472 |
* |
| 473 |
* @param array<string,string> $defaults Plugin default image_settings. |
| 474 |
* @param array<string,mixed> $client Client/sidebar payload. |
| 475 |
* @param array<string,string> $rules Values from getImageSettingsFromRules(). |
| 476 |
* @return array<string,string> |
| 477 |
*/ |
| 478 |
public static function mergeImageSettings( $defaults, $client, $rules ) { |
| 479 |
$keys = array( 'ratio', 'quality', 'format', 'resolution' ); |
| 480 |
$merged = array(); |
| 481 |
|
| 482 |
foreach ( $keys as $key ) { |
| 483 |
$merged[ $key ] = isset( $defaults[ $key ] ) ? (string) $defaults[ $key ] : ''; |
| 484 |
|
| 485 |
if ( isset( $rules[ $key ] ) && (string) $rules[ $key ] !== '' ) { |
| 486 |
$merged[ $key ] = (string) $rules[ $key ]; |
| 487 |
} |
| 488 |
|
| 489 |
$client_val = isset( $client[ $key ] ) ? $client[ $key ] : ''; |
| 490 |
if ( ! self::isImageSettingDefault( $client_val ) ) { |
| 491 |
$merged[ $key ] = 'ratio' === $key |
| 492 |
? strtolower( trim( (string) $client_val ) ) |
| 493 |
: trim( (string) $client_val ); |
| 494 |
} |
| 495 |
} |
| 496 |
|
| 497 |
return $merged; |
| 498 |
} |
| 499 |
|
| 500 |
/** |
| 501 |
* Merge client image_settings with defaults and cell-rule overrides for the given column. |
| 502 |
* |
| 503 |
* @param array<string,mixed> $table_data Table/cell data (post_type, image_settings, include_rules). |
| 504 |
* @param string $column_key Normalized column key (e.g. post_image). |
| 505 |
* @return array{image_size:string,quality_override:string,format_override:string} |
| 506 |
*/ |
| 507 |
public static function resolveImageSettingsForTable( $table_data, $column_key ) { |
| 508 |
|
| 509 |
//get the settings from 3 sources and merge them. client then rules then defaults. |
| 510 |
|
| 511 |
$image_settings_defaults = self::getDefaultImageSettings(); |
| 512 |
$image_settings_client = (array) SheetsPilotFunctions::getVal( $table_data, 'image_settings', array() ); |
| 513 |
|
| 514 |
$image_settings_rules = self::getImageSettingsFromRules( $table_data, $column_key ); |
| 515 |
$image_settings = self::mergeImageSettings( $image_settings_defaults, $image_settings_client, $image_settings_rules ); |
| 516 |
|
| 517 |
$ratio = strtolower( trim( (string) SheetsPilotFunctions::getVal( $image_settings, 'ratio', 'auto' ) ) ); |
| 518 |
$ratio = $ratio === '' ? 'auto' : $ratio; |
| 519 |
|
| 520 |
//map the image settings to the image size. |
| 521 |
$image_size = self::mapAspectRatioToImageSize( $ratio ); |
| 522 |
|
| 523 |
$quality_raw = (string) SheetsPilotFunctions::getVal( $image_settings, 'quality', 'default' ); |
| 524 |
$format_raw = (string) SheetsPilotFunctions::getVal( $image_settings, 'format', 'default' ); |
| 525 |
$resolution_raw = (string) SheetsPilotFunctions::getVal( $image_settings, 'resolution', 'default' ); |
| 526 |
|
| 527 |
$quality_override = self::resolveApiImageQuality( $quality_raw ); |
| 528 |
$format_override = self::resolveApiImageFormat( $format_raw ); |
| 529 |
$resolution = self::resolveApiImageResolution( $resolution_raw ); |
| 530 |
$image_size = self::scaleImageSizeByResolution( $image_size, $resolution ); |
| 531 |
|
| 532 |
$output = array( |
| 533 |
'image_size' => $image_size, |
| 534 |
'quality_override' => $quality_override, |
| 535 |
'format_override' => $format_override, |
| 536 |
); |
| 537 |
|
| 538 |
|
| 539 |
return $output; |
| 540 |
} |
| 541 |
|
| 542 |
|
| 543 |
private static function a_______PENDING_IMAGE________(){} |
| 544 |
|
| 545 |
/** |
| 546 |
* Get the pending directory path and ensure it exists. |
| 547 |
* |
| 548 |
* @return string Absolute path to pending dir. |
| 549 |
*/ |
| 550 |
public static function getPendingDir() |
| 551 |
{ |
| 552 |
$upload_dir = wp_upload_dir(); |
| 553 |
if (! empty($upload_dir['error'])) { |
| 554 |
SheetsPilotFunctions::throwError($upload_dir['error']); |
| 555 |
} |
| 556 |
$dir = $upload_dir['basedir'] . '/' . self::PENDING_DIR; |
| 557 |
if (! is_dir($dir)) { |
| 558 |
wp_mkdir_p($dir); |
| 559 |
} |
| 560 |
if (! is_dir($dir) || ! wp_is_writable($dir)) { |
| 561 |
SheetsPilotFunctions::throwError(__('Could not create or write to pending images directory.', 'sheetspilot')); |
| 562 |
} |
| 563 |
return $dir; |
| 564 |
} |
| 565 |
|
| 566 |
|
| 567 |
/** |
| 568 |
* Get the base URL for pending images (for preview). |
| 569 |
* |
| 570 |
* @return string Base URL. |
| 571 |
*/ |
| 572 |
public static function getPendingUrl() |
| 573 |
{ |
| 574 |
$upload_dir = wp_upload_dir(); |
| 575 |
return $upload_dir['baseurl'] . '/' . self::PENDING_DIR . '/'; |
| 576 |
} |
| 577 |
|
| 578 |
/** |
| 579 |
* Save image from API result (URL or data URL) to pending dir and store metadata in transient. |
| 580 |
* |
| 581 |
* @param string $image_url_or_data URL (http/https) or data URL (data:image/png;base64,...). |
| 582 |
* @param int $post_id Post ID this image is for. |
| 583 |
* @param string $column Column name (e.g. post_image). |
| 584 |
* @param string $quality Image quality label. |
| 585 |
* @param array $args Optional request_id, generation_context for one-time retry. |
| 586 |
* @return array { request_id, preview_url }. |
| 587 |
*/ |
| 588 |
public static function savePending($image_url_or_data, $post_id, $column, $quality, $args = array()) |
| 589 |
{ |
| 590 |
|
| 591 |
$args = is_array( $args ) ? $args : array(); |
| 592 |
$post_title = get_post($post_id)->post_title; |
| 593 |
$request_id = ''; |
| 594 |
if ( ! empty( $args['request_id'] ) ) { |
| 595 |
$request_id = sanitize_file_name( (string) $args['request_id'] ); |
| 596 |
} |
| 597 |
if ( $request_id === '' ) { |
| 598 |
$request_id = 'uba_' . sanitize_file_name($post_title) . '_' . wp_generate_password(22, false); |
| 599 |
} |
| 600 |
$dir = self::getPendingDir(); |
| 601 |
$ext = 'png'; |
| 602 |
$filename = $request_id . '.' . $ext; |
| 603 |
$filepath = $dir . '/' . $filename; |
| 604 |
|
| 605 |
// Malformed data URL from API handler (e.g. missing mime): data:;base64,... |
| 606 |
if ( preg_match( '#^data:;base64,(.+)$#s', $image_url_or_data, $m_bare ) ) { |
| 607 |
$image_url_or_data = 'data:image/png;base64,' . $m_bare[1]; |
| 608 |
} |
| 609 |
|
| 610 |
if (preg_match('#^data:image/(\w+);base64,(.+)$#s', $image_url_or_data, $m)) { |
| 611 |
$data = base64_decode($m[2], true); |
| 612 |
if ($data === false) { |
| 613 |
SheetsPilotFunctions::throwError(__('Invalid base64 image data.', 'sheetspilot')); |
| 614 |
} |
| 615 |
if ($m[1] === 'jpeg' || $m[1] === 'jpg') { |
| 616 |
$ext = 'jpg'; |
| 617 |
$filename = $request_id . '.' . $ext; |
| 618 |
$filepath = $dir . '/' . $filename; |
| 619 |
} |
| 620 |
if ($m[1] === 'webp') { |
| 621 |
$ext = 'webp'; |
| 622 |
$filename = $request_id . '.' . $ext; |
| 623 |
$filepath = $dir . '/' . $filename; |
| 624 |
} |
| 625 |
$written = file_put_contents($filepath, $data); |
| 626 |
} else { |
| 627 |
// Remote URL: download to temp file. |
| 628 |
$tmp = download_url($image_url_or_data); |
| 629 |
if (is_wp_error($tmp)) { |
| 630 |
SheetsPilotFunctions::throwError($tmp->get_error_message()); |
| 631 |
} |
| 632 |
|
| 633 |
$detected_ext = self::detectImageFormatExt($tmp); |
| 634 |
if ($detected_ext !== '') { |
| 635 |
$ext = $detected_ext; |
| 636 |
$filename = $request_id . '.' . $ext; |
| 637 |
$filepath = $dir . '/' . $filename; |
| 638 |
} |
| 639 |
|
| 640 |
$written = copy($tmp, $filepath); |
| 641 |
@unlink($tmp); |
| 642 |
} |
| 643 |
|
| 644 |
if (! $written || ! is_file($filepath)) { |
| 645 |
SheetsPilotFunctions::throwError(__('Could not save pending image file.', 'sheetspilot')); |
| 646 |
} |
| 647 |
|
| 648 |
$filepath_before_compress = $filepath; |
| 649 |
$ext_before_compress = $ext; |
| 650 |
$source_backup_path = ''; |
| 651 |
if ( self::isValidPendingImageFile( $filepath ) ) { |
| 652 |
$source_backup_path = $dir . '/' . $request_id . '_source.' . $ext_before_compress; |
| 653 |
if ( ! @copy( $filepath, $source_backup_path ) ) { |
| 654 |
$source_backup_path = ''; |
| 655 |
} |
| 656 |
} |
| 657 |
|
| 658 |
$filepath_after_compress = self::compressImageForMedia( $filepath ); |
| 659 |
$filepath = self::isValidPendingImageFile( $filepath_after_compress ) |
| 660 |
? $filepath_after_compress |
| 661 |
: $filepath_before_compress; |
| 662 |
if ( ! self::isValidPendingImageFile( $filepath ) && $source_backup_path !== '' ) { |
| 663 |
$filepath = $source_backup_path; |
| 664 |
} |
| 665 |
|
| 666 |
$filename = basename( $filepath ); |
| 667 |
$ext = strtolower( pathinfo( $filepath, PATHINFO_EXTENSION ) ); |
| 668 |
if ( $ext === '' ) { |
| 669 |
$ext = $ext_before_compress !== '' ? $ext_before_compress : 'png'; |
| 670 |
} |
| 671 |
if ( $ext === 'jpeg' ) { |
| 672 |
$ext = 'jpg'; |
| 673 |
} |
| 674 |
|
| 675 |
$thumb_path = self::createThumbnail( $filepath, $dir, $request_id, $ext ); |
| 676 |
if ( ! $thumb_path && $source_backup_path !== '' && self::isValidPendingImageFile( $source_backup_path ) ) { |
| 677 |
$source_ext = strtolower( pathinfo( $source_backup_path, PATHINFO_EXTENSION ) ); |
| 678 |
if ( $source_ext === 'jpeg' ) { |
| 679 |
$source_ext = 'jpg'; |
| 680 |
} |
| 681 |
$thumb_path = self::createThumbnail( $source_backup_path, $dir, $request_id, $source_ext !== '' ? $source_ext : $ext ); |
| 682 |
} |
| 683 |
|
| 684 |
$baseurl = self::getPendingUrl(); |
| 685 |
$preview_url = self::resolvePendingPreviewUrl( $baseurl, $thumb_path, $filepath, $source_backup_path ); |
| 686 |
|
| 687 |
$file_meta = self::probePendingImageFileMeta( |
| 688 |
array( |
| 689 |
$thumb_path, |
| 690 |
$filepath, |
| 691 |
$source_backup_path, |
| 692 |
$filepath_before_compress, |
| 693 |
) |
| 694 |
); |
| 695 |
|
| 696 |
// Thumb missing or unreadable: use compressed or source backup as the single preview URL. |
| 697 |
if ( $file_meta['file_size'] <= 0 || $preview_url === '' ) { |
| 698 |
$preview_url = self::resolvePendingPreviewUrl( $baseurl, '', $filepath, $source_backup_path ); |
| 699 |
if ( $preview_url === '' ) { |
| 700 |
$preview_url = self::resolvePendingPreviewUrl( $baseurl, '', $filepath_before_compress, $source_backup_path ); |
| 701 |
} |
| 702 |
$file_meta = self::probePendingImageFileMeta( |
| 703 |
array( |
| 704 |
$filepath, |
| 705 |
$source_backup_path, |
| 706 |
$filepath_before_compress, |
| 707 |
) |
| 708 |
); |
| 709 |
} |
| 710 |
|
| 711 |
if ( $preview_url === '' ) { |
| 712 |
SheetsPilotFunctions::throwError( __( 'Could not save pending image file.', 'sheetspilot' ) ); |
| 713 |
} |
| 714 |
|
| 715 |
$meta = array( |
| 716 |
'path' => $filepath, |
| 717 |
'thumb_path' => $thumb_path, |
| 718 |
'source_backup_path' => $source_backup_path, |
| 719 |
'url' => $preview_url, |
| 720 |
'post_id' => (int) $post_id, |
| 721 |
'column' => $column, |
| 722 |
'created' => time(), |
| 723 |
'quality' => $quality, |
| 724 |
'file_size' => (int) $file_meta['file_size'], |
| 725 |
'file_type' => (string) $file_meta['file_type'], |
| 726 |
'width' => (int) $file_meta['width'], |
| 727 |
'height' => (int) $file_meta['height'], |
| 728 |
); |
| 729 |
set_transient(self::TRANSIENT_PREFIX . $request_id, $meta, self::TRANSIENT_TTL); |
| 730 |
|
| 731 |
if ( ! empty( $args['generation_context'] ) && is_array( $args['generation_context'] ) ) { |
| 732 |
set_transient( |
| 733 |
self::PENDING_CONTEXT_PREFIX . $request_id, |
| 734 |
$args['generation_context'], |
| 735 |
self::TRANSIENT_TTL |
| 736 |
); |
| 737 |
} |
| 738 |
|
| 739 |
return array( |
| 740 |
'request_id' => $request_id, |
| 741 |
'preview_url' => $preview_url, |
| 742 |
'file_size' => (int) $file_meta['file_size'], |
| 743 |
'file_type' => (string) $file_meta['file_type'], |
| 744 |
'width' => (int) $file_meta['width'], |
| 745 |
'height' => (int) $file_meta['height'], |
| 746 |
); |
| 747 |
|
| 748 |
} |
| 749 |
|
| 750 |
/** |
| 751 |
* Build promote-pending AJAX payload with hover-preview metadata. |
| 752 |
* |
| 753 |
* @param int $attachment_id Attachment post ID. |
| 754 |
* @param string $thumbnail_url Medium thumbnail URL. |
| 755 |
* @param string|null $thumbnail_url_full Optional full-size thumbnail URL (galleries). |
| 756 |
* @return array |
| 757 |
*/ |
| 758 |
private static function buildPromotePendingResponse( $attachment_id, $thumbnail_url, $thumbnail_url_full = null ) { |
| 759 |
$preview_meta = SheetsPilotHelper::getAttachmentImagePreviewMeta( $attachment_id ); |
| 760 |
|
| 761 |
if ( ! is_string( $thumbnail_url ) || $thumbnail_url === '' ) { |
| 762 |
$thumbnail_url = self::resolveAttachmentThumbnailUrl( $attachment_id, 'medium' ); |
| 763 |
} |
| 764 |
if ( $thumbnail_url_full !== null && ( ! is_string( $thumbnail_url_full ) || $thumbnail_url_full === '' ) ) { |
| 765 |
$thumbnail_url_full = self::resolveAttachmentThumbnailUrl( $attachment_id, 'full' ); |
| 766 |
} |
| 767 |
if ( $preview_meta['full_url'] === '' ) { |
| 768 |
$preview_meta['full_url'] = self::resolveAttachmentThumbnailUrl( $attachment_id, 'full' ); |
| 769 |
} |
| 770 |
|
| 771 |
$response = array( |
| 772 |
'attachment_id' => $attachment_id, |
| 773 |
'thumbnail_url' => $thumbnail_url, |
| 774 |
'file_size' => $preview_meta['file_size'], |
| 775 |
'file_type' => $preview_meta['file_type'], |
| 776 |
'width' => $preview_meta['width'], |
| 777 |
'height' => $preview_meta['height'], |
| 778 |
'full_url' => $preview_meta['full_url'], |
| 779 |
'filename' => $preview_meta['filename'], |
| 780 |
); |
| 781 |
|
| 782 |
if ( $thumbnail_url_full !== null ) { |
| 783 |
$response['thumbnail_url_full'] = $thumbnail_url_full; |
| 784 |
} |
| 785 |
|
| 786 |
return $response; |
| 787 |
} |
| 788 |
|
| 789 |
/** |
| 790 |
* Best-effort attachment preview URL when WordPress size URLs are missing. |
| 791 |
* |
| 792 |
* @param int $attachment_id Attachment post ID. |
| 793 |
* @param string $size Image size name or 'full'. |
| 794 |
* @return string |
| 795 |
*/ |
| 796 |
private static function resolveAttachmentThumbnailUrl( $attachment_id, $size = 'medium' ) { |
| 797 |
$attachment_id = absint( $attachment_id ); |
| 798 |
if ( $attachment_id <= 0 ) { |
| 799 |
return ''; |
| 800 |
} |
| 801 |
|
| 802 |
if ( $size !== 'full' ) { |
| 803 |
$url = wp_get_attachment_image_url( $attachment_id, $size ); |
| 804 |
if ( is_string( $url ) && $url !== '' ) { |
| 805 |
return $url; |
| 806 |
} |
| 807 |
} |
| 808 |
|
| 809 |
$url = wp_get_attachment_url( $attachment_id ); |
| 810 |
if ( is_string( $url ) && $url !== '' ) { |
| 811 |
return $url; |
| 812 |
} |
| 813 |
|
| 814 |
$file = get_attached_file( $attachment_id ); |
| 815 |
if ( ! is_string( $file ) || $file === '' || ! is_file( $file ) ) { |
| 816 |
return ''; |
| 817 |
} |
| 818 |
|
| 819 |
$upload_dir = wp_upload_dir(); |
| 820 |
if ( ! empty( $upload_dir['error'] ) || empty( $upload_dir['basedir'] ) || empty( $upload_dir['baseurl'] ) ) { |
| 821 |
return ''; |
| 822 |
} |
| 823 |
|
| 824 |
$basedir = wp_normalize_path( $upload_dir['basedir'] ); |
| 825 |
$normalized = wp_normalize_path( $file ); |
| 826 |
if ( strpos( $normalized, $basedir ) !== 0 ) { |
| 827 |
return ''; |
| 828 |
} |
| 829 |
|
| 830 |
$relative = ltrim( substr( $normalized, strlen( $basedir ) ), '/\\' ); |
| 831 |
|
| 832 |
return $upload_dir['baseurl'] . '/' . str_replace( '\\', '/', $relative ); |
| 833 |
} |
| 834 |
|
| 835 |
/** |
| 836 |
* @param string $filepath Candidate image path. |
| 837 |
* @return string|null Path when the file exists and is non-empty. |
| 838 |
*/ |
| 839 |
private static function rememberValidImagePath( $filepath ) { |
| 840 |
return self::isValidPendingImageFile( $filepath ) ? $filepath : null; |
| 841 |
} |
| 842 |
|
| 843 |
/** |
| 844 |
* Whether a pending image path exists and is non-empty. |
| 845 |
* |
| 846 |
* @param string $path Absolute file path. |
| 847 |
* @return bool |
| 848 |
*/ |
| 849 |
private static function isValidPendingImageFile( $path ) { |
| 850 |
if ( ! is_string( $path ) || $path === '' ) { |
| 851 |
return false; |
| 852 |
} |
| 853 |
clearstatcache( true, $path ); |
| 854 |
if ( ! is_file( $path ) ) { |
| 855 |
return false; |
| 856 |
} |
| 857 |
$bytes = filesize( $path ); |
| 858 |
|
| 859 |
return $bytes !== false && $bytes > 0; |
| 860 |
} |
| 861 |
|
| 862 |
/** |
| 863 |
* Read image width/height from disk with WebP-friendly fallbacks. |
| 864 |
* |
| 865 |
* @param string $filepath Absolute image path. |
| 866 |
* @return array{width:int,height:int} |
| 867 |
*/ |
| 868 |
private static function readImageDimensions( $filepath ) { |
| 869 |
$width = 0; |
| 870 |
$height = 0; |
| 871 |
|
| 872 |
if ( ! self::isValidPendingImageFile( $filepath ) ) { |
| 873 |
return array( |
| 874 |
'width' => 0, |
| 875 |
'height' => 0, |
| 876 |
); |
| 877 |
} |
| 878 |
|
| 879 |
$info = wp_getimagesize( $filepath ); |
| 880 |
if ( is_array( $info ) && ! empty( $info[0] ) && ! empty( $info[1] ) ) { |
| 881 |
return array( |
| 882 |
'width' => (int) $info[0], |
| 883 |
'height' => (int) $info[1], |
| 884 |
); |
| 885 |
} |
| 886 |
|
| 887 |
if ( function_exists( 'getimagesize' ) ) { |
| 888 |
$info = @getimagesize( $filepath ); |
| 889 |
if ( is_array( $info ) && ! empty( $info[0] ) && ! empty( $info[1] ) ) { |
| 890 |
return array( |
| 891 |
'width' => (int) $info[0], |
| 892 |
'height' => (int) $info[1], |
| 893 |
); |
| 894 |
} |
| 895 |
} |
| 896 |
|
| 897 |
$ext = strtolower( pathinfo( $filepath, PATHINFO_EXTENSION ) ); |
| 898 |
if ( $ext === 'webp' && function_exists( 'imagecreatefromwebp' ) ) { |
| 899 |
$img = @imagecreatefromwebp( $filepath ); |
| 900 |
if ( $img !== false ) { |
| 901 |
$width = (int) imagesx( $img ); |
| 902 |
$height = (int) imagesy( $img ); |
| 903 |
imagedestroy( $img ); |
| 904 |
} |
| 905 |
} |
| 906 |
|
| 907 |
if ( ( $width <= 0 || $height <= 0 ) && class_exists( 'Imagick' ) ) { |
| 908 |
try { |
| 909 |
$imagick = new Imagick( $filepath ); |
| 910 |
$width = (int) $imagick->getImageWidth(); |
| 911 |
$height = (int) $imagick->getImageHeight(); |
| 912 |
$imagick->clear(); |
| 913 |
$imagick->destroy(); |
| 914 |
} catch ( Exception $e ) { |
| 915 |
} |
| 916 |
} |
| 917 |
|
| 918 |
return array( |
| 919 |
'width' => $width, |
| 920 |
'height' => $height, |
| 921 |
); |
| 922 |
} |
| 923 |
|
| 924 |
/** |
| 925 |
* Probe the first readable pending image path for preview metadata. |
| 926 |
* |
| 927 |
* @param array<int,string|null> $paths Candidate paths in priority order. |
| 928 |
* @return array{file_size:int,width:int,height:int,file_type:string} |
| 929 |
*/ |
| 930 |
private static function probePendingImageFileMeta( $paths ) { |
| 931 |
$empty = array( |
| 932 |
'file_size' => 0, |
| 933 |
'width' => 0, |
| 934 |
'height' => 0, |
| 935 |
'file_type' => '', |
| 936 |
); |
| 937 |
|
| 938 |
if ( ! is_array( $paths ) ) { |
| 939 |
return $empty; |
| 940 |
} |
| 941 |
|
| 942 |
foreach ( $paths as $path ) { |
| 943 |
if ( ! self::isValidPendingImageFile( $path ) ) { |
| 944 |
continue; |
| 945 |
} |
| 946 |
|
| 947 |
clearstatcache( true, $path ); |
| 948 |
$bytes = filesize( $path ); |
| 949 |
if ( $bytes === false || $bytes <= 0 ) { |
| 950 |
continue; |
| 951 |
} |
| 952 |
|
| 953 |
$dims = self::readImageDimensions( $path ); |
| 954 |
$ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ); |
| 955 |
if ( $ext === 'jpeg' ) { |
| 956 |
$ext = 'jpg'; |
| 957 |
} |
| 958 |
|
| 959 |
return array( |
| 960 |
'file_size' => (int) $bytes, |
| 961 |
'width' => (int) $dims['width'], |
| 962 |
'height' => (int) $dims['height'], |
| 963 |
'file_type' => $ext !== '' ? $ext : 'png', |
| 964 |
); |
| 965 |
} |
| 966 |
|
| 967 |
return $empty; |
| 968 |
} |
| 969 |
|
| 970 |
/** |
| 971 |
* Pick a single client-facing preview URL (thumb, compressed, or source backup). |
| 972 |
* |
| 973 |
* @param string $baseurl Pending images base URL. |
| 974 |
* @param string|null $thumb_path Optional thumbnail path. |
| 975 |
* @param string $primary_path Primary image path after compression. |
| 976 |
* @param string $fallback_path Optional uncompressed backup path. |
| 977 |
* @return string Public URL or empty string when no readable file exists. |
| 978 |
*/ |
| 979 |
private static function resolvePendingPreviewUrl( $baseurl, $thumb_path, $primary_path, $fallback_path = '' ) { |
| 980 |
$candidates = array(); |
| 981 |
if ( is_string( $thumb_path ) && $thumb_path !== '' ) { |
| 982 |
$candidates[] = $thumb_path; |
| 983 |
} |
| 984 |
if ( is_string( $primary_path ) && $primary_path !== '' ) { |
| 985 |
$candidates[] = $primary_path; |
| 986 |
} |
| 987 |
if ( is_string( $fallback_path ) && $fallback_path !== '' ) { |
| 988 |
$candidates[] = $fallback_path; |
| 989 |
} |
| 990 |
|
| 991 |
foreach ( $candidates as $path ) { |
| 992 |
if ( ! self::isValidPendingImageFile( $path ) ) { |
| 993 |
continue; |
| 994 |
} |
| 995 |
|
| 996 |
return $baseurl . basename( $path ); |
| 997 |
} |
| 998 |
|
| 999 |
return ''; |
| 1000 |
} |
| 1001 |
|
| 1002 |
/** |
| 1003 |
* Create a thumbnail (max width THUMB_MAX_WIDTH) from the full-size image. Same extension. |
| 1004 |
* |
| 1005 |
* @param string $filepath Full path to source image. |
| 1006 |
* @param string $dir Directory to save thumb in. |
| 1007 |
* @param string $request_id Request ID for naming. |
| 1008 |
* @param string $ext File extension (png, jpg, etc). |
| 1009 |
* @return string|null Thumb path or null on failure. |
| 1010 |
*/ |
| 1011 |
private static function createThumbnail($filepath, $dir, $request_id, $ext) |
| 1012 |
{ |
| 1013 |
$isDebug = false; |
| 1014 |
|
| 1015 |
if ($isDebug) { |
| 1016 |
dmp('createThumbnail: start filepath=' . $filepath . ' dir=' . $dir . ' request_id=' . $request_id . ' ext=' . $ext); |
| 1017 |
} |
| 1018 |
$thumb_filename = $request_id . '_thumb.' . $ext; |
| 1019 |
$thumb_path = $dir . '/' . $thumb_filename; |
| 1020 |
if ($isDebug) { |
| 1021 |
dmp('createThumbnail: thumb_path=' . $thumb_path); |
| 1022 |
} |
| 1023 |
$editor = wp_get_image_editor($filepath); |
| 1024 |
if ($isDebug) { |
| 1025 |
dmp('createThumbnail: wp_get_image_editor done' . (is_wp_error($editor) ? ' ERROR=' . $editor->get_error_message() : '')); |
| 1026 |
} |
| 1027 |
if (! $editor || is_wp_error($editor) || ! method_exists($editor, 'load')) { |
| 1028 |
return null; |
| 1029 |
} |
| 1030 |
|
| 1031 |
$loaded = $editor->load(); |
| 1032 |
if ($isDebug) { |
| 1033 |
dmp('createThumbnail: editor->load done' . (is_wp_error($loaded) ? ' ERROR=' . $loaded->get_error_message() : '')); |
| 1034 |
} |
| 1035 |
if (is_wp_error($loaded)) { |
| 1036 |
return null; |
| 1037 |
} |
| 1038 |
// Max width 400; use large height so only width is constrained (proportional scale). |
| 1039 |
$resized = $editor->resize(self::THUMB_MAX_WIDTH, 9999, false); |
| 1040 |
if ($isDebug) { |
| 1041 |
dmp('createThumbnail: editor->resize done' . (is_wp_error($resized) ? ' ERROR=' . $resized->get_error_message() : '')); |
| 1042 |
} |
| 1043 |
if (is_wp_error($resized)) { |
| 1044 |
return null; |
| 1045 |
} |
| 1046 |
$saved = $editor->save($thumb_path); |
| 1047 |
if ($isDebug) { |
| 1048 |
dmp('createThumbnail: editor->save done' . (is_wp_error($saved) ? ' ERROR=' . $saved->get_error_message() : ' path=' . (is_array($saved) && ! empty($saved['path']) ? $saved['path'] : $thumb_path))); |
| 1049 |
} |
| 1050 |
if (is_wp_error($saved)) { |
| 1051 |
return null; |
| 1052 |
} |
| 1053 |
$thumb_path = is_array($saved) && ! empty($saved['path']) ? $saved['path'] : $thumb_path; |
| 1054 |
if ($isDebug) { |
| 1055 |
dmp('createThumbnail: final thumb_path=' . $thumb_path . ' is_file=' . (is_file($thumb_path) ? 'yes' : 'no')); |
| 1056 |
} |
| 1057 |
if (! is_file($thumb_path)) { |
| 1058 |
return null; |
| 1059 |
} |
| 1060 |
if ($isDebug) { |
| 1061 |
dmp('createThumbnail: success'); |
| 1062 |
} |
| 1063 |
return $thumb_path; |
| 1064 |
} |
| 1065 |
|
| 1066 |
/** |
| 1067 |
* Get pending image metadata by request_id. |
| 1068 |
* |
| 1069 |
* @param string $request_id Request ID from savePending(). |
| 1070 |
* @return array|null Meta array or null if not found/expired. |
| 1071 |
*/ |
| 1072 |
public static function getPending($request_id) |
| 1073 |
{ |
| 1074 |
$meta = get_transient(self::TRANSIENT_PREFIX . $request_id); |
| 1075 |
if (! is_array($meta) || empty($meta['path']) || ! is_file($meta['path'])) { |
| 1076 |
return null; |
| 1077 |
} |
| 1078 |
return $meta; |
| 1079 |
} |
| 1080 |
|
| 1081 |
/** |
| 1082 |
* Detect image format from file contents (mime), falling back to extension. |
| 1083 |
* |
| 1084 |
* @param string $filepath Absolute path to an image file. |
| 1085 |
* @return string jpg|png|webp|'' when unknown. |
| 1086 |
*/ |
| 1087 |
private static function detectImageFormatExt( $filepath ) { |
| 1088 |
$mime = ''; |
| 1089 |
if ( function_exists( 'wp_get_image_mime' ) ) { |
| 1090 |
$mime = (string) wp_get_image_mime( $filepath ); |
| 1091 |
} elseif ( function_exists( 'mime_content_type' ) ) { |
| 1092 |
$mime = (string) mime_content_type( $filepath ); |
| 1093 |
} |
| 1094 |
|
| 1095 |
$mime = strtolower( trim( $mime ) ); |
| 1096 |
if ( $mime === 'image/jpeg' || $mime === 'image/jpg' ) { |
| 1097 |
return 'jpg'; |
| 1098 |
} |
| 1099 |
if ( $mime === 'image/png' ) { |
| 1100 |
return 'png'; |
| 1101 |
} |
| 1102 |
if ( $mime === 'image/webp' ) { |
| 1103 |
return 'webp'; |
| 1104 |
} |
| 1105 |
|
| 1106 |
$info = wp_check_filetype( $filepath ); |
| 1107 |
$ext = strtolower( (string) ( $info['ext'] ?? '' ) ); |
| 1108 |
if ( $ext === 'jpeg' ) { |
| 1109 |
return 'jpg'; |
| 1110 |
} |
| 1111 |
if ( in_array( $ext, array( 'jpg', 'png', 'webp' ), true ) ) { |
| 1112 |
return $ext; |
| 1113 |
} |
| 1114 |
|
| 1115 |
return ''; |
| 1116 |
} |
| 1117 |
|
| 1118 |
/** |
| 1119 |
* Copy the image to a sidecar backup before in-place compression. |
| 1120 |
* |
| 1121 |
* @param string $filepath Absolute path to the image. |
| 1122 |
* @return string Backup file path. |
| 1123 |
*/ |
| 1124 |
private static function createCompressBackup( $filepath ) { |
| 1125 |
$backup_path = $filepath . '.sheetspilot-compress-backup'; |
| 1126 |
if ( ! @copy( $filepath, $backup_path ) ) { |
| 1127 |
self::logLosslessCompressImageNote( 'skipped compression: could not create backup' ); |
| 1128 |
return ''; |
| 1129 |
} |
| 1130 |
|
| 1131 |
return $backup_path; |
| 1132 |
} |
| 1133 |
|
| 1134 |
/** |
| 1135 |
* @param string $backup_path Sidecar backup path. |
| 1136 |
*/ |
| 1137 |
private static function removeCompressBackup( $backup_path ) { |
| 1138 |
if ( is_string( $backup_path ) && $backup_path !== '' && is_file( $backup_path ) ) { |
| 1139 |
@unlink( $backup_path ); |
| 1140 |
} |
| 1141 |
} |
| 1142 |
|
| 1143 |
/** |
| 1144 |
* Restore the original image from a compression backup. |
| 1145 |
* |
| 1146 |
* @param string $filepath Absolute path to the image. |
| 1147 |
* @param string $backup_path Sidecar backup path. |
| 1148 |
* @param string $format Image format label for logging (jpg|png|webp|unknown). |
| 1149 |
*/ |
| 1150 |
private static function restoreCompressBackup( $filepath, $backup_path, $format = '' ) { |
| 1151 |
if ( ! is_string( $backup_path ) || $backup_path === '' || ! is_file( $backup_path ) ) { |
| 1152 |
return; |
| 1153 |
} |
| 1154 |
|
| 1155 |
@copy( $backup_path, $filepath ); |
| 1156 |
$restored_size = is_file( $filepath ) ? (int) filesize( $filepath ) : 0; |
| 1157 |
$message = 'backup restored'; |
| 1158 |
if ( $format !== '' ) { |
| 1159 |
$message .= ', format ' . $format; |
| 1160 |
} |
| 1161 |
$message .= ', file size after restore: ' . $restored_size . ' bytes'; |
| 1162 |
self::logLosslessCompressImageNote( $message ); |
| 1163 |
self::removeCompressBackup( $backup_path ); |
| 1164 |
} |
| 1165 |
|
| 1166 |
/** |
| 1167 |
* Ensure compression produced a non-empty file; otherwise restore the backup. |
| 1168 |
* |
| 1169 |
* @param string $filepath Absolute path to the image. |
| 1170 |
* @param string $backup_path Sidecar backup path. |
| 1171 |
* @param string $format Image format label for logging (jpg|png|webp|unknown). |
| 1172 |
* @return bool True when the compressed file is valid; false when the original was restored. |
| 1173 |
*/ |
| 1174 |
private static function assertValidCompressedImage( $filepath, $backup_path, $format = '' ) { |
| 1175 |
$size = is_file( $filepath ) ? (int) filesize( $filepath ) : 0; |
| 1176 |
if ( $size <= 0 ) { |
| 1177 |
self::restoreCompressBackup( $filepath, $backup_path, $format ); |
| 1178 |
self::logLosslessCompressImageNote( 'compression produced empty file; kept original' ); |
| 1179 |
return false; |
| 1180 |
} |
| 1181 |
|
| 1182 |
self::removeCompressBackup( $backup_path ); |
| 1183 |
return true; |
| 1184 |
} |
| 1185 |
|
| 1186 |
/** |
| 1187 |
* Temp path used for safe in-place image rewrites (avoid truncating the source file). |
| 1188 |
* |
| 1189 |
* @param string $filepath Target image path. |
| 1190 |
* @return string |
| 1191 |
*/ |
| 1192 |
private static function getImageTempWritePath( $filepath ) { |
| 1193 |
return $filepath . '.sheetspilot-compress-tmp'; |
| 1194 |
} |
| 1195 |
|
| 1196 |
/** |
| 1197 |
* Replace an image file with a validated temp write (non-empty temp required). |
| 1198 |
* |
| 1199 |
* @param string $filepath Destination path. |
| 1200 |
* @param string $temp_path Temp file path. |
| 1201 |
* @return bool |
| 1202 |
*/ |
| 1203 |
private static function replaceImageFileFromTemp( $filepath, $temp_path ) { |
| 1204 |
if ( ! is_file( $temp_path ) || (int) filesize( $temp_path ) <= 0 ) { |
| 1205 |
@unlink( $temp_path ); |
| 1206 |
return false; |
| 1207 |
} |
| 1208 |
|
| 1209 |
if ( @rename( $temp_path, $filepath ) ) { |
| 1210 |
return is_file( $filepath ) && (int) filesize( $filepath ) > 0; |
| 1211 |
} |
| 1212 |
|
| 1213 |
if ( @copy( $temp_path, $filepath ) ) { |
| 1214 |
@unlink( $temp_path ); |
| 1215 |
return is_file( $filepath ) && (int) filesize( $filepath ) > 0; |
| 1216 |
} |
| 1217 |
|
| 1218 |
@unlink( $temp_path ); |
| 1219 |
return false; |
| 1220 |
} |
| 1221 |
|
| 1222 |
/** |
| 1223 |
* @return bool |
| 1224 |
*/ |
| 1225 |
private static function imagickSupportsWebp() { |
| 1226 |
if ( ! class_exists( 'Imagick' ) ) { |
| 1227 |
return false; |
| 1228 |
} |
| 1229 |
|
| 1230 |
$formats = Imagick::queryFormats( 'WEBP' ); |
| 1231 |
return is_array( $formats ) && ! empty( $formats ); |
| 1232 |
} |
| 1233 |
|
| 1234 |
/** |
| 1235 |
* @return bool |
| 1236 |
*/ |
| 1237 |
private static function gdSupportsWebp() { |
| 1238 |
return function_exists( 'imagecreatetruecolor' ) |
| 1239 |
&& function_exists( 'imagecreatefromwebp' ) |
| 1240 |
&& function_exists( 'imagewebp' ); |
| 1241 |
} |
| 1242 |
|
| 1243 |
/** |
| 1244 |
* Imagick — lossless WebP with maximum compression effort. |
| 1245 |
* |
| 1246 |
* @param string $source Absolute path to source WebP. |
| 1247 |
* @param string $dest Absolute path for output WebP. |
| 1248 |
* @return bool |
| 1249 |
*/ |
| 1250 |
private static function compressWebpWithImagick( $source, $dest ) { |
| 1251 |
if ( ! self::imagickSupportsWebp() ) { |
| 1252 |
return false; |
| 1253 |
} |
| 1254 |
|
| 1255 |
$image = self::loadImagickFromFile( $source ); |
| 1256 |
if ( $image === null ) { |
| 1257 |
return false; |
| 1258 |
} |
| 1259 |
|
| 1260 |
try { |
| 1261 |
$image->setImageFilename( $dest ); |
| 1262 |
$image->setImageFormat( 'webp' ); |
| 1263 |
$image->setOption( 'webp:lossless', 'true' ); |
| 1264 |
$image->setOption( 'webp:method', '6' ); |
| 1265 |
$image->setOption( 'webp:alpha-quality', '100' ); |
| 1266 |
$image->stripImage(); |
| 1267 |
$written = $image->writeImage( $dest ); |
| 1268 |
$image->clear(); |
| 1269 |
$image->destroy(); |
| 1270 |
} catch ( Exception $e ) { |
| 1271 |
@unlink( $dest ); |
| 1272 |
self::logLosslessCompressImageNote( 'imagick WebP write error (lossless): ' . $e->getMessage() ); |
| 1273 |
return false; |
| 1274 |
} |
| 1275 |
|
| 1276 |
return ! empty( $written ) && is_file( $dest ) && (int) filesize( $dest ) > 0; |
| 1277 |
} |
| 1278 |
|
| 1279 |
/** |
| 1280 |
* GD — lossless WebP (PHP 8.1+ IMG_WEBP_LOSSLESS flag). |
| 1281 |
* |
| 1282 |
* @param string $source Absolute path to source WebP. |
| 1283 |
* @param string $dest Absolute path for output WebP. |
| 1284 |
* @return bool |
| 1285 |
*/ |
| 1286 |
private static function compressWebpWithGd( $source, $dest ) { |
| 1287 |
if ( ! self::gdSupportsWebp() ) { |
| 1288 |
return false; |
| 1289 |
} |
| 1290 |
|
| 1291 |
$image = @imagecreatefromwebp( $source ); |
| 1292 |
if ( $image === false ) { |
| 1293 |
return false; |
| 1294 |
} |
| 1295 |
|
| 1296 |
if ( function_exists( 'imagepalettetotruecolor' ) ) { |
| 1297 |
imagepalettetotruecolor( $image ); |
| 1298 |
} |
| 1299 |
imagesavealpha( $image, true ); |
| 1300 |
|
| 1301 |
$quality = defined( 'IMG_WEBP_LOSSLESS' ) ? IMG_WEBP_LOSSLESS : 100; |
| 1302 |
$ok = imagewebp( $image, $dest, $quality ); |
| 1303 |
imagedestroy( $image ); |
| 1304 |
|
| 1305 |
return $ok && is_file( $dest ) && (int) filesize( $dest ) > 0; |
| 1306 |
} |
| 1307 |
|
| 1308 |
/** |
| 1309 |
* @return bool |
| 1310 |
*/ |
| 1311 |
private static function imagickSupportsPng() { |
| 1312 |
if ( ! class_exists( 'Imagick' ) ) { |
| 1313 |
return false; |
| 1314 |
} |
| 1315 |
|
| 1316 |
$formats = Imagick::queryFormats( 'PNG' ); |
| 1317 |
return is_array( $formats ) && ! empty( $formats ); |
| 1318 |
} |
| 1319 |
|
| 1320 |
/** |
| 1321 |
* @return bool |
| 1322 |
*/ |
| 1323 |
private static function gdSupportsPng() { |
| 1324 |
return function_exists( 'imagecreatefrompng' ) && function_exists( 'imagepng' ); |
| 1325 |
} |
| 1326 |
|
| 1327 |
/** |
| 1328 |
* Lossless PNG via Imagick using a temp file (never overwrite source in place). |
| 1329 |
* |
| 1330 |
* @param string $filepath Absolute path to the image. |
| 1331 |
* @return bool |
| 1332 |
*/ |
| 1333 |
private static function losslessCompressPngImagick( $filepath, $backup_path = '' ) { |
| 1334 |
$read_path = self::getCompressReadPath( $filepath, $backup_path ); |
| 1335 |
$temp_path = self::getImageTempWritePath( $filepath ); |
| 1336 |
@unlink( $temp_path ); |
| 1337 |
|
| 1338 |
$image = self::loadImagickFromFile( $read_path ); |
| 1339 |
if ( $image === null ) { |
| 1340 |
return false; |
| 1341 |
} |
| 1342 |
|
| 1343 |
try { |
| 1344 |
$image->setImageFilename( $temp_path ); |
| 1345 |
$image->setImageFormat( 'png' ); |
| 1346 |
$image->setImageCompression( Imagick::COMPRESSION_ZIP ); |
| 1347 |
$image->setOption( 'png:compression-level', (string) self::PNG_COMPRESSION_LEVEL ); |
| 1348 |
$image->stripImage(); |
| 1349 |
$written = $image->writeImage( $temp_path ); |
| 1350 |
$image->clear(); |
| 1351 |
$image->destroy(); |
| 1352 |
} catch ( Exception $e ) { |
| 1353 |
@unlink( $temp_path ); |
| 1354 |
self::logLosslessCompressImageNote( 'imagick PNG write error: ' . $e->getMessage() ); |
| 1355 |
return false; |
| 1356 |
} |
| 1357 |
|
| 1358 |
if ( empty( $written ) ) { |
| 1359 |
@unlink( $temp_path ); |
| 1360 |
return false; |
| 1361 |
} |
| 1362 |
|
| 1363 |
return self::replaceImageFileFromTemp( $filepath, $temp_path ); |
| 1364 |
} |
| 1365 |
|
| 1366 |
/** |
| 1367 |
* Re-encode PNG via GD at max compression using a temp file. |
| 1368 |
* |
| 1369 |
* @param string $filepath Absolute path to the image. |
| 1370 |
* @return bool |
| 1371 |
*/ |
| 1372 |
private static function losslessCompressPngGd( $filepath ) { |
| 1373 |
$temp_path = self::getImageTempWritePath( $filepath ); |
| 1374 |
@unlink( $temp_path ); |
| 1375 |
|
| 1376 |
$image = @imagecreatefrompng( $filepath ); |
| 1377 |
if ( $image === false ) { |
| 1378 |
return false; |
| 1379 |
} |
| 1380 |
|
| 1381 |
imagealphablending( $image, false ); |
| 1382 |
imagesavealpha( $image, true ); |
| 1383 |
$written = @imagepng( $image, $temp_path, self::PNG_COMPRESSION_LEVEL ); |
| 1384 |
imagedestroy( $image ); |
| 1385 |
|
| 1386 |
if ( ! $written ) { |
| 1387 |
@unlink( $temp_path ); |
| 1388 |
self::logLosslessCompressImageNote( 'gd imagepng() returned false' ); |
| 1389 |
return false; |
| 1390 |
} |
| 1391 |
|
| 1392 |
return self::replaceImageFileFromTemp( $filepath, $temp_path ); |
| 1393 |
} |
| 1394 |
|
| 1395 |
/** |
| 1396 |
* Re-encode PNG via WordPress image editor (fallback when direct Imagick/GD paths fail). |
| 1397 |
* |
| 1398 |
* @param WP_Image_Editor $editor Image editor instance. |
| 1399 |
* @param string $filepath Absolute path to the image. |
| 1400 |
* @return bool |
| 1401 |
*/ |
| 1402 |
private static function losslessCompressPngWpEditor( $editor, $filepath ) { |
| 1403 |
$temp_path = self::getImageTempWritePath( $filepath ); |
| 1404 |
@unlink( $temp_path ); |
| 1405 |
|
| 1406 |
$editor->set_quality( self::PNG_COMPRESSION_LEVEL ); |
| 1407 |
$saved = $editor->save( $temp_path, 'image/png' ); |
| 1408 |
if ( is_wp_error( $saved ) ) { |
| 1409 |
@unlink( $temp_path ); |
| 1410 |
self::logLosslessCompressImageNote( 'wp_image_editor PNG save error: ' . $saved->get_error_message() ); |
| 1411 |
return false; |
| 1412 |
} |
| 1413 |
|
| 1414 |
$output_path = $temp_path; |
| 1415 |
if ( is_array( $saved ) && ! empty( $saved['path'] ) && is_file( $saved['path'] ) ) { |
| 1416 |
$output_path = $saved['path']; |
| 1417 |
} |
| 1418 |
|
| 1419 |
$ok = self::replaceImageFileFromTemp( $filepath, $output_path ); |
| 1420 |
if ( $output_path !== $filepath && is_file( $output_path ) ) { |
| 1421 |
@unlink( $output_path ); |
| 1422 |
} |
| 1423 |
@unlink( $temp_path ); |
| 1424 |
|
| 1425 |
return $ok; |
| 1426 |
} |
| 1427 |
|
| 1428 |
/** |
| 1429 |
* Restore the compress target from backup when missing or empty (silent, no throw). |
| 1430 |
* |
| 1431 |
* @param string $filepath Absolute path to the image. |
| 1432 |
* @param string $backup_path Sidecar backup path. |
| 1433 |
* @return bool True when the target exists and is non-empty afterward. |
| 1434 |
*/ |
| 1435 |
private static function ensureCompressTargetFromBackup( $filepath, $backup_path ) { |
| 1436 |
if ( ! is_string( $backup_path ) || $backup_path === '' || ! is_file( $backup_path ) ) { |
| 1437 |
return is_file( $filepath ) && (int) filesize( $filepath ) > 0; |
| 1438 |
} |
| 1439 |
|
| 1440 |
if ( ! is_file( $filepath ) || (int) filesize( $filepath ) <= 0 ) { |
| 1441 |
if ( ! @copy( $backup_path, $filepath ) ) { |
| 1442 |
return false; |
| 1443 |
} |
| 1444 |
self::logLosslessCompressImageNote( |
| 1445 |
'restored missing compress target from backup (' . (int) filesize( $filepath ) . ' bytes)' |
| 1446 |
); |
| 1447 |
} |
| 1448 |
|
| 1449 |
return is_file( $filepath ) && (int) filesize( $filepath ) > 0; |
| 1450 |
} |
| 1451 |
|
| 1452 |
/** |
| 1453 |
* Best path to read image bytes for Imagick/GD (prefer unchanged backup). |
| 1454 |
* |
| 1455 |
* @param string $filepath Current image path. |
| 1456 |
* @param string $backup_path Sidecar backup path. |
| 1457 |
* @return string |
| 1458 |
*/ |
| 1459 |
private static function getCompressReadPath( $filepath, $backup_path = '' ) { |
| 1460 |
if ( is_string( $backup_path ) && $backup_path !== '' && is_file( $backup_path ) ) { |
| 1461 |
return $backup_path; |
| 1462 |
} |
| 1463 |
|
| 1464 |
return $filepath; |
| 1465 |
} |
| 1466 |
|
| 1467 |
/** |
| 1468 |
* Load an image into Imagick from disk without binding/mutating the source file path. |
| 1469 |
* |
| 1470 |
* Using new Imagick( $path ) can unlink or truncate the source on some servers when a |
| 1471 |
* subsequent write fails (seen with WebP lossless on Cloudways). readImageBlob avoids that. |
| 1472 |
* |
| 1473 |
* @param string $path Absolute path to an image file. |
| 1474 |
* @return Imagick|null |
| 1475 |
*/ |
| 1476 |
private static function loadImagickFromFile( $path ) { |
| 1477 |
if ( ! is_string( $path ) || $path === '' || ! is_file( $path ) ) { |
| 1478 |
return null; |
| 1479 |
} |
| 1480 |
|
| 1481 |
$bytes = @file_get_contents( $path ); |
| 1482 |
if ( ! is_string( $bytes ) || $bytes === '' ) { |
| 1483 |
return null; |
| 1484 |
} |
| 1485 |
|
| 1486 |
try { |
| 1487 |
$image = new Imagick(); |
| 1488 |
$image->readImageBlob( $bytes ); |
| 1489 |
return $image; |
| 1490 |
} catch ( Exception $e ) { |
| 1491 |
self::logLosslessCompressImageNote( 'imagick read error (' . basename( $path ) . '): ' . $e->getMessage() ); |
| 1492 |
return null; |
| 1493 |
} |
| 1494 |
} |
| 1495 |
|
| 1496 |
/** |
| 1497 |
* Recreate the sidecar backup from the current target when Imagick consumed/deleted it. |
| 1498 |
* |
| 1499 |
* @param string $filepath Absolute path to the image. |
| 1500 |
* @param string $backup_path Sidecar backup path. |
| 1501 |
* @return bool |
| 1502 |
*/ |
| 1503 |
private static function recreateCompressBackupIfMissing( $filepath, $backup_path ) { |
| 1504 |
if ( ! is_string( $backup_path ) || $backup_path === '' ) { |
| 1505 |
return false; |
| 1506 |
} |
| 1507 |
|
| 1508 |
if ( is_file( $backup_path ) && (int) filesize( $backup_path ) > 0 ) { |
| 1509 |
return true; |
| 1510 |
} |
| 1511 |
|
| 1512 |
if ( ! is_file( $filepath ) || (int) filesize( $filepath ) <= 0 ) { |
| 1513 |
return false; |
| 1514 |
} |
| 1515 |
|
| 1516 |
if ( ! @copy( $filepath, $backup_path ) ) { |
| 1517 |
return false; |
| 1518 |
} |
| 1519 |
|
| 1520 |
self::logLosslessCompressImageNote( |
| 1521 |
'recreated missing sidecar backup from compress target (' . (int) filesize( $backup_path ) . ' bytes)' |
| 1522 |
); |
| 1523 |
|
| 1524 |
return true; |
| 1525 |
} |
| 1526 |
|
| 1527 |
/** |
| 1528 |
* Losslessly re-compress WebP using GD or Imagick. |
| 1529 |
* |
| 1530 |
* Reads from the sidecar backup when present. Replaces the target only when the |
| 1531 |
* encoded output is smaller than the source; otherwise the original is kept. |
| 1532 |
* |
| 1533 |
* @param string $filepath Absolute path to the image. |
| 1534 |
* @param string $backup_path Sidecar backup path. |
| 1535 |
* @return bool True when an encoder ran (file may be unchanged if output was not smaller). |
| 1536 |
*/ |
| 1537 |
private static function losslessCompressWebp( $filepath, $backup_path, $preferred_engine = '' ) { |
| 1538 |
self::ensureCompressTargetFromBackup( $filepath, $backup_path ); |
| 1539 |
$read_path = self::getCompressReadPath( $filepath, $backup_path ); |
| 1540 |
$original_bytes = (int) filesize( $read_path ); |
| 1541 |
if ( $original_bytes <= 0 ) { |
| 1542 |
return false; |
| 1543 |
} |
| 1544 |
|
| 1545 |
$temp_path = self::getImageTempWritePath( $filepath ) . '.' . bin2hex( random_bytes( 4 ) ); |
| 1546 |
@unlink( $temp_path ); |
| 1547 |
$method = null; |
| 1548 |
|
| 1549 |
if ( $preferred_engine === 'gd' ) { |
| 1550 |
if ( self::compressWebpWithGd( $read_path, $temp_path ) ) { |
| 1551 |
$method = 'gd'; |
| 1552 |
} |
| 1553 |
} elseif ( $preferred_engine === 'imagick' ) { |
| 1554 |
if ( self::compressWebpWithImagick( $read_path, $temp_path ) ) { |
| 1555 |
$method = 'imagick'; |
| 1556 |
} |
| 1557 |
} elseif ( self::compressWebpWithGd( $read_path, $temp_path ) ) { |
| 1558 |
$method = 'gd'; |
| 1559 |
} elseif ( self::compressWebpWithImagick( $read_path, $temp_path ) ) { |
| 1560 |
$method = 'imagick'; |
| 1561 |
} else { |
| 1562 |
@unlink( $temp_path ); |
| 1563 |
return false; |
| 1564 |
} |
| 1565 |
|
| 1566 |
$compressed_bytes = (int) filesize( $temp_path ); |
| 1567 |
if ( $compressed_bytes <= 0 ) { |
| 1568 |
@unlink( $temp_path ); |
| 1569 |
return false; |
| 1570 |
} |
| 1571 |
|
| 1572 |
if ( $compressed_bytes < $original_bytes ) { |
| 1573 |
$ok = self::replaceImageFileFromTemp( $filepath, $temp_path ); |
| 1574 |
if ( ! $ok ) { |
| 1575 |
@unlink( $temp_path ); |
| 1576 |
return false; |
| 1577 |
} |
| 1578 |
|
| 1579 |
$saved = $original_bytes - $compressed_bytes; |
| 1580 |
$pct = round( ( $saved / $original_bytes ) * 100, 2 ); |
| 1581 |
self::logCompressImageAction( 'webp', $method, 'lossless, saved ' . $saved . ' bytes (' . $pct . '%)' ); |
| 1582 |
|
| 1583 |
return true; |
| 1584 |
} |
| 1585 |
|
| 1586 |
@unlink( $temp_path ); |
| 1587 |
self::logLosslessCompressImageNote( |
| 1588 |
'WebP ' . $method . ' output not smaller (' . $compressed_bytes . ' vs ' . $original_bytes . ' bytes), keeping original' |
| 1589 |
); |
| 1590 |
|
| 1591 |
return true; |
| 1592 |
} |
| 1593 |
|
| 1594 |
/** |
| 1595 |
* Re-encode WebP via WordPress image editor (fallback when direct Imagick/GD paths fail). |
| 1596 |
* |
| 1597 |
* @param WP_Image_Editor $editor Image editor instance. |
| 1598 |
* @param string $filepath Absolute path to the image. |
| 1599 |
* @return bool |
| 1600 |
*/ |
| 1601 |
private static function losslessCompressWebpWpEditor( $editor, $filepath ) { |
| 1602 |
$temp_path = self::getImageTempWritePath( $filepath ); |
| 1603 |
@unlink( $temp_path ); |
| 1604 |
|
| 1605 |
$editor->set_quality( 100 ); |
| 1606 |
$saved = $editor->save( $temp_path, 'image/webp' ); |
| 1607 |
if ( is_wp_error( $saved ) ) { |
| 1608 |
@unlink( $temp_path ); |
| 1609 |
self::logLosslessCompressImageNote( 'wp_image_editor WebP save error: ' . $saved->get_error_message() ); |
| 1610 |
return false; |
| 1611 |
} |
| 1612 |
|
| 1613 |
$output_path = $temp_path; |
| 1614 |
if ( is_array( $saved ) && ! empty( $saved['path'] ) && is_file( $saved['path'] ) ) { |
| 1615 |
$output_path = $saved['path']; |
| 1616 |
} |
| 1617 |
|
| 1618 |
$ok = self::replaceImageFileFromTemp( $filepath, $output_path ); |
| 1619 |
if ( $output_path !== $filepath && is_file( $output_path ) ) { |
| 1620 |
@unlink( $output_path ); |
| 1621 |
} |
| 1622 |
@unlink( $temp_path ); |
| 1623 |
|
| 1624 |
return $ok; |
| 1625 |
} |
| 1626 |
|
| 1627 |
/** |
| 1628 |
* Losslessly compress an image file in place. |
| 1629 |
* |
| 1630 |
* Rewrites the file on disk with metadata stripped and format-specific lossless |
| 1631 |
* settings (max PNG/WebP deflate, high-quality JPEG). Used before media insert so |
| 1632 |
* AI-generated images are smaller without visible quality loss. |
| 1633 |
* |
| 1634 |
* Flow: |
| 1635 |
* 1. Validate file and resolve format (jpg|png|webp). |
| 1636 |
* 2. Detect available engine via WordPress image editor (Imagick preferred, GD fallback). |
| 1637 |
* 3. Copy original to a sidecar backup ({filepath}.sheetspilot-compress-backup). |
| 1638 |
* 4. Compress in place with the first matching engine for the format. |
| 1639 |
* 5. Verify output is non-empty; on failure restore backup and return false. |
| 1640 |
* 6. On success remove the sidecar backup. |
| 1641 |
* |
| 1642 |
* Compression by format: |
| 1643 |
* - PNG — Imagick / GD / WP image editor; temp-file write to avoid zero-byte overwrites. |
| 1644 |
* - WebP — GD (lossless) / Imagick (lossless); |
| 1645 |
* always written via temp file; original kept when output is not smaller. |
| 1646 |
* - JPEG — Imagick or GD re-encode at LOSSLESS_JPEG_QUALITY (metadata stripped). |
| 1647 |
* |
| 1648 |
* @param string $filepath Absolute path to the image. |
| 1649 |
* @param string|null $ext Optional format hint (jpg|png|webp); auto-detected when omitted. |
| 1650 |
* @return bool True when compression ran and produced a valid file; false when skipped |
| 1651 |
* (missing file, unknown format, no supported engine, backup failure, |
| 1652 |
* or compression yields a zero-byte / missing file — original is restored). |
| 1653 |
*/ |
| 1654 |
private static function losslessCompressImage( $filepath, $ext = null, $preferred_engine = '' ) { |
| 1655 |
|
| 1656 |
// --- Early exit: file must exist on disk --- |
| 1657 |
if ( ! is_file( $filepath ) ) { |
| 1658 |
return false; |
| 1659 |
} |
| 1660 |
|
| 1661 |
$preferred_engine = in_array( $preferred_engine, array( 'gd', 'imagick' ), true ) ? $preferred_engine : ''; |
| 1662 |
|
| 1663 |
// --- Resolve target format (caller hint or mime/extension detection) --- |
| 1664 |
if ( $ext === null || $ext === '' ) { |
| 1665 |
$ext = self::detectImageFormatExt( $filepath ); |
| 1666 |
} |
| 1667 |
if ( $ext === 'jpeg' ) { |
| 1668 |
$ext = 'jpg'; |
| 1669 |
} |
| 1670 |
|
| 1671 |
self::$lastCompressEngine = ''; |
| 1672 |
|
| 1673 |
$format_label = ( $ext !== '' ) ? $ext : 'unknown'; |
| 1674 |
$size_before = (int) filesize( $filepath ); |
| 1675 |
self::logLosslessCompressImageNote( |
| 1676 |
'format ' . $format_label . ', size before compress: ' . $size_before . ' bytes' |
| 1677 |
); |
| 1678 |
|
| 1679 |
// --- Pick compression engine (Imagick when available, otherwise GD) --- |
| 1680 |
self::beginPreferredCompressEngine( $preferred_engine ); |
| 1681 |
$editor = wp_get_image_editor( $filepath ); |
| 1682 |
if ( is_wp_error( $editor ) ) { |
| 1683 |
self::endPreferredCompressEngine(); |
| 1684 |
return false; |
| 1685 |
} |
| 1686 |
|
| 1687 |
$is_imagick = ( $editor instanceof WP_Image_Editor_Imagick ); |
| 1688 |
$is_gd = ( $editor instanceof WP_Image_Editor_GD ); |
| 1689 |
|
| 1690 |
if ( $preferred_engine === 'gd' ) { |
| 1691 |
$is_imagick = false; |
| 1692 |
$is_gd = self::isGdExtensionAvailable(); |
| 1693 |
} elseif ( $preferred_engine === 'imagick' ) { |
| 1694 |
$is_gd = false; |
| 1695 |
$is_imagick = self::isImagickExtensionAvailable(); |
| 1696 |
} |
| 1697 |
|
| 1698 |
// --- Safety: backup original before any in-place write --- |
| 1699 |
$backup_path = self::createCompressBackup( $filepath ); |
| 1700 |
if ( $backup_path === '' ) { |
| 1701 |
self::endPreferredCompressEngine(); |
| 1702 |
return false; |
| 1703 |
} |
| 1704 |
$compressed = false; |
| 1705 |
|
| 1706 |
// --- Format-specific lossless compression (first matching engine wins) --- |
| 1707 |
if ( $ext === 'png' ) { |
| 1708 |
if ( $preferred_engine !== 'gd' && $is_imagick && self::imagickSupportsPng() ) { |
| 1709 |
if ( self::losslessCompressPngImagick( $filepath, $backup_path ) ) { |
| 1710 |
self::logCompressImageAction( 'png', 'imagick', 'lossless level ' . self::PNG_COMPRESSION_LEVEL ); |
| 1711 |
$compressed = true; |
| 1712 |
} |
| 1713 |
} |
| 1714 |
if ( ! $compressed && $preferred_engine !== 'imagick' && $is_gd && self::gdSupportsPng() ) { |
| 1715 |
if ( self::losslessCompressPngGd( $filepath ) ) { |
| 1716 |
self::logCompressImageAction( 'png', 'gd', 'lossless level ' . self::PNG_COMPRESSION_LEVEL ); |
| 1717 |
$compressed = true; |
| 1718 |
} |
| 1719 |
} |
| 1720 |
if ( ! $compressed && $preferred_engine === '' && ! is_wp_error( $editor ) ) { |
| 1721 |
if ( self::losslessCompressPngWpEditor( $editor, $filepath ) ) { |
| 1722 |
self::logCompressImageAction( 'png', 'wp_image_editor', 'level ' . self::PNG_COMPRESSION_LEVEL ); |
| 1723 |
$compressed = true; |
| 1724 |
} |
| 1725 |
} |
| 1726 |
if ( ! $compressed ) { |
| 1727 |
self::logLosslessCompressImageNote( |
| 1728 |
'no working PNG compress engine on this server (imagick png=' . ( self::imagickSupportsPng() ? 'yes' : 'no' ) |
| 1729 |
. ', gd png=' . ( self::gdSupportsPng() ? 'yes' : 'no' ) . ')' |
| 1730 |
); |
| 1731 |
} |
| 1732 |
} elseif ( $ext === 'webp' ) { |
| 1733 |
if ( self::losslessCompressWebp( $filepath, $backup_path, $preferred_engine ) ) { |
| 1734 |
$compressed = true; |
| 1735 |
} |
| 1736 |
if ( ! $compressed && $preferred_engine === '' && ! is_wp_error( $editor ) ) { |
| 1737 |
if ( self::losslessCompressWebpWpEditor( $editor, $filepath ) ) { |
| 1738 |
self::logCompressImageAction( 'webp', 'wp_image_editor', 'quality 100' ); |
| 1739 |
$compressed = true; |
| 1740 |
} |
| 1741 |
} |
| 1742 |
if ( ! $compressed ) { |
| 1743 |
self::logLosslessCompressImageNote( |
| 1744 |
'no working WebP compress engine on this server (gd webp=' . ( self::gdSupportsWebp() ? 'yes' : 'no' ) |
| 1745 |
. ', imagick webp=' . ( self::imagickSupportsWebp() ? 'yes' : 'no' ) . ')' |
| 1746 |
); |
| 1747 |
} |
| 1748 |
} elseif ( in_array( $ext, array( 'jpg', 'jpeg' ), true ) ) { |
| 1749 |
$jpeg_quality = self::LOSSLESS_JPEG_QUALITY; |
| 1750 |
|
| 1751 |
if ( $preferred_engine !== 'gd' && $is_imagick && class_exists( 'Imagick' ) ) { |
| 1752 |
$image = new Imagick( $filepath ); |
| 1753 |
$image->setImageCompression( Imagick::COMPRESSION_JPEG ); |
| 1754 |
$image->setImageCompressionQuality( $jpeg_quality ); |
| 1755 |
$image->stripImage(); |
| 1756 |
$image->writeImage( $filepath ); |
| 1757 |
$image->clear(); |
| 1758 |
$image->destroy(); |
| 1759 |
|
| 1760 |
self::logCompressImageAction( 'jpg', 'imagick', 'quality ' . $jpeg_quality ); |
| 1761 |
$compressed = true; |
| 1762 |
} elseif ( $preferred_engine !== 'imagick' && $is_gd && function_exists( 'imagecreatefromjpeg' ) ) { |
| 1763 |
$image = @imagecreatefromjpeg( $filepath ); |
| 1764 |
if ( $image !== false ) { |
| 1765 |
imagejpeg( $image, $filepath, $jpeg_quality ); |
| 1766 |
imagedestroy( $image ); |
| 1767 |
|
| 1768 |
self::logCompressImageAction( 'jpg', 'gd', 'quality ' . $jpeg_quality ); |
| 1769 |
$compressed = true; |
| 1770 |
} |
| 1771 |
} |
| 1772 |
} |
| 1773 |
|
| 1774 |
// --- No engine ran: discard unused backup and leave file unchanged --- |
| 1775 |
if ( ! $compressed ) { |
| 1776 |
self::removeCompressBackup( $backup_path ); |
| 1777 |
self::endPreferredCompressEngine(); |
| 1778 |
return false; |
| 1779 |
} |
| 1780 |
|
| 1781 |
// --- Validate output size; restore original when compression yields an empty file --- |
| 1782 |
if ( ! self::assertValidCompressedImage( $filepath, $backup_path, $format_label ) ) { |
| 1783 |
self::endPreferredCompressEngine(); |
| 1784 |
return false; |
| 1785 |
} |
| 1786 |
|
| 1787 |
$size_after = is_file( $filepath ) ? (int) filesize( $filepath ) : 0; |
| 1788 |
self::logLosslessCompressImageNote( |
| 1789 |
'format ' . $format_label . ', size after compress: ' . $size_after . ' bytes' |
| 1790 |
); |
| 1791 |
|
| 1792 |
self::endPreferredCompressEngine(); |
| 1793 |
|
| 1794 |
return true; |
| 1795 |
} |
| 1796 |
|
| 1797 |
/** |
| 1798 |
* Restrict WordPress image editor choice while a preferred engine is active. |
| 1799 |
* |
| 1800 |
* @param string[] $editors Editor class names. |
| 1801 |
* @return string[] |
| 1802 |
*/ |
| 1803 |
public static function filterWpImageEditorsForCompress( $editors ) { |
| 1804 |
if ( self::$compressPreferredEngine === 'gd' ) { |
| 1805 |
return array( 'WP_Image_Editor_GD' ); |
| 1806 |
} |
| 1807 |
if ( self::$compressPreferredEngine === 'imagick' ) { |
| 1808 |
return array( 'WP_Image_Editor_Imagick' ); |
| 1809 |
} |
| 1810 |
|
| 1811 |
return $editors; |
| 1812 |
} |
| 1813 |
|
| 1814 |
/** |
| 1815 |
* Begin forcing a preferred compression engine for wp_get_image_editor(). |
| 1816 |
* |
| 1817 |
* @param string $preferred_engine gd|imagick|''. |
| 1818 |
*/ |
| 1819 |
private static function beginPreferredCompressEngine( $preferred_engine ) { |
| 1820 |
self::$compressPreferredEngine = in_array( $preferred_engine, array( 'gd', 'imagick' ), true ) ? $preferred_engine : ''; |
| 1821 |
if ( self::$compressPreferredEngine !== '' ) { |
| 1822 |
add_filter( 'wp_image_editors', array( __CLASS__, 'filterWpImageEditorsForCompress' ), 999 ); |
| 1823 |
} |
| 1824 |
} |
| 1825 |
|
| 1826 |
/** |
| 1827 |
* Stop forcing a preferred compression engine. |
| 1828 |
*/ |
| 1829 |
private static function endPreferredCompressEngine() { |
| 1830 |
if ( self::$compressPreferredEngine !== '' ) { |
| 1831 |
remove_filter( 'wp_image_editors', array( __CLASS__, 'filterWpImageEditorsForCompress' ), 999 ); |
| 1832 |
} |
| 1833 |
self::$compressPreferredEngine = ''; |
| 1834 |
} |
| 1835 |
|
| 1836 |
/** |
| 1837 |
* Normalize a Prompt Tester engine choice against installed libraries. |
| 1838 |
* |
| 1839 |
* @param string $preferred_engine Requested engine (auto|gd|imagick). |
| 1840 |
* @param array $library_status Output from getCompressionLibraryStatus(). |
| 1841 |
* @return string gd|imagick|'' (auto). |
| 1842 |
*/ |
| 1843 |
private static function normalizeCompressPreferredEngine( $preferred_engine, $library_status ) { |
| 1844 |
$preferred_engine = sanitize_key( (string) $preferred_engine ); |
| 1845 |
if ( $preferred_engine === '' || $preferred_engine === 'auto' ) { |
| 1846 |
return ''; |
| 1847 |
} |
| 1848 |
|
| 1849 |
$gd_installed = ! empty( $library_status['gd_installed'] ); |
| 1850 |
$imagick_installed = ! empty( $library_status['imagick_installed'] ); |
| 1851 |
|
| 1852 |
if ( $preferred_engine === 'gd' ) { |
| 1853 |
if ( ! $gd_installed ) { |
| 1854 |
SheetsPilotFunctions::throwError( __( 'GD is not installed on this server.', 'sheetspilot' ) ); |
| 1855 |
} |
| 1856 |
return 'gd'; |
| 1857 |
} |
| 1858 |
|
| 1859 |
if ( $preferred_engine === 'imagick' ) { |
| 1860 |
if ( ! $imagick_installed ) { |
| 1861 |
SheetsPilotFunctions::throwError( __( 'Imagick is not installed on this server.', 'sheetspilot' ) ); |
| 1862 |
} |
| 1863 |
return 'imagick'; |
| 1864 |
} |
| 1865 |
|
| 1866 |
SheetsPilotFunctions::throwError( __( 'Invalid compression library selection.', 'sheetspilot' ) ); |
| 1867 |
} |
| 1868 |
|
| 1869 |
/** |
| 1870 |
* Resolve a media attachment ID from an editor table snapshot. |
| 1871 |
* |
| 1872 |
* @param array $table_data Client table payload. |
| 1873 |
* @return int Attachment ID or 0 when none. |
| 1874 |
*/ |
| 1875 |
public static function resolveAttachmentIdFromTableData( $table_data ) { |
| 1876 |
$attachment_id = 0; |
| 1877 |
|
| 1878 |
if ( ! empty( $table_data['imageAttachmentId'] ) ) { |
| 1879 |
$attachment_id = absint( $table_data['imageAttachmentId'] ); |
| 1880 |
} |
| 1881 |
|
| 1882 |
$current_value = isset( $table_data['value'] ) ? (string) $table_data['value'] : ''; |
| 1883 |
if ( $current_value !== '' ) { |
| 1884 |
if ( preg_match( '/^\d+$/', trim( $current_value ) ) ) { |
| 1885 |
$attachment_id = absint( trim( $current_value ) ); |
| 1886 |
} elseif ( preg_match( '/data-id\s*=\s*["\'](\d+)["\']/i', $current_value, $id_m ) ) { |
| 1887 |
$attachment_id = absint( $id_m[1] ); |
| 1888 |
} |
| 1889 |
} |
| 1890 |
|
| 1891 |
if ( ! empty( $table_data['imageIsPlaceholder'] ) && $attachment_id <= 0 ) { |
| 1892 |
return 0; |
| 1893 |
} |
| 1894 |
|
| 1895 |
if ( $attachment_id > 0 && get_post_type( $attachment_id ) !== 'attachment' ) { |
| 1896 |
return 0; |
| 1897 |
} |
| 1898 |
|
| 1899 |
return $attachment_id; |
| 1900 |
} |
| 1901 |
|
| 1902 |
/** |
| 1903 |
* Whether the PHP GD extension is available (same criteria as WP_Image_Editor_GD::test). |
| 1904 |
* |
| 1905 |
* @return bool |
| 1906 |
*/ |
| 1907 |
private static function isGdExtensionAvailable() { |
| 1908 |
return extension_loaded( 'gd' ) && function_exists( 'gd_info' ); |
| 1909 |
} |
| 1910 |
|
| 1911 |
/** |
| 1912 |
* Whether the PHP Imagick extension is available for image processing. |
| 1913 |
* |
| 1914 |
* @return bool |
| 1915 |
*/ |
| 1916 |
private static function isImagickExtensionAvailable() { |
| 1917 |
if ( ! extension_loaded( 'imagick' ) || ! class_exists( 'Imagick', false ) ) { |
| 1918 |
return false; |
| 1919 |
} |
| 1920 |
|
| 1921 |
$version = phpversion( 'imagick' ); |
| 1922 |
if ( $version !== false && version_compare( $version, '2.2.0', '<' ) ) { |
| 1923 |
return false; |
| 1924 |
} |
| 1925 |
|
| 1926 |
return true; |
| 1927 |
} |
| 1928 |
|
| 1929 |
/** |
| 1930 |
* Whether GD and/or Imagick are available for image compression. |
| 1931 |
* |
| 1932 |
* @return array{gd_installed:bool,imagick_installed:bool,has_any:bool,wp_preferred:string} |
| 1933 |
*/ |
| 1934 |
public static function getCompressionLibraryStatus() { |
| 1935 |
$gd_installed = self::isGdExtensionAvailable(); |
| 1936 |
$imagick_installed = self::isImagickExtensionAvailable(); |
| 1937 |
$wp_preferred = ''; |
| 1938 |
|
| 1939 |
if ( $imagick_installed ) { |
| 1940 |
$wp_preferred = 'imagick'; |
| 1941 |
} elseif ( $gd_installed ) { |
| 1942 |
$wp_preferred = 'gd'; |
| 1943 |
} |
| 1944 |
|
| 1945 |
return array( |
| 1946 |
'gd_installed' => $gd_installed, |
| 1947 |
'imagick_installed' => $imagick_installed, |
| 1948 |
'has_any' => ( $gd_installed || $imagick_installed ), |
| 1949 |
'wp_preferred' => $wp_preferred, |
| 1950 |
); |
| 1951 |
} |
| 1952 |
|
| 1953 |
/** |
| 1954 |
* Copy an attachment file to uploads for a Prompt Tester before-preview URL. |
| 1955 |
* |
| 1956 |
* @param string $filepath Source file path. |
| 1957 |
* @param int $attachment_id Attachment post ID. |
| 1958 |
* @param string $ext File extension without dot. |
| 1959 |
* @return string Public URL to the copied file. |
| 1960 |
*/ |
| 1961 |
private static function createPromptTesterBeforePreviewUrl( $filepath, $attachment_id, $ext ) { |
| 1962 |
$upload_dir = wp_upload_dir(); |
| 1963 |
if ( ! empty( $upload_dir['error'] ) ) { |
| 1964 |
SheetsPilotFunctions::throwError( __( 'Upload directory is not writable.', 'sheetspilot' ) ); |
| 1965 |
} |
| 1966 |
|
| 1967 |
$safe_ext = preg_replace( '/[^a-z0-9]/i', '', (string) $ext ); |
| 1968 |
if ( $safe_ext === '' ) { |
| 1969 |
$safe_ext = 'jpg'; |
| 1970 |
} |
| 1971 |
|
| 1972 |
$filename = 'sheetspilot-compress-before-' . absint( $attachment_id ) . '-' . wp_generate_password( 8, false ) . '.' . $safe_ext; |
| 1973 |
$dest = trailingslashit( $upload_dir['path'] ) . $filename; |
| 1974 |
|
| 1975 |
if ( ! @copy( $filepath, $dest ) ) { |
| 1976 |
SheetsPilotFunctions::throwError( __( 'Failed to create before-preview copy.', 'sheetspilot' ) ); |
| 1977 |
} |
| 1978 |
|
| 1979 |
return trailingslashit( $upload_dir['url'] ) . $filename; |
| 1980 |
} |
| 1981 |
|
| 1982 |
/** |
| 1983 |
* Compress an attachment for the Prompt Tester and return before/after preview data. |
| 1984 |
* |
| 1985 |
* @param int $attachment_id Attachment post ID. |
| 1986 |
* @param string $preferred_engine Optional gd|imagick|auto. |
| 1987 |
* @return array<string,mixed> |
| 1988 |
*/ |
| 1989 |
public static function compressAttachmentImageForPromptTester( $attachment_id, $preferred_engine = '' ) { |
| 1990 |
$library_status = self::getCompressionLibraryStatus(); |
| 1991 |
if ( empty( $library_status['has_any'] ) ) { |
| 1992 |
SheetsPilotFunctions::throwError( |
| 1993 |
__( 'No image library is installed. Please install GD or Imagick (ImageMagick) on your server.', 'sheetspilot' ) |
| 1994 |
); |
| 1995 |
} |
| 1996 |
|
| 1997 |
$preferred_engine = self::normalizeCompressPreferredEngine( $preferred_engine, $library_status ); |
| 1998 |
|
| 1999 |
$attachment_id = absint( $attachment_id ); |
| 2000 |
if ( $attachment_id <= 0 ) { |
| 2001 |
SheetsPilotFunctions::throwError( __( 'No image attachment found.', 'sheetspilot' ) ); |
| 2002 |
} |
| 2003 |
|
| 2004 |
$filepath = get_attached_file( $attachment_id ); |
| 2005 |
if ( ! is_string( $filepath ) || $filepath === '' || ! is_file( $filepath ) ) { |
| 2006 |
SheetsPilotFunctions::throwError( __( 'Attachment file not found on disk.', 'sheetspilot' ) ); |
| 2007 |
} |
| 2008 |
|
| 2009 |
$ext = self::detectImageFormatExt( $filepath ); |
| 2010 |
if ( $ext === 'jpeg' ) { |
| 2011 |
$ext = 'jpg'; |
| 2012 |
} |
| 2013 |
if ( $ext === '' ) { |
| 2014 |
SheetsPilotFunctions::throwError( __( 'Unsupported image format.', 'sheetspilot' ) ); |
| 2015 |
} |
| 2016 |
|
| 2017 |
$size_before = (int) filesize( $filepath ); |
| 2018 |
$before_url = self::createPromptTesterBeforePreviewUrl( $filepath, $attachment_id, $ext ); |
| 2019 |
|
| 2020 |
self::$lastCompressEngine = ''; |
| 2021 |
$result = self::compressAttachmentImage( $attachment_id, $preferred_engine ); |
| 2022 |
|
| 2023 |
$after_url = self::resolveAttachmentThumbnailUrl( $attachment_id, 'full' ); |
| 2024 |
if ( $after_url === '' && ! empty( $result['thumbnail_url'] ) ) { |
| 2025 |
$after_url = (string) $result['thumbnail_url']; |
| 2026 |
} |
| 2027 |
if ( $after_url === '' && is_string( $before_url ) && $before_url !== '' ) { |
| 2028 |
$after_url = $before_url; |
| 2029 |
} |
| 2030 |
if ( ! empty( $result['cache_bust'] ) && $after_url !== '' ) { |
| 2031 |
$after_url = add_query_arg( 'v', (int) $result['cache_bust'], $after_url ); |
| 2032 |
} |
| 2033 |
|
| 2034 |
$engine = self::$lastCompressEngine !== '' ? self::$lastCompressEngine : 'none'; |
| 2035 |
$size_after = isset( $result['size_after'] ) ? (int) $result['size_after'] : (int) filesize( $filepath ); |
| 2036 |
$size_saved = max( 0, $size_before - $size_after ); |
| 2037 |
|
| 2038 |
return array( |
| 2039 |
'before_url' => $before_url, |
| 2040 |
'after_url' => $after_url, |
| 2041 |
'size_before' => $size_before, |
| 2042 |
'size_after' => $size_after, |
| 2043 |
'size_saved' => $size_saved, |
| 2044 |
'engine' => $engine, |
| 2045 |
'preferred_engine' => $preferred_engine !== '' ? $preferred_engine : 'auto', |
| 2046 |
'attachment_id' => $attachment_id, |
| 2047 |
'library_status' => $library_status, |
| 2048 |
); |
| 2049 |
} |
| 2050 |
|
| 2051 |
/** |
| 2052 |
* Losslessly compress an existing attachment in place and regenerate thumbnails. |
| 2053 |
* |
| 2054 |
* @param int $attachment_id Attachment post ID. |
| 2055 |
* @param string $preferred_engine Optional gd|imagick|'' for auto. |
| 2056 |
* @return array Preview metadata for the client (same attachment ID and URLs). |
| 2057 |
*/ |
| 2058 |
public static function compressAttachmentImage( $attachment_id, $preferred_engine = '' ) { |
| 2059 |
$attachment_id = absint( $attachment_id ); |
| 2060 |
if ( $attachment_id <= 0 ) { |
| 2061 |
SheetsPilotFunctions::throwError( __( 'No image attachment found.', 'sheetspilot' ) ); |
| 2062 |
} |
| 2063 |
|
| 2064 |
$filepath = get_attached_file( $attachment_id ); |
| 2065 |
if ( ! is_string( $filepath ) || $filepath === '' || ! is_file( $filepath ) ) { |
| 2066 |
SheetsPilotFunctions::throwError( __( 'Attachment file not found on disk.', 'sheetspilot' ) ); |
| 2067 |
} |
| 2068 |
|
| 2069 |
$size_before = (int) filesize( $filepath ); |
| 2070 |
$ext = self::detectImageFormatExt( $filepath ); |
| 2071 |
if ( $ext === 'jpeg' ) { |
| 2072 |
$ext = 'jpg'; |
| 2073 |
} |
| 2074 |
|
| 2075 |
if ( $ext !== '' ) { |
| 2076 |
self::losslessCompressImage( $filepath, $ext, $preferred_engine ); |
| 2077 |
} |
| 2078 |
|
| 2079 |
if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) { |
| 2080 |
require_once ABSPATH . 'wp-admin/includes/image.php'; |
| 2081 |
} |
| 2082 |
|
| 2083 |
$attach_data = wp_generate_attachment_metadata( $attachment_id, $filepath ); |
| 2084 |
if ( is_array( $attach_data ) && ! empty( $attach_data ) ) { |
| 2085 |
wp_update_attachment_metadata( $attachment_id, $attach_data ); |
| 2086 |
} |
| 2087 |
|
| 2088 |
$thumbnail_url = self::resolveAttachmentThumbnailUrl( $attachment_id, 'medium' ); |
| 2089 |
|
| 2090 |
$response = self::buildPromotePendingResponse( $attachment_id, $thumbnail_url ); |
| 2091 |
$response['size_before'] = $size_before; |
| 2092 |
$response['size_after'] = (int) filesize( $filepath ); |
| 2093 |
$response['cache_bust'] = (int) @filemtime( $filepath ); |
| 2094 |
$response['engine'] = self::$lastCompressEngine !== '' ? self::$lastCompressEngine : 'none'; |
| 2095 |
|
| 2096 |
return $response; |
| 2097 |
} |
| 2098 |
|
| 2099 |
/** |
| 2100 |
* Prepare an AI-generated image for WordPress media library insert. |
| 2101 |
* |
| 2102 |
* Runs the full pre-insert pipeline on a file already saved to disk: |
| 2103 |
* lossless compression, optional large PNG/WebP → JPEG conversion, then a |
| 2104 |
* final size guard. The returned path may differ from the input when a |
| 2105 |
* large PNG/WebP is converted to .jpg. |
| 2106 |
* |
| 2107 |
* Flow: |
| 2108 |
* 1. Validate path and log original file size (prompt request metadata). |
| 2109 |
* 2. Detect format (jpg|png|webp) and run losslessCompressImage() in place. |
| 2110 |
* 3. If PNG/WebP exceeds LARGE_PNG_TO_JPEG_BYTES (800 KB), convert to JPEG. |
| 2111 |
* 4. When conversion happened, lossless-compress the new JPEG as well. |
| 2112 |
* 5. Log final size; return original path when the file is missing or zero bytes. |
| 2113 |
* |
| 2114 |
* @param string $filepath Absolute path to the image file. |
| 2115 |
* @return string Path to use for attachment insert (unchanged, or .jpg after conversion). |
| 2116 |
* Returns the input unchanged when the file is missing or invalid. |
| 2117 |
*/ |
| 2118 |
public static function compressImageForMedia( $filepath ) { |
| 2119 |
|
| 2120 |
// --- Early exit: nothing to process --- |
| 2121 |
if ( ! is_string( $filepath ) || $filepath === '' || ! is_file( $filepath ) ) { |
| 2122 |
return $filepath; |
| 2123 |
} |
| 2124 |
|
| 2125 |
// --- Log pipeline start size (overall, before any step) --- |
| 2126 |
$size_before = (int) filesize( $filepath ); |
| 2127 |
SheetsPilot_Prompts::addNoteToLastPromptRequestMetadata( |
| 2128 |
'image size before compress: ' . $size_before . ' bytes' |
| 2129 |
); |
| 2130 |
|
| 2131 |
$last_good_filepath = $filepath; |
| 2132 |
|
| 2133 |
// --- Step 1: lossless compress in original format --- |
| 2134 |
$ext = self::detectImageFormatExt( $filepath ); |
| 2135 |
if ( $ext === 'jpeg' ) { |
| 2136 |
$ext = 'jpg'; |
| 2137 |
} |
| 2138 |
|
| 2139 |
if ( $ext !== '' ) { |
| 2140 |
self::losslessCompressImage( $filepath, $ext ); |
| 2141 |
} |
| 2142 |
$valid_path = self::rememberValidImagePath( $filepath ); |
| 2143 |
if ( $valid_path ) { |
| 2144 |
$last_good_filepath = $valid_path; |
| 2145 |
} |
| 2146 |
|
| 2147 |
// --- Step 2: convert oversized PNG/WebP to JPEG (path may change) --- |
| 2148 |
$filepath_before_convert = $filepath; |
| 2149 |
$filepath = self::convertLargePngOrWebpToJpegBeforeInsert( $filepath ); |
| 2150 |
$valid_path = self::rememberValidImagePath( $filepath ); |
| 2151 |
if ( $valid_path ) { |
| 2152 |
$last_good_filepath = $valid_path; |
| 2153 |
} |
| 2154 |
|
| 2155 |
// --- Step 3: compress the new JPEG when conversion ran --- |
| 2156 |
if ( $filepath !== $filepath_before_convert ) { |
| 2157 |
SheetsPilot_Prompts::addNoteToLastPromptRequestMetadata( |
| 2158 |
'image converted to JPEG (over 800 KB): ' . basename( $filepath ) |
| 2159 |
); |
| 2160 |
self::losslessCompressImage( $filepath, 'jpg' ); |
| 2161 |
$valid_path = self::rememberValidImagePath( $filepath ); |
| 2162 |
if ( $valid_path ) { |
| 2163 |
$last_good_filepath = $valid_path; |
| 2164 |
} |
| 2165 |
} |
| 2166 |
|
| 2167 |
// --- Final guard: file must exist and be non-empty --- |
| 2168 |
$size_after = is_file( $filepath ) ? (int) filesize( $filepath ) : 0; |
| 2169 |
|
| 2170 |
SheetsPilot_Prompts::addNoteToLastPromptRequestMetadata( |
| 2171 |
'image size after compress: ' . $size_after . ' bytes' |
| 2172 |
); |
| 2173 |
|
| 2174 |
if ( $size_after <= 0 ) { |
| 2175 |
self::logLosslessCompressImageNote( 'compress pipeline ended with empty file; keeping last known good file' ); |
| 2176 |
if ( is_file( $last_good_filepath ) && (int) filesize( $last_good_filepath ) > 0 ) { |
| 2177 |
return $last_good_filepath; |
| 2178 |
} |
| 2179 |
} |
| 2180 |
|
| 2181 |
return $filepath; |
| 2182 |
} |
| 2183 |
|
| 2184 |
/** |
| 2185 |
* Append a losslessCompressImage note to the last prompt request metadata. |
| 2186 |
* |
| 2187 |
* @param string $message Log detail (format, sizes, restore status, etc.). |
| 2188 |
*/ |
| 2189 |
private static function logLosslessCompressImageNote( $message ) { |
| 2190 |
SheetsPilot_Prompts::addNoteToLastPromptRequestMetadata( 'losslessCompressImage: ' . $message ); |
| 2191 |
} |
| 2192 |
|
| 2193 |
/** |
| 2194 |
* Append a compress-image action note to the last prompt request metadata. |
| 2195 |
* |
| 2196 |
* @param string $format jpg|png|webp. |
| 2197 |
* @param string $engine imagick|gd. |
| 2198 |
* @param string $detail Optional extra detail. |
| 2199 |
*/ |
| 2200 |
private static function logCompressImageAction( $format, $engine, $detail = '' ) { |
| 2201 |
self::$lastCompressEngine = (string) $engine; |
| 2202 |
$message = 'compressImageForMedia ' . $format . ': engine ' . $engine; |
| 2203 |
if ( $detail !== '' ) { |
| 2204 |
$message .= ', ' . $detail; |
| 2205 |
} |
| 2206 |
SheetsPilot_Prompts::addNoteToLastPromptRequestMetadata( $message ); |
| 2207 |
} |
| 2208 |
|
| 2209 |
/** |
| 2210 |
* Append a changeImageQuality action note to the last prompt request metadata. |
| 2211 |
* |
| 2212 |
* @param string $format jpg|png|webp. |
| 2213 |
* @param string $level low|medium|high. |
| 2214 |
* @param string $engine imagick|gd|wp_image_editor. |
| 2215 |
* @param string $detail Optional extra detail (numeric quality, compression, etc.). |
| 2216 |
*/ |
| 2217 |
private static function logChangeImageQualityAction( $format, $level, $engine, $detail = '' ) { |
| 2218 |
$message = 'changeImageQuality ' . $format . ': level ' . $level . ', engine ' . $engine; |
| 2219 |
if ( $detail !== '' ) { |
| 2220 |
$message .= ', ' . $detail; |
| 2221 |
} |
| 2222 |
SheetsPilot_Prompts::addNoteToLastPromptRequestMetadata( $message ); |
| 2223 |
} |
| 2224 |
|
| 2225 |
/** |
| 2226 |
* change image quality |
| 2227 |
*/ |
| 2228 |
public static function changeImageQuality($filepath, $level = 'medium', $target_format = null) |
| 2229 |
{ |
| 2230 |
$editor = wp_get_image_editor($filepath); |
| 2231 |
|
| 2232 |
$is_imagick = ($editor instanceof WP_Image_Editor_Imagick); |
| 2233 |
$is_gd = ($editor instanceof WP_Image_Editor_GD); |
| 2234 |
|
| 2235 |
if (is_wp_error($editor)) { |
| 2236 |
return $editor; |
| 2237 |
} |
| 2238 |
|
| 2239 |
$level = self::resolveApiImageQuality( $level ); |
| 2240 |
if ( $level === 'auto' ) { |
| 2241 |
$level = 'medium'; |
| 2242 |
} |
| 2243 |
|
| 2244 |
$ext = $target_format ? strtolower( (string) $target_format ) : self::detectImageFormatExt( $filepath ); |
| 2245 |
if ( $ext === 'jpeg' ) { |
| 2246 |
$ext = 'jpg'; |
| 2247 |
} |
| 2248 |
|
| 2249 |
// Quality level mapping. |
| 2250 |
$quality_map = [ |
| 2251 |
'low' => 40, |
| 2252 |
'medium' => 70, |
| 2253 |
'high' => 90, |
| 2254 |
]; |
| 2255 |
|
| 2256 |
$quality = $quality_map[ $level ] ?? $quality_map['medium']; |
| 2257 |
|
| 2258 |
// JPEG — re-encode directly (WP_Image_Editor::save() alone often leaves size unchanged). |
| 2259 |
if ( in_array( $ext, array( 'jpg', 'jpeg' ), true ) ) { |
| 2260 |
if ( $is_imagick && class_exists( 'Imagick' ) ) { |
| 2261 |
$image = new Imagick( $filepath ); |
| 2262 |
$image->setImageCompression( Imagick::COMPRESSION_JPEG ); |
| 2263 |
$image->setImageCompressionQuality( $quality ); |
| 2264 |
$image->stripImage(); |
| 2265 |
$image->writeImage( $filepath ); |
| 2266 |
$image->clear(); |
| 2267 |
$image->destroy(); |
| 2268 |
|
| 2269 |
self::logChangeImageQualityAction( 'jpg', $level, 'imagick', 'quality ' . $quality ); |
| 2270 |
|
| 2271 |
return true; |
| 2272 |
} |
| 2273 |
|
| 2274 |
if ( $is_gd && function_exists( 'imagecreatefromjpeg' ) ) { |
| 2275 |
$image = @imagecreatefromjpeg( $filepath ); |
| 2276 |
if ( $image !== false ) { |
| 2277 |
$gd_quality = max( 10, $quality - 10 ); |
| 2278 |
imagejpeg( $image, $filepath, $gd_quality ); |
| 2279 |
imagedestroy( $image ); |
| 2280 |
|
| 2281 |
self::logChangeImageQualityAction( 'jpg', $level, 'gd', 'quality ' . $gd_quality ); |
| 2282 |
|
| 2283 |
return true; |
| 2284 |
} |
| 2285 |
} |
| 2286 |
|
| 2287 |
$loaded = $editor->load(); |
| 2288 |
if ( ! is_wp_error( $loaded ) ) { |
| 2289 |
$editor->set_quality( $quality ); |
| 2290 |
|
| 2291 |
self::logChangeImageQualityAction( 'jpg', $level, 'wp_image_editor', 'quality ' . $quality ); |
| 2292 |
|
| 2293 |
return $editor->save( $filepath ); |
| 2294 |
} |
| 2295 |
} |
| 2296 |
|
| 2297 |
// webp processing |
| 2298 |
if ($ext === 'webp') { |
| 2299 |
if ($is_imagick) { |
| 2300 |
$image = new Imagick($filepath); |
| 2301 |
|
| 2302 |
if ($level == 'low') { |
| 2303 |
// Strong lossy compression. |
| 2304 |
$webp_quality = 40; |
| 2305 |
$image->setImageCompressionQuality( $webp_quality ); |
| 2306 |
} elseif ($level == 'medium') { |
| 2307 |
// Medium compression. |
| 2308 |
$webp_quality = 70; |
| 2309 |
$image->setImageCompressionQuality( $webp_quality ); |
| 2310 |
} else { |
| 2311 |
// High quality. |
| 2312 |
$webp_quality = 85; |
| 2313 |
$image->setImageCompressionQuality( $webp_quality ); |
| 2314 |
} |
| 2315 |
|
| 2316 |
$image->setOption('webp:method', '6'); |
| 2317 |
$image->stripImage(); |
| 2318 |
$image->writeImage($filepath); |
| 2319 |
$image->clear(); |
| 2320 |
$image->destroy(); |
| 2321 |
|
| 2322 |
self::logChangeImageQualityAction( 'webp', $level, 'imagick', 'quality ' . $webp_quality ); |
| 2323 |
|
| 2324 |
return true; |
| 2325 |
} elseif ($is_gd) { |
| 2326 |
|
| 2327 |
$image = imagecreatefromwebp($filepath); |
| 2328 |
|
| 2329 |
// Preserve transparency. |
| 2330 |
imagealphablending($image, false); |
| 2331 |
imagesavealpha($image, true); |
| 2332 |
|
| 2333 |
if ($level == 'low') { |
| 2334 |
$new_quality = 20; |
| 2335 |
} elseif ($level == 'medium') { |
| 2336 |
$new_quality = 60; |
| 2337 |
} else { |
| 2338 |
$new_quality = 100; |
| 2339 |
} |
| 2340 |
|
| 2341 |
imagewebp($image, $filepath, $new_quality); |
| 2342 |
imagedestroy($image); |
| 2343 |
|
| 2344 |
self::logChangeImageQualityAction( 'webp', $level, 'gd', 'quality ' . $new_quality ); |
| 2345 |
|
| 2346 |
return true; |
| 2347 |
} |
| 2348 |
} |
| 2349 |
|
| 2350 |
|
| 2351 |
if ($ext === 'png') { |
| 2352 |
|
| 2353 |
$compression_map = [ |
| 2354 |
'low' => 1, |
| 2355 |
'medium' => 5, |
| 2356 |
'high' => 9, |
| 2357 |
]; |
| 2358 |
|
| 2359 |
$compression = $compression_map[$level] ?? 6; |
| 2360 |
|
| 2361 |
if ($editor instanceof WP_Image_Editor_Imagick) { |
| 2362 |
|
| 2363 |
$image = new Imagick($filepath); |
| 2364 |
// Set compression and quality |
| 2365 |
if ($level == 'low') { |
| 2366 |
$png_colors = 32; |
| 2367 |
$image->quantizeImage( $png_colors, Imagick::COLORSPACE_SRGB, 0, false, false ); |
| 2368 |
} elseif ($level == 'medium') { |
| 2369 |
$png_colors = 128; |
| 2370 |
$image->quantizeImage( $png_colors, Imagick::COLORSPACE_SRGB, 0, false, false ); |
| 2371 |
} else { |
| 2372 |
$png_colors = 256; |
| 2373 |
$image->quantizeImage( $png_colors, Imagick::COLORSPACE_SRGB, 0, false, false ); |
| 2374 |
} |
| 2375 |
|
| 2376 |
$image->setImageCompression(Imagick::COMPRESSION_ZIP); |
| 2377 |
$image->setImageCompressionQuality(95); |
| 2378 |
|
| 2379 |
// Preserve size and format while reducing quality |
| 2380 |
$image->stripImage(); |
| 2381 |
|
| 2382 |
$image->writeImage($filepath); |
| 2383 |
$image->clear(); |
| 2384 |
$image->destroy(); |
| 2385 |
|
| 2386 |
self::logChangeImageQualityAction( 'png', $level, 'imagick', 'colors ' . $png_colors . ', compression ' . $compression ); |
| 2387 |
|
| 2388 |
return; |
| 2389 |
} elseif ($editor instanceof WP_Image_Editor_GD) { |
| 2390 |
|
| 2391 |
$image = imagecreatefrompng($filepath); |
| 2392 |
|
| 2393 |
imagealphablending($image, false); |
| 2394 |
imagesavealpha($image, true); |
| 2395 |
|
| 2396 |
// Reduce color count (quantize). |
| 2397 |
if ($level == 'low') { |
| 2398 |
$png_colors = 32; |
| 2399 |
imagetruecolortopalette($image, true, $png_colors); |
| 2400 |
} elseif ($level == 'medium') { |
| 2401 |
$png_colors = 128; |
| 2402 |
imagetruecolortopalette($image, true, $png_colors); |
| 2403 |
} else { |
| 2404 |
// For PNG, 256 is the palette maximum in GD. |
| 2405 |
$png_colors = 256; |
| 2406 |
imagetruecolortopalette($image, true, $png_colors); |
| 2407 |
} |
| 2408 |
|
| 2409 |
imagepng($image, $filepath, 9); |
| 2410 |
imagedestroy($image); |
| 2411 |
|
| 2412 |
self::logChangeImageQualityAction( 'png', $level, 'gd', 'colors ' . $png_colors . ', compression ' . $compression ); |
| 2413 |
|
| 2414 |
return; |
| 2415 |
} |
| 2416 |
} |
| 2417 |
|
| 2418 |
$new_path = $filepath; |
| 2419 |
|
| 2420 |
if ($target_format) { |
| 2421 |
$new_path = preg_replace('/\.\w+$/', '.' . $target_format, $filepath); |
| 2422 |
} |
| 2423 |
|
| 2424 |
$fallback_format = $ext !== '' ? $ext : 'unknown'; |
| 2425 |
self::logChangeImageQualityAction( $fallback_format, $level, 'wp_image_editor', 'quality ' . $quality ); |
| 2426 |
|
| 2427 |
return $editor->save($new_path); |
| 2428 |
} |
| 2429 |
|
| 2430 |
|
| 2431 |
/** |
| 2432 |
* Convert PNG or WebP images over 800 KB to high-quality JPEG. |
| 2433 |
* |
| 2434 |
* @param string $filepath Absolute path to the image. |
| 2435 |
* @return string Updated filepath (extension may change to .jpg). |
| 2436 |
*/ |
| 2437 |
public static function convertLargePngOrWebpToJpegBeforeInsert( $filepath ) { |
| 2438 |
|
| 2439 |
if ( ! is_string( $filepath ) || $filepath === '' || ! is_file( $filepath ) ) { |
| 2440 |
return $filepath; |
| 2441 |
} |
| 2442 |
|
| 2443 |
if ( (int) filesize( $filepath ) <= self::LARGE_PNG_TO_JPEG_BYTES ) { |
| 2444 |
return $filepath; |
| 2445 |
} |
| 2446 |
|
| 2447 |
$info = wp_check_filetype( $filepath ); |
| 2448 |
$ext = strtolower( (string) ( $info['ext'] ?? '' ) ); |
| 2449 |
$mime = strtolower( (string) ( $info['type'] ?? '' ) ); |
| 2450 |
|
| 2451 |
$is_png = ( $ext === 'png' || $mime === 'image/png' ); |
| 2452 |
$is_webp = ( $ext === 'webp' || $mime === 'image/webp' ); |
| 2453 |
|
| 2454 |
if ( ! $is_png && ! $is_webp ) { |
| 2455 |
return $filepath; |
| 2456 |
} |
| 2457 |
|
| 2458 |
$editor = wp_get_image_editor( $filepath ); |
| 2459 |
if ( is_wp_error( $editor ) ) { |
| 2460 |
return $filepath; |
| 2461 |
} |
| 2462 |
|
| 2463 |
$editor->set_quality( self::LOSSLESS_JPEG_QUALITY ); |
| 2464 |
|
| 2465 |
$jpg_path = preg_replace( '/\.(png|webp)$/i', '.jpg', $filepath ); |
| 2466 |
if ( $jpg_path === $filepath ) { |
| 2467 |
$jpg_path = $filepath . '.jpg'; |
| 2468 |
} |
| 2469 |
|
| 2470 |
$saved = $editor->save( $jpg_path, 'image/jpeg' ); |
| 2471 |
if ( is_wp_error( $saved ) ) { |
| 2472 |
return $filepath; |
| 2473 |
} |
| 2474 |
|
| 2475 |
$new_path = ( is_array( $saved ) && ! empty( $saved['path'] ) ) ? $saved['path'] : $jpg_path; |
| 2476 |
if ( ! is_file( $new_path ) ) { |
| 2477 |
return $filepath; |
| 2478 |
} |
| 2479 |
|
| 2480 |
if ( $new_path !== $filepath && is_file( $filepath ) ) { |
| 2481 |
$pathinfo = pathinfo( $filepath ); |
| 2482 |
$thumb_orig = $pathinfo['dirname'] . '/' . $pathinfo['filename'] . '_thumb.' . $pathinfo['extension']; |
| 2483 |
|
| 2484 |
wp_delete_file( $filepath ); |
| 2485 |
if ( is_file( $thumb_orig ) ) { |
| 2486 |
wp_delete_file( $thumb_orig ); |
| 2487 |
} |
| 2488 |
} |
| 2489 |
|
| 2490 |
return $new_path; |
| 2491 |
} |
| 2492 |
|
| 2493 |
/** |
| 2494 |
* @deprecated Use convertLargePngOrWebpToJpegBeforeInsert(). |
| 2495 |
*/ |
| 2496 |
public static function convertLargePngToJpegBeforeInsert( $filepath ) { |
| 2497 |
return self::convertLargePngOrWebpToJpegBeforeInsert( $filepath ); |
| 2498 |
} |
| 2499 |
|
| 2500 |
/** |
| 2501 |
* Delete pending image files (full + thumb) and transient. |
| 2502 |
* |
| 2503 |
* @param string $request_id Request ID. |
| 2504 |
* @return bool True if deleted or already missing. |
| 2505 |
*/ |
| 2506 |
public static function deletePending($request_id) |
| 2507 |
{ |
| 2508 |
|
| 2509 |
$meta = get_transient(self::TRANSIENT_PREFIX . $request_id); |
| 2510 |
if (is_array($meta)) { |
| 2511 |
if (! empty($meta['path']) && is_file($meta['path'])) { |
| 2512 |
@unlink($meta['path']); |
| 2513 |
} |
| 2514 |
if (! empty($meta['thumb_path']) && is_file($meta['thumb_path'])) { |
| 2515 |
@unlink($meta['thumb_path']); |
| 2516 |
} |
| 2517 |
if (! empty($meta['source_backup_path']) && is_file($meta['source_backup_path'])) { |
| 2518 |
@unlink($meta['source_backup_path']); |
| 2519 |
} |
| 2520 |
} |
| 2521 |
delete_transient(self::TRANSIENT_PREFIX . $request_id); |
| 2522 |
delete_transient(self::PENDING_CONTEXT_PREFIX . $request_id); |
| 2523 |
delete_transient(self::PENDING_RETRIED_PREFIX . $request_id); |
| 2524 |
return true; |
| 2525 |
|
| 2526 |
} |
| 2527 |
|
| 2528 |
/** |
| 2529 |
* Resolve pending metadata for promote: transient, on-disk file, or one regeneration attempt. |
| 2530 |
* |
| 2531 |
* @param string $request_id Request ID. |
| 2532 |
* @param int $post_id Post ID. |
| 2533 |
* @param string $column Column name. |
| 2534 |
* @return array|null |
| 2535 |
*/ |
| 2536 |
private static function resolvePromotePendingMeta( $request_id, $post_id, $column ) { |
| 2537 |
$meta = self::getPending( $request_id ); |
| 2538 |
if ( $meta ) { |
| 2539 |
return $meta; |
| 2540 |
} |
| 2541 |
|
| 2542 |
$meta = self::resolvePendingMetaFromDisk( $request_id, $post_id, $column ); |
| 2543 |
if ( $meta ) { |
| 2544 |
return $meta; |
| 2545 |
} |
| 2546 |
|
| 2547 |
return self::retryPendingImageGenerationOnce( $request_id, $post_id, $column ); |
| 2548 |
} |
| 2549 |
|
| 2550 |
/** |
| 2551 |
* Rebuild pending metadata when the file still exists but the transient expired. |
| 2552 |
* |
| 2553 |
* @param string $request_id Request ID. |
| 2554 |
* @param int $post_id Post ID. |
| 2555 |
* @param string $column Column name. |
| 2556 |
* @return array|null |
| 2557 |
*/ |
| 2558 |
private static function resolvePendingMetaFromDisk( $request_id, $post_id, $column ) { |
| 2559 |
$request_id = sanitize_file_name( (string) $request_id ); |
| 2560 |
if ( $request_id === '' ) { |
| 2561 |
return null; |
| 2562 |
} |
| 2563 |
|
| 2564 |
$dir = self::getPendingDir(); |
| 2565 |
foreach ( array( 'png', 'jpg', 'jpeg', 'webp' ) as $ext ) { |
| 2566 |
$filepath = $dir . '/' . $request_id . '.' . $ext; |
| 2567 |
if ( ! is_file( $filepath ) || (int) filesize( $filepath ) <= 0 ) { |
| 2568 |
continue; |
| 2569 |
} |
| 2570 |
|
| 2571 |
$thumb_ext = ( $ext === 'jpeg' ) ? 'jpg' : $ext; |
| 2572 |
$thumb_path = $dir . '/' . $request_id . '_thumb.' . $thumb_ext; |
| 2573 |
if ( ! is_file( $thumb_path ) ) { |
| 2574 |
$thumb_path = self::createThumbnail( $filepath, $dir, $request_id, $thumb_ext ); |
| 2575 |
} |
| 2576 |
|
| 2577 |
$source_backup_path = $dir . '/' . $request_id . '_source.' . ( ( $ext === 'jpeg' ) ? 'jpg' : $ext ); |
| 2578 |
if ( ! is_file( $source_backup_path ) ) { |
| 2579 |
$source_backup_path = ''; |
| 2580 |
} |
| 2581 |
|
| 2582 |
$baseurl = self::getPendingUrl(); |
| 2583 |
$preview_url = self::resolvePendingPreviewUrl( $baseurl, $thumb_path, $filepath, $source_backup_path ); |
| 2584 |
if ( $preview_url === '' ) { |
| 2585 |
continue; |
| 2586 |
} |
| 2587 |
|
| 2588 |
$file_meta = self::probePendingImageFileMeta( |
| 2589 |
array( |
| 2590 |
$thumb_path, |
| 2591 |
$filepath, |
| 2592 |
$source_backup_path, |
| 2593 |
) |
| 2594 |
); |
| 2595 |
|
| 2596 |
$meta = array( |
| 2597 |
'path' => $filepath, |
| 2598 |
'thumb_path' => $thumb_path, |
| 2599 |
'source_backup_path' => $source_backup_path, |
| 2600 |
'url' => $preview_url, |
| 2601 |
'post_id' => (int) $post_id, |
| 2602 |
'column' => $column, |
| 2603 |
'created' => time(), |
| 2604 |
'quality' => 'default', |
| 2605 |
'file_size' => (int) $file_meta['file_size'], |
| 2606 |
'file_type' => (string) $file_meta['file_type'], |
| 2607 |
'width' => (int) $file_meta['width'], |
| 2608 |
'height' => (int) $file_meta['height'], |
| 2609 |
); |
| 2610 |
set_transient( self::TRANSIENT_PREFIX . $request_id, $meta, self::TRANSIENT_TTL ); |
| 2611 |
|
| 2612 |
return $meta; |
| 2613 |
} |
| 2614 |
|
| 2615 |
return null; |
| 2616 |
} |
| 2617 |
|
| 2618 |
/** |
| 2619 |
* Re-run the original image generation request once, then resolve pending metadata. |
| 2620 |
* |
| 2621 |
* @param string $request_id Request ID. |
| 2622 |
* @param int $post_id Post ID. |
| 2623 |
* @param string $column Column name. |
| 2624 |
* @return array|null |
| 2625 |
*/ |
| 2626 |
private static function retryPendingImageGenerationOnce( $request_id, $post_id, $column ) { |
| 2627 |
if ( get_transient( self::PENDING_RETRIED_PREFIX . $request_id ) ) { |
| 2628 |
return null; |
| 2629 |
} |
| 2630 |
|
| 2631 |
$context = get_transient( self::PENDING_CONTEXT_PREFIX . $request_id ); |
| 2632 |
if ( ! is_array( $context ) || empty( $context['table_data'] ) || ! is_array( $context['table_data'] ) ) { |
| 2633 |
return null; |
| 2634 |
} |
| 2635 |
|
| 2636 |
if ( SheetsPilotGlobals::$isPro != true || ! class_exists( 'SheetsPilotCellEditor', false ) ) { |
| 2637 |
return null; |
| 2638 |
} |
| 2639 |
|
| 2640 |
set_transient( self::PENDING_RETRIED_PREFIX . $request_id, 1, self::TRANSIENT_TTL ); |
| 2641 |
|
| 2642 |
$table_data = $context['table_data']; |
| 2643 |
$prompt_text = isset( $context['prompt_text'] ) ? (string) $context['prompt_text'] : ''; |
| 2644 |
$table_data['pending_request_id'] = $request_id; |
| 2645 |
$table_data['postId'] = (int) $post_id; |
| 2646 |
if ( ! isset( $table_data['column'] ) || (string) $table_data['column'] === '' ) { |
| 2647 |
$table_data['column'] = $column; |
| 2648 |
} |
| 2649 |
|
| 2650 |
try { |
| 2651 |
$result = SheetsPilotCellEditor::applyImageGenerationToTable( $table_data, $prompt_text ); |
| 2652 |
} catch ( Throwable $e ) { |
| 2653 |
return null; |
| 2654 |
} |
| 2655 |
|
| 2656 |
if ( ! is_array( $result ) ) { |
| 2657 |
return null; |
| 2658 |
} |
| 2659 |
|
| 2660 |
if ( isset( $result['status'] ) && in_array( $result['status'], array( 'queued', 'in_progress' ), true ) ) { |
| 2661 |
return null; |
| 2662 |
} |
| 2663 |
|
| 2664 |
if ( ( $result['type'] ?? '' ) !== 'pending_image' || empty( $result['request_id'] ) ) { |
| 2665 |
return null; |
| 2666 |
} |
| 2667 |
|
| 2668 |
return self::getPending( $request_id ); |
| 2669 |
} |
| 2670 |
|
| 2671 |
/** |
| 2672 |
* Promote pending image to WordPress attachment and set as post thumbnail (for post_image column). |
| 2673 |
* Deletes the pending file and transient on success. |
| 2674 |
* |
| 2675 |
* @param string $request_id Request ID. |
| 2676 |
* @param int $post_id Post ID to attach to. |
| 2677 |
* @param string $column Column name (e.g. post_image). |
| 2678 |
* @return array { attachment_id, thumbnail_url } for client to update cell. |
| 2679 |
*/ |
| 2680 |
public static function promotePendingToPost($request_id, $post_id, $column) |
| 2681 |
{ |
| 2682 |
|
| 2683 |
$meta = self::resolvePromotePendingMeta( $request_id, $post_id, $column ); |
| 2684 |
if (! $meta) { |
| 2685 |
SheetsPilotFunctions::throwError(__('Pending image not found or expired.', 'sheetspilot')); |
| 2686 |
} |
| 2687 |
|
| 2688 |
$filepath = $meta['path']; |
| 2689 |
$post_id = (int) $post_id; |
| 2690 |
|
| 2691 |
$filename = basename( $filepath ); |
| 2692 |
$upload_dir = wp_upload_dir(); |
| 2693 |
$dest_file = wp_unique_filename( $upload_dir['path'], $filename ); |
| 2694 |
$dest_path = $upload_dir['path'] . '/' . $dest_file; |
| 2695 |
|
| 2696 |
if ( ! copy( $filepath, $dest_path ) ) { |
| 2697 |
SheetsPilotFunctions::throwError( __( 'Could not copy image to uploads.', 'sheetspilot' ) ); |
| 2698 |
} |
| 2699 |
|
| 2700 |
$filetype = wp_check_filetype( $dest_file, null ); |
| 2701 |
$attachment = array( |
| 2702 |
'post_mime_type' => $filetype['type'], |
| 2703 |
'post_title' => sanitize_file_name( pathinfo( $dest_file, PATHINFO_FILENAME ) ), |
| 2704 |
'post_content' => '', |
| 2705 |
'post_status' => 'inherit', |
| 2706 |
); |
| 2707 |
$attachment_id = wp_insert_attachment( $attachment, $dest_path, $post_id ); |
| 2708 |
if ( is_wp_error( $attachment_id ) ) { |
| 2709 |
@unlink( $dest_path ); |
| 2710 |
SheetsPilotFunctions::throwError( $attachment_id->get_error_message() ); |
| 2711 |
} |
| 2712 |
|
| 2713 |
$attach_data = array( |
| 2714 |
'file' => _wp_relative_upload_path( $dest_path ), |
| 2715 |
); |
| 2716 |
$imagesize = wp_getimagesize( $dest_path ); |
| 2717 |
if ( $imagesize ) { |
| 2718 |
$attach_data['width'] = (int) $imagesize[0]; |
| 2719 |
$attach_data['height'] = (int) $imagesize[1]; |
| 2720 |
} |
| 2721 |
$file_bytes = (int) filesize( $dest_path ); |
| 2722 |
if ( $file_bytes > 0 ) { |
| 2723 |
$attach_data['filesize'] = $file_bytes; |
| 2724 |
} |
| 2725 |
wp_update_attachment_metadata( $attachment_id, $attach_data ); |
| 2726 |
|
| 2727 |
if ( $column === 'post_image' ) { |
| 2728 |
update_post_meta($post_id, '_thumbnail_id', $attachment_id); |
| 2729 |
} |
| 2730 |
|
| 2731 |
self::deletePending($request_id); |
| 2732 |
|
| 2733 |
$thumbnail_url = self::resolveAttachmentThumbnailUrl( $attachment_id, SheetsPilotUniteFunctionsWP::THUMB_MEDIUM ); |
| 2734 |
|
| 2735 |
return self::buildPromotePendingResponse( $attachment_id, $thumbnail_url ); |
| 2736 |
|
| 2737 |
} |
| 2738 |
|
| 2739 |
|
| 2740 |
/** |
| 2741 |
* Promote pending image to WordPress attachment and set as post thumbnail (for post_image column). |
| 2742 |
* Deletes the pending file and transient on success. |
| 2743 |
* |
| 2744 |
* @param string $request_id Request ID. |
| 2745 |
* @param int $post_id Post ID to attach to. |
| 2746 |
* @param string $column Column name (e.g. post_image). |
| 2747 |
* @param string $column_type Column type (e.g. acf_gallery). |
| 2748 |
* @return array { attachment_id, thumbnail_url } for client to update cell. |
| 2749 |
*/ |
| 2750 |
public static function promotePendingToPostGallery($request_id, $post_id, $column, $column_type ) |
| 2751 |
{ |
| 2752 |
|
| 2753 |
$meta = self::resolvePromotePendingMeta( $request_id, $post_id, $column ); |
| 2754 |
if (! $meta) { |
| 2755 |
SheetsPilotFunctions::throwError(__('Pending image not found or expired.', 'sheetspilot')); |
| 2756 |
} |
| 2757 |
|
| 2758 |
$filepath = $meta['path']; |
| 2759 |
$post_id = (int) $post_id; |
| 2760 |
|
| 2761 |
$filename = basename( $filepath ); |
| 2762 |
$upload_dir = wp_upload_dir(); |
| 2763 |
$dest_file = wp_unique_filename( $upload_dir['path'], $filename ); |
| 2764 |
$dest_path = $upload_dir['path'] . '/' . $dest_file; |
| 2765 |
|
| 2766 |
if ( ! copy( $filepath, $dest_path ) ) { |
| 2767 |
SheetsPilotFunctions::throwError( __( 'Could not copy image to uploads.', 'sheetspilot' ) ); |
| 2768 |
} |
| 2769 |
|
| 2770 |
$filetype = wp_check_filetype( $dest_file, null ); |
| 2771 |
$attachment = array( |
| 2772 |
'post_mime_type' => $filetype['type'], |
| 2773 |
'post_title' => sanitize_file_name( pathinfo( $dest_file, PATHINFO_FILENAME ) ), |
| 2774 |
'post_content' => '', |
| 2775 |
'post_status' => 'inherit', |
| 2776 |
); |
| 2777 |
$attachment_id = wp_insert_attachment( $attachment, $dest_path, $post_id ); |
| 2778 |
if ( is_wp_error( $attachment_id ) ) { |
| 2779 |
@unlink( $dest_path ); |
| 2780 |
SheetsPilotFunctions::throwError( $attachment_id->get_error_message() ); |
| 2781 |
} |
| 2782 |
|
| 2783 |
$attach_data = array( |
| 2784 |
'file' => _wp_relative_upload_path( $dest_path ), |
| 2785 |
); |
| 2786 |
$imagesize = wp_getimagesize( $dest_path ); |
| 2787 |
if ( $imagesize ) { |
| 2788 |
$attach_data['width'] = (int) $imagesize[0]; |
| 2789 |
$attach_data['height'] = (int) $imagesize[1]; |
| 2790 |
} |
| 2791 |
$file_bytes = (int) filesize( $dest_path ); |
| 2792 |
if ( $file_bytes > 0 ) { |
| 2793 |
$attach_data['filesize'] = $file_bytes; |
| 2794 |
} |
| 2795 |
wp_update_attachment_metadata( $attachment_id, $attach_data ); |
| 2796 |
|
| 2797 |
self::deletePending( $request_id ); |
| 2798 |
|
| 2799 |
$thumbnail_url = self::resolveAttachmentThumbnailUrl( $attachment_id, SheetsPilotUniteFunctionsWP::THUMB_MEDIUM ); |
| 2800 |
$thumbnail_url_full = self::resolveAttachmentThumbnailUrl( $attachment_id, SheetsPilotUniteFunctionsWP::THUMB_FULL ); |
| 2801 |
|
| 2802 |
return self::buildPromotePendingResponse( $attachment_id, $thumbnail_url, $thumbnail_url_full ); |
| 2803 |
|
| 2804 |
} |
| 2805 |
|
| 2806 |
/** |
| 2807 |
* Clean up expired pending images (full + thumb files and transients). Call from cron or on editor load. |
| 2808 |
*/ |
| 2809 |
public static function cleanupExpired() |
| 2810 |
{ |
| 2811 |
|
| 2812 |
$upload_dir = wp_upload_dir(); |
| 2813 |
$dir = $upload_dir['basedir'] . '/' . self::PENDING_DIR; |
| 2814 |
if (! is_dir($dir)) { |
| 2815 |
return; |
| 2816 |
} |
| 2817 |
$scan = @scandir($dir); |
| 2818 |
if (! is_array($scan)) { |
| 2819 |
return; |
| 2820 |
} |
| 2821 |
$prefix = self::TRANSIENT_PREFIX; |
| 2822 |
$cutoff = time() - self::TRANSIENT_TTL; |
| 2823 |
$processed_bases = array(); |
| 2824 |
foreach ($scan as $name) { |
| 2825 |
if ($name === '.' || $name === '..') { |
| 2826 |
continue; |
| 2827 |
} |
| 2828 |
$file = $dir . '/' . $name; |
| 2829 |
if (! is_file($file)) { |
| 2830 |
continue; |
| 2831 |
} |
| 2832 |
$stem = pathinfo($name, PATHINFO_FILENAME); |
| 2833 |
$ext = pathinfo($name, PATHINFO_EXTENSION); |
| 2834 |
$base_id = preg_replace('/_thumb$/', '', $stem); |
| 2835 |
if (isset($processed_bases[$base_id . '.' . $ext])) { |
| 2836 |
continue; |
| 2837 |
} |
| 2838 |
$processed_bases[$base_id . '.' . $ext] = true; |
| 2839 |
$transient_key = $prefix . $base_id; |
| 2840 |
$meta = get_transient($transient_key); |
| 2841 |
if (! is_array($meta) || empty($meta['created']) || $meta['created'] < $cutoff) { |
| 2842 |
$main_file = $dir . '/' . $base_id . '.' . $ext; |
| 2843 |
$thumb_file = $dir . '/' . $base_id . '_thumb.' . $ext; |
| 2844 |
if (is_file($main_file)) { |
| 2845 |
@unlink($main_file); |
| 2846 |
} |
| 2847 |
if (is_file($thumb_file)) { |
| 2848 |
@unlink($thumb_file); |
| 2849 |
} |
| 2850 |
delete_transient($transient_key); |
| 2851 |
} |
| 2852 |
} |
| 2853 |
|
| 2854 |
} |
| 2855 |
} |
| 2856 |
|