PluginProbe
SheetsPilot – AI Spreadsheet Bulk Edit for Posts, WooCommerce Products & SEO / trunk
SheetsPilot – AI Spreadsheet Bulk Edit for Posts, WooCommerce Products & SEO vtrunk
sheetspilot / inc_php / image_processing.class.php

image_processing.class.php in SheetsPilot – AI Spreadsheet Bulk Edit for Posts, WooCommerce Products & SEO trunk, at inc_php/image_processing.class.php

2,856 lines 92.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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