PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.13.0
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.13.0
2.13.0 2.13.1 2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 All 80 releases
ablocks / includes / classes / images / compressor.php

compressor.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder 2.13.0, at includes/classes/images/compressor.php

579 lines 17.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace ABlocks\Classes\Images;
3
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit;
6 }
7
8 /**
9 * Image compression — recompress attachment files in place.
10 *
11 * Three strength levels, following the shape users know from Smush and similar:
12 *
13 * 1x gentle ~ quality 90, visually indistinguishable
14 * 2x balanced ~ quality 80, the sensible default
15 * 5x aggressive~ quality 65, noticeably smaller, some loss on detailed photos
16 *
17 * Everything runs locally through WP_Image_Editor (Imagick or GD), so no image
18 * ever leaves the server and there is no API key, quota or third-party
19 * dependency. That is a deliberate difference from the hosted optimizers: the
20 * ceiling is lower than a dedicated service, but it works offline, on staging,
21 * and for free.
22 *
23 * ## Originals are always kept
24 *
25 * Compression is lossy and cannot be undone by recompressing. Before the first
26 * write, the untouched file is copied aside, so any level can be re-applied
27 * from the original rather than stacking loss on loss — running 2x and then 5x
28 * would otherwise compress an already-compressed image and look far worse than
29 * 5x alone. It is also what makes "restore originals" possible at all.
30 *
31 * ## Never grows a file
32 *
33 * Recompression can produce a *larger* file than it started with, particularly
34 * on flat graphics and on images already optimised elsewhere. Results are only
35 * kept when they are actually smaller.
36 */
37 class Compressor {
38
39 const BACKUP_DIR = 'ablocks-originals';
40 const META_KEY = '_ablocks_image_optimized';
41
42 /**
43 * Quality per level.
44 */
45 const LEVELS = [
46 '1x' => 90,
47 '2x' => 80,
48 '5x' => 65,
49 ];
50
51 /**
52 * Mime types worth recompressing.
53 *
54 * PNG is included but treated carefully: for photographic PNGs quality has
55 * little meaning, and the real win is WebP alongside.
56 */
57 const SUPPORTED = [ 'image/jpeg', 'image/png', 'image/webp' ];
58
59 /**
60 * Quality for a level name.
61 *
62 * @param string $level Level key.
63 * @return int
64 */
65 public static function quality_for( $level ) {
66 $level = (string) $level;
67 $map = (array) apply_filters( 'ablocks/images/levels', self::LEVELS );
68
69 if ( isset( $map[ $level ] ) ) {
70 return (int) $map[ $level ];
71 }
72
73 return (int) $map['2x'];
74 }
75
76 /**
77 * Optimize an attachment and every generated size.
78 *
79 * @param int $attachment_id Attachment post ID.
80 * @param string $level Level key (1x|2x|5x).
81 * @param bool $make_webp Also write a .webp sibling.
82 * @return array{ok:bool, before:int, after:int, files:int, webp:int, message:string}
83 */
84 public static function optimize_attachment( $attachment_id, $level = '2x', $make_webp = true ) {
85 $attachment_id = (int) $attachment_id;
86 $result = [
87 'ok' => false,
88 'before' => 0,
89 'after' => 0,
90 'files' => 0,
91 'webp' => 0,
92 'message' => '',
93 ];
94
95 // Once the originals are gone there is no pristine source left, and
96 // compressing again would work from the already-compressed file:
97 // ensure_backup() would quietly adopt *that* as the new "original", so
98 // every future saving would be measured against it and a restore would
99 // hand back a compressed image. Refusing is the only honest option.
100 $record = self::record( $attachment_id );
101 if ( ! empty( $record['originals_deleted'] ) ) {
102 $result['message'] = __( 'Originals were deleted, so this image cannot be re-compressed.', 'ablocks' );
103 return $result;
104 }
105
106 $file = get_attached_file( $attachment_id );
107 if ( ! $file || ! file_exists( $file ) ) {
108 $result['message'] = __( 'File missing.', 'ablocks' );
109 return $result;
110 }
111
112 $mime = get_post_mime_type( $attachment_id );
113 if ( ! in_array( $mime, self::SUPPORTED, true ) ) {
114 $result['message'] = __( 'Unsupported file type.', 'ablocks' );
115 return $result;
116 }
117
118 foreach ( self::files_for( $attachment_id, $file ) as $path ) {
119 $one = self::optimize_file( $path, $level, $make_webp, $attachment_id );
120
121 $result['before'] += $one['before'];
122 $result['after'] += $one['after'];
123 $result['files'] += $one['changed'] ? 1 : 0;
124 $result['webp'] += $one['webp'] ? 1 : 0;
125 }
126
127 $result['ok'] = true;
128
129 update_post_meta(
130 $attachment_id,
131 self::META_KEY,
132 [
133 'level' => (string) $level,
134 'before' => (int) $result['before'],
135 'after' => (int) $result['after'],
136 'files' => (int) $result['files'],
137 'webp' => (int) $result['webp'],
138 'time' => time(),
139 ]
140 );
141
142 return $result;
143 }
144
145 /**
146 * Every file belonging to an attachment: the original plus each thumbnail.
147 *
148 * @param int $attachment_id Attachment ID.
149 * @param string $file Absolute path to the full-size file.
150 * @return string[]
151 */
152 public static function files_for( $attachment_id, $file = '' ) {
153 $file = $file ? $file : get_attached_file( $attachment_id );
154 if ( ! $file ) {
155 return [];
156 }
157
158 $paths = [ $file ];
159 $dir = dirname( $file );
160 $meta = wp_get_attachment_metadata( $attachment_id );
161
162 if ( ! empty( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) {
163 foreach ( $meta['sizes'] as $size ) {
164 if ( empty( $size['file'] ) ) {
165 continue;
166 }
167 $candidate = $dir . '/' . basename( $size['file'] );
168 if ( file_exists( $candidate ) ) {
169 $paths[] = $candidate;
170 }
171 }
172 }
173
174 return array_values( array_unique( $paths ) );
175 }
176
177 /**
178 * Recompress one file.
179 *
180 * @param string $path Absolute file path.
181 * @param string $level Level key.
182 * @param bool $make_webp Also write a .webp sibling.
183 * @param int $attachment_id Owning attachment, for the backup path.
184 * @return array{before:int, after:int, changed:bool, webp:bool}
185 */
186 private static function optimize_file( $path, $level, $make_webp, $attachment_id ) {
187 $out = [
188 'before' => 0,
189 'after' => 0,
190 'changed' => false,
191 'webp' => false,
192 ];
193
194 $current = (int) filesize( $path );
195 if ( $current <= 0 ) {
196 return $out;
197 }
198
199 // Always compress from the pristine copy, never from the current file,
200 // so levels are re-applicable instead of compounding.
201 $source = self::ensure_backup( $path, $attachment_id );
202 $source = $source ? $source : $path;
203
204 // Savings are reported against the *original*, not against whatever the
205 // last run left behind. Measuring from the current file makes every
206 // re-run look like it saved another 25% and makes levels impossible to
207 // compare — 5x has to be judged against the untouched image, not against
208 // the output of 2x.
209 $before = (int) filesize( $source );
210 $before = $before > 0 ? $before : $current;
211 $out['before'] = $before;
212 $out['after'] = $current;
213
214 $quality = self::quality_for( $level );
215
216 $editor = wp_get_image_editor( $source );
217 if ( is_wp_error( $editor ) ) {
218 return $out;
219 }
220 $editor->set_quality( $quality );
221
222 // Written to a temporary name and swapped in only if smaller, so a failed
223 // or counter-productive pass can never damage the live file.
224 $tmp = $path . '.ablocks-tmp';
225 $save = $editor->save( $tmp );
226
227 if ( is_wp_error( $save ) || empty( $save['path'] ) || ! file_exists( $save['path'] ) ) {
228 return $out;
229 }
230
231 $after = (int) filesize( $save['path'] );
232
233 // Compared against the original, so a weaker level applied after a
234 // stronger one restores the larger-but-better file rather than being
235 // rejected for being bigger than the current one.
236 if ( $after > 0 && $after < $before ) {
237 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Return value checked; a racing writer must not warn into output.
238 if ( @rename( $save['path'], $path ) ) {
239 $out['after'] = $after;
240 $out['changed'] = true;
241 } else {
242 wp_delete_file( $save['path'] );
243 }
244 } else {
245 // Recompression came out no smaller than the original — common on
246 // flat graphics and on images already optimised elsewhere.
247 wp_delete_file( $save['path'] );
248
249 // Fall back to the original rather than leaving whatever a previous
250 // pass left behind, so a level always produces the same result no
251 // matter what ran before it. Without this, applying 5x and then 1x
252 // leaves thumbnails at 5x quality — because a 1x re-encode is bigger
253 // than the current file but smaller than the original — and the
254 // picture the user gets depends on the order they clicked, which is
255 // impossible to reason about or support.
256 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_copy,WordPress.PHP.NoSilencedErrors.Discouraged -- Restoring inside uploads; see ensure_backup().
257 if ( $source !== $path && $current !== $before && @copy( $source, $path ) ) {
258 $out['after'] = $before;
259 $out['changed'] = true;
260 }
261 }//end if
262
263 if ( $make_webp ) {
264 $out['webp'] = self::write_webp( $source, $path, $quality );
265 }
266
267 return $out;
268 }
269
270 /**
271 * Write a .webp sibling next to a file.
272 *
273 * Kept as a sibling rather than replacing the original so nothing that
274 * references the existing URL breaks; delivery picks it up separately.
275 *
276 * @param string $source Pristine source path.
277 * @param string $path Live file path (names the sibling).
278 * @param int $quality Encoder quality.
279 * @return bool
280 */
281 private static function write_webp( $source, $path, $quality ) {
282 if ( ! function_exists( 'imagewebp' ) && ! class_exists( 'Imagick' ) ) {
283 return false;
284 }
285
286 $target = preg_replace( '/\.(jpe?g|png)$/i', '', $path ) . '.webp';
287 if ( $target === $path ) {
288 return false; // Already a webp.
289 }
290
291 $editor = wp_get_image_editor( $source );
292 if ( is_wp_error( $editor ) ) {
293 self::drop_stale_webp( $target );
294 return false;
295 }
296 $editor->set_quality( $quality );
297
298 // Written beside the target first, so a rejected result never replaces a
299 // good sibling that is already in place.
300 $tmp = $target . '.ablocks-tmp.webp';
301 $saved = $editor->save( $tmp, 'image/webp' );
302
303 if ( is_wp_error( $saved ) || empty( $saved['path'] ) || ! file_exists( $saved['path'] ) ) {
304 self::drop_stale_webp( $target );
305 return false;
306 }
307
308 // A WebP larger than the file it is meant to replace is worse than none.
309 // This matters most on a *re-run at a stronger level*: the JPEG shrinks,
310 // so a WebP that won last time can now lose. Leaving the old one on disk
311 // would keep serving a file bigger than the JPEG beside it, so the stale
312 // sibling is removed rather than merely not replaced.
313 if ( file_exists( $path ) && filesize( $saved['path'] ) >= filesize( $path ) ) {
314 wp_delete_file( $saved['path'] );
315 self::drop_stale_webp( $target );
316 return false;
317 }
318
319 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Return value checked.
320 if ( ! @rename( $saved['path'], $target ) ) {
321 wp_delete_file( $saved['path'] );
322 return false;
323 }
324
325 return true;
326 }
327
328 /**
329 * Remove a WebP sibling left by an earlier, weaker pass.
330 *
331 * @param string $target Sibling path.
332 */
333 private static function drop_stale_webp( $target ) {
334 if ( $target && file_exists( $target ) ) {
335 wp_delete_file( $target );
336 }
337 }
338
339 /**
340 * Copy a file into the originals store the first time it is touched.
341 *
342 * @param string $path Live file path.
343 * @param int $attachment_id Owning attachment.
344 * @return string|null Path to the pristine copy.
345 */
346 public static function ensure_backup( $path, $attachment_id ) {
347 $backup = self::backup_path( $path, $attachment_id );
348 if ( ! $backup ) {
349 return null;
350 }
351
352 if ( file_exists( $backup ) ) {
353 return $backup;
354 }
355
356 $dir = dirname( $backup );
357 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
358 return null;
359 }
360
361 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_copy,WordPress.PHP.NoSilencedErrors.Discouraged -- Copying inside uploads; WP_Filesystem adds no safety and may need FTP credentials on a frontend request.
362 return @copy( $path, $backup ) ? $backup : null;
363 }
364
365 /**
366 * Where a file's pristine copy lives.
367 *
368 * @param string $path Live file path.
369 * @param int $attachment_id Owning attachment.
370 * @return string|null
371 */
372 public static function backup_path( $path, $attachment_id ) {
373 $upload = wp_upload_dir();
374 if ( ! empty( $upload['error'] ) ) {
375 return null;
376 }
377
378 $base = trailingslashit( $upload['basedir'] ) . self::BACKUP_DIR;
379
380 return $base . '/' . (int) $attachment_id . '/' . basename( $path );
381 }
382
383 /**
384 * Put every original back, discarding optimized versions.
385 *
386 * @param int $attachment_id Attachment ID.
387 * @return int Files restored.
388 */
389 public static function restore_attachment( $attachment_id ) {
390 $attachment_id = (int) $attachment_id;
391 $restored = 0;
392
393 foreach ( self::files_for( $attachment_id ) as $path ) {
394 $backup = self::backup_path( $path, $attachment_id );
395 if ( ! $backup || ! file_exists( $backup ) ) {
396 continue;
397 }
398 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_copy,WordPress.PHP.NoSilencedErrors.Discouraged -- See ensure_backup().
399 if ( @copy( $backup, $path ) ) {
400 $restored++;
401 }
402
403 $webp = preg_replace( '/\.(jpe?g|png)$/i', '', $path ) . '.webp';
404 if ( $webp !== $path && file_exists( $webp ) ) {
405 wp_delete_file( $webp );
406 }
407 }
408
409 if ( $restored ) {
410 self::delete_backups( $attachment_id );
411 delete_post_meta( $attachment_id, self::META_KEY );
412 }
413
414 return $restored;
415 }
416
417 /**
418 * Remove an attachment's stored originals.
419 *
420 * @param int $attachment_id Attachment ID.
421 */
422 public static function delete_backups( $attachment_id ) {
423 $upload = wp_upload_dir();
424 if ( ! empty( $upload['error'] ) ) {
425 return;
426 }
427
428 $dir = trailingslashit( $upload['basedir'] ) . self::BACKUP_DIR . '/' . (int) $attachment_id;
429 if ( ! is_dir( $dir ) ) {
430 return;
431 }
432
433 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Return value checked.
434 $entries = @scandir( $dir );
435 if ( false === $entries ) {
436 return;
437 }
438 foreach ( $entries as $entry ) {
439 if ( '.' === $entry || '..' === $entry ) {
440 continue;
441 }
442 wp_delete_file( $dir . '/' . $entry );
443 }
444 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best effort.
445 @rmdir( $dir );
446 }
447
448 /**
449 * Delete an attachment's stored originals and mark it as no longer reversible.
450 *
451 * Reclaims the disk the pristine copies occupy, at the cost of everything
452 * they make possible: restoring, and re-applying a different strength from a
453 * clean source. The flag is what stops a later run silently treating the
454 * compressed file as the original — see optimize_attachment().
455 *
456 * @param int $attachment_id Attachment ID.
457 * @return int Bytes reclaimed.
458 */
459 public static function discard_originals( $attachment_id ) {
460 $attachment_id = (int) $attachment_id;
461 $freed = self::originals_size( $attachment_id );
462
463 self::delete_backups( $attachment_id );
464
465 $record = self::record( $attachment_id );
466 if ( is_array( $record ) ) {
467 $record['originals_deleted'] = true;
468 update_post_meta( $attachment_id, self::META_KEY, $record );
469 }
470
471 return $freed;
472 }
473
474 /**
475 * Bytes held by stored originals.
476 *
477 * @param int $attachment_id Attachment ID, or 0 for every attachment.
478 * @return int
479 */
480 public static function originals_size( $attachment_id = 0 ) {
481 $upload = wp_upload_dir();
482 if ( ! empty( $upload['error'] ) ) {
483 return 0;
484 }
485
486 $base = trailingslashit( $upload['basedir'] ) . self::BACKUP_DIR;
487 $dirs = $attachment_id
488 ? [ $base . '/' . (int) $attachment_id ]
489 : glob( $base . '/*', GLOB_ONLYDIR );
490
491 $total = 0;
492 foreach ( (array) $dirs as $dir ) {
493 if ( ! is_dir( $dir ) ) {
494 continue;
495 }
496 foreach ( (array) glob( $dir . '/*' ) as $file ) {
497 if ( is_file( $file ) ) {
498 $total += (int) filesize( $file );
499 }
500 }
501 }
502
503 return $total;
504 }
505
506 /**
507 * Attachment IDs whose originals are still stored.
508 *
509 * @param int $limit Maximum to return.
510 * @return int[]
511 */
512 public static function ids_with_originals( $limit = 10000 ) {
513 $upload = wp_upload_dir();
514 if ( ! empty( $upload['error'] ) ) {
515 return [];
516 }
517
518 $base = trailingslashit( $upload['basedir'] ) . self::BACKUP_DIR;
519 $dirs = glob( $base . '/*', GLOB_ONLYDIR );
520
521 $ids = [];
522 foreach ( (array) $dirs as $dir ) {
523 $id = (int) basename( $dir );
524 if ( $id > 0 ) {
525 $ids[] = $id;
526 }
527 if ( count( $ids ) >= $limit ) {
528 break;
529 }
530 }
531
532 return $ids;
533 }
534
535 /**
536 * Has this attachment been optimized?
537 *
538 * @param int $attachment_id Attachment ID.
539 * @return array|null Stored record, or null.
540 */
541 public static function record( $attachment_id ) {
542 $meta = get_post_meta( (int) $attachment_id, self::META_KEY, true );
543 return is_array( $meta ) ? $meta : null;
544 }
545
546 /**
547 * Attachment IDs that are candidates for optimization.
548 *
549 * @param int $limit Maximum to return.
550 * @param bool $include_done Include already-optimized attachments.
551 * @return int[]
552 */
553 public static function pending_ids( $limit = 50, $include_done = false ) {
554 $args = [
555 'post_type' => 'attachment',
556 'post_status' => 'inherit',
557 'post_mime_type' => self::SUPPORTED,
558 'posts_per_page' => max( 1, (int) $limit ),
559 'fields' => 'ids',
560 'no_found_rows' => true,
561 'orderby' => 'ID',
562 'order' => 'ASC',
563 ];
564
565 if ( ! $include_done ) {
566 $args['meta_query'] = [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Bounded by posts_per_page; this is an admin-triggered batch, not a page render.
567 [
568 'key' => self::META_KEY,
569 'compare' => 'NOT EXISTS',
570 ],
571 ];
572 }
573
574 $query = new \WP_Query( $args );
575
576 return array_map( 'intval', $query->posts );
577 }
578 }
579