From 7e1c86f215154ec5d96fcd3656d967a996d3c530 Mon Sep 17 00:00:00 2001 From: Henk Date: Tue, 19 May 2026 13:41:03 +1000 Subject: [PATCH] feat: initial v0.1.0 MVP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces Smush Pro's optimisation pipeline without the grey-wash bug. CLI commands working: wp h4b-img status wp h4b-img optimise --id= wp h4b-img bulk wp h4b-img rescue Verified on dev.rds.ink: - ICC profile preservation works (the Smush-bug fix) - Bulk: 20 attachments → 487 KB saved (10.4%), 0 errors - Rescue: end-to-end mechanism verified on WorkingAsOne_horse fixture - WebP synchronous, AVIF queued via WP-Cron - Originals backed up to wp-content/h4b-img-originals/ See CHANGELOG.md for details + ../DESIGN-h4b-image-optim.md for architecture. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 33 +++ CHANGELOG.md | 61 +++++ LICENSE | 338 ++++++++++++++++++++++++++ README.md | 69 ++++++ assets/sRGB_v4_ICC_preference.icc | Bin 0 -> 2576 bytes h4b-image-optim.php | 28 +++ includes/class-attachment-meta.php | 71 ++++++ includes/class-cli-bulk.php | 283 ++++++++++++++++++++++ includes/class-cli-rescue.php | 299 +++++++++++++++++++++++ includes/class-cli.php | 180 ++++++++++++++ includes/class-format-generator.php | 185 +++++++++++++++ includes/class-icc-profile.php | 67 ++++++ includes/class-optimizer.php | 353 ++++++++++++++++++++++++++++ includes/class-plugin.php | 80 +++++++ includes/class-rescue-detector.php | 161 +++++++++++++ includes/class-settings.php | 89 +++++++ includes/class-tools.php | 68 ++++++ includes/class-uploader-hook.php | 112 +++++++++ uninstall.php | 21 ++ 19 files changed, 2498 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 assets/sRGB_v4_ICC_preference.icc create mode 100644 h4b-image-optim.php create mode 100644 includes/class-attachment-meta.php create mode 100644 includes/class-cli-bulk.php create mode 100644 includes/class-cli-rescue.php create mode 100644 includes/class-cli.php create mode 100644 includes/class-format-generator.php create mode 100644 includes/class-icc-profile.php create mode 100644 includes/class-optimizer.php create mode 100644 includes/class-plugin.php create mode 100644 includes/class-rescue-detector.php create mode 100644 includes/class-settings.php create mode 100644 includes/class-tools.php create mode 100644 includes/class-uploader-hook.php create mode 100644 uninstall.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfbd572 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# macOS / editor noise +.DS_Store +Thumbs.db +*.swp +*~ +.idea/ +.vscode/ + +# PHP build / dependencies (we vendor manually if needed) +vendor/ +composer.lock +.phpunit.result.cache + +# Generated assets we don't want committed +*.tmp +*.bak +*.rescue.tmp +*.h4b.tmp +*.pngq.tmp + +# Test fixtures / outputs +tests/output/ +tests/.coverage/ +coverage.xml +phpunit.xml + +# Logs / state +*.log +debug.log + +# IDE / OS files +.directory +.project diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b88b764 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,61 @@ +# CHANGELOG + +All notable changes to **h4b-image-optim** will be documented here. + +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] — 2026-05-19 + +Initial MVP. Replaces Smush Pro's optimisation pipeline without the +grey-wash bug. CLI-only in this release (admin UI comes in 0.2). + +### Added +- **Plugin scaffold** with PSR-12-style namespaced classes (`H4B\ImageOptim\*`) +- **`Optimizer`** — in-place JPG / PNG re-encode using Imagick + jpegoptim / pngquant + - **ICC profile preservation** (the Smush-bug fix) via bundled sRGB v4 profile fallback + - 4:4:4 chroma subsampling for large images; 4:2:0 for small ones + - Configurable size threshold (`min_optimise_bytes`, default 20 KB) to skip + thumbnails that re-encoding would inflate + - Atomic file replacement with ownership preservation + - GPS EXIF stripping on by default for privacy + - EXIF orientation applied before encode +- **`Format_Generator`** — WebP (synchronous) + AVIF (queued via WP-Cron) siblings +- **`Uploader_Hook`** — intercepts `wp_generate_attachment_metadata` +- **`Attachment_Meta`** — per-attachment optimisation history in `_h4b_img_optim` +- **`Rescue_Detector`** — PHP port of the Python grey-wash detector +- **Originals backup** to `wp-content/h4b-img-originals/` with 90-day cron prune +- **WP-CLI commands**: + - `wp h4b-img status` — tool detection + settings dump + - `wp h4b-img optimise --id=` — single-attachment optimise (+ `--dry-run`) + - `wp h4b-img bulk` — bulk-optimise unprocessed attachments (+ `--limit`, + `--force`, `--dry-run`, `--batch`, `--pause`) + - `wp h4b-img rescue` — scan + optionally repair Smush-mangled JPGs from + their `.webp` siblings (+ `--scan`, `--apply`, `--min-severity`, `--csv`, + `--manifest`) +- **`uninstall.php`** — clean removal of plugin options + cron, preserves + user data (backups + .webp/.avif siblings + optimisation postmeta) + +### Required system tools (AlmaLinux 9 / Debian 13) + +| Tool | AlmaLinux | Debian | +|---|---|---| +| `cwebp` | `libwebp-tools` | `webp` | +| `jpegoptim` | `jpegoptim` | `jpegoptim` | +| `cjpeg` / `jpegtran` | `libjpeg-turbo-utils` | `libjpeg-turbo-progs` | +| `avifenc` | `libavif-tools` | `libavif-bin` | +| `pngquant` | `pngquant` | `pngquant` | +| `optipng` | `optipng` | `optipng` | + +### Known limitations +- No admin settings page (CLI/`wp option` only) +- No Picture-tag rewriting on `the_content` (Smush still serving WebPs) +- No `.htaccess` content-negotiation fallback +- No `migrate-from-smush` command yet +- No PHPUnit test suite yet + +### Verified against +- AlmaLinux 9.7 / PHP 8.4.21 / Imagick 3.8.1 (production) +- Debian 13 / PHP 8.4 / wordpress:php8.4-apache (dev container) +- Tested on 20 rds.ink attachments in bulk mode: 487 KB saved (10.4%), 0 errors +- Rescue mechanic verified end-to-end on the WorkingAsOne_horse900-800x524.jpg fixture diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9efa6fb --- /dev/null +++ b/LICENSE @@ -0,0 +1,338 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Moe Ghoul, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..75a384e --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# h4b-image-optim + +ICC-safe image optimisation with WebP + AVIF generation for WordPress. Replaces Smush Pro without the grey-wash bug. No CDN. + +**Status:** v0.1.0 MVP — under active development. Tested on AlmaLinux 9 + PHP 8.4 + Imagick 3.8.1. + +## Why this exists + +Smush Pro's Ultra mode (`lossy=2` + `strip_exif=true`) destroyed 1,345 high-contrast B&W ink art JPGs on rds.ink by stripping ICC profiles and applying aggressive JPEG quantisation. The diagnosis, rescue, and a full design document are in the sibling `image-rescue/` directory. + +This plugin is the structural fix: an in-house Smush replacement that **never** strips ICC profiles, with full source-code ownership and zero CDN dependency. + +## Features (v0.1) + +- ✅ JPEG / PNG optimisation in place (Imagick + jpegoptim/pngquant) +- ✅ ICC profile preservation (the Smush-bug fix) +- ✅ EXIF orientation applied + GPS stripped for privacy +- ✅ WebP sibling generation (cwebp) +- ✅ AVIF sibling generation (avifenc, queued via WP-Cron) +- ✅ Backup of originals to `wp-content/h4b-img-originals/`, pruned after 90 days +- ✅ WP-CLI: `wp h4b-img status`, `wp h4b-img optimise --id=` +- 🔜 Bulk processing (`wp h4b-img bulk`) +- 🔜 Picture-tag rewriting + .htaccess fallback +- 🔜 Admin settings page +- 🔜 Migration command from Smush metadata + +## Required system tools + +Install on AlmaLinux 9: + +```bash +dnf install --setopt=install_weak_deps=False \ + libwebp-tools jpegoptim libjpeg-turbo-utils libavif-tools pngquant +``` + +Verify with: + +```bash +wp h4b-img status +``` + +## Settings (v0.1 — set via WP-CLI) + +```bash +wp option get h4b_image_optim_settings +wp option patch update h4b_image_optim_settings jpeg_quality 90 +``` + +Key defaults: + +| Setting | Default | Why | +|---|---|---| +| `jpeg_quality` | 85 | Industry standard; visually lossless for art | +| `jpeg_chroma_subsampling` | `4:4:4` | The Smush-bug fix — no chroma loss on edges | +| `preserve_icc_profile` | `true` | **Critical** — never strip ICC | +| `generate_webp` | `true` | Always make .webp sibling | +| `generate_avif` | `true` | Always make .avif sibling (background) | +| `avif_async` | `true` | Don't block upload UI | +| `backup_originals` | `true` | Always | +| `backup_prune_days` | `90` | Cron prunes after 90 days | +| `resize_max_width` | `2560` | Hard cap on uploaded image size | + +## Uninstall + +`uninstall.php` deletes the settings option and clears cron hooks. It does **not** delete backup files or .webp/.avif siblings — those are user data. + +## License + +GPL-2.0-or-later. diff --git a/assets/sRGB_v4_ICC_preference.icc b/assets/sRGB_v4_ICC_preference.icc new file mode 100644 index 0000000000000000000000000000000000000000..055997234ccb99efe28d61a99f92459a4da3f77d GIT binary patch literal 2576 zcmb7_XH=BO8pr2--|dBk-DT;p^d?ox0#cTxEk$}26ZcZBoOQ`uf#?tl0H-M$+I(29Cb@84tpQz1k~rSn9tB~i)arSdZXfO15v zm7S5g!be3+k4X5!XIGdnI-p3uvBEK5dHjp#D+|AP{Cqf~TcH5Jkz)SA5&z)7_7&NI z3Y(u17af(u^i7G*$PVX6G1FHv!R2t6Ui_5kxWuRx*I)5}ng5>jUu{NIj_7Uxa8gn? z@#A7*GnoH6fw7^H5%~5NZ@j)B|MJ+ zgorQ@Wke5QAsoaL2|=O}KC%@lLMo63qz&mo`jLBx0GUHRpfD&Jiiy%gS)p7|{wN+Q z4Yd_jf~rQHKy{+}Q1?(1C?V=Inv9l5>!K~uTyzjR2Az#AL|34j&>iS&=n?c3`W*&? zVPG^d<`@@DFeVBejA_SQ#tdO5G4og~RtBq!wZ?j4!?79ILTnZGB=!<^7(0c1 zk0augaK<<%TnH{1myfH!wcsw|hH=xlMZ7p(9dC*E#7E*c;Y;z2_#XTn{1kqXAVJV1 zSQGpR@q`_ON5w?2P*NsoFX=ex zGU*}d4Vg^VAls2c$Qk6lBAAF`AgZ7*{M(Y@1k(Sf|*q*eeR2qE2B`Hc)aY z6_hiSJCr$bytsxqM?747t9Xt0Iq@;^c?p_?k%Xs2l0=C_i^NTd87h{lL3N}?QVXcZ zs8^|zG=!!~UHQE#%L)WBp>GAYp`j7NMx=@lTX(AaQnIl;vc|lUZKo}Yf zE+dgq$~es!Wqg!UkYYS^k*(fc(6IqJpzRszQ~*RfRc4 zhN7KfykfcHMa5Yqnv#uDoKm^cC8ZZkNhX__$gE`cF@?(V$}Y;8%Js^F%8M$RD*h_@ zD(xyyR0*mq)o9gn)m~Mhnv$A_TAo^~+9P$mI!irP{g8UU`hte0Mxe%SjV_HDO=(T8 zX0B$d=C~GF%T_B*t3m6&Hb$GJouGYK`>qbGW2zIYQ=@Z77uGe?jnh4>JG2VD%3@XW zs)ki#dL%uzUY1^~-h@6w-(5dnzf1p>fto>x!G42&gU^PhhKYuahL4S?My^KrMmw0T} z4bvvf=BSOpR@pY(w!!w9ovK}=U6b83TbmuvKFNM%Z)nfA@33FsSaP;-E;}F&&JINm zgN{^3f5&Rar%ozPF;1;cLT6Lw&CZuy&@OH+`(4Jk3S1tynJaWPcirOJ=SFh#b*pxJ z=C0$;ckl6lJzPBwc#N-AUz@zP%M*BVJr8(3@zV54^ZMBv>gk+V_OQDsrn(bmyr(K9jDF$ZF1V{KzAV&~#Gan*6J<6YwG;ujJ;6OJb= zCk7_AC*hL9lFlVllM|BrQWR5iQif7>QwviCX_jdR(}a9Cep5P>zCOJ>gD(0M2QsxX zcV$jw*<{sZz0VHFK9eJnlbkcKNoUiZP1Ctfxj$@1ZRTz6%~Q_XkvFl0y`_FDv^8RD z?>5zK1>2^!J8wU+gRmoE$1nMY`DOWUcLwh4DNrcbUNE`Kc~@(pSRub~Y`4|!y6;fm z#eFxpXZ4<{J)esri*6Mg7FQH6mGDY#eQ)%A<@d{bqxTM$nw1{ehuN3BZ*;%y{u5;q zWw~XO2RsgRmNUzX%HJN`c<^S0Nk!cu!lBGVf=c(w?kcsaeN{`YeL58#Ed!j$w{v9edsw(AfV2>xbqh*{0&APsfvw z3r_f)=xa7_Zf=opDQ$&YGh1g*hMv6pqr;EgZF+6>?Tq%~Q{YtQsTV(m{WN;o?R4)M z)|s}m>St>^XdOkJQ0JyjVOMOIpgXvG=$z}h-X5!-j-T~^K7L;Le9Z;P3#Atc7Yi;f zU&_5Se>wH?+?ALslf4^zA72f+dcV)RZ}6JiwHw!+u3znE_g}hUedGL1%bVwJv2JzU zHoM(9U^>w8i|H>NgJy%Bcg*i}-?g~gGqh&t;;`-Tm3t2NuHWb0zdf>cWN6fX^ubu@ znBYO=gPDhk4_`mZdbId>`#5^M=!y7~il_2V8-CUPwOwE)xG>=`G4Ray+2cvxboot(); diff --git a/includes/class-attachment-meta.php b/includes/class-attachment-meta.php new file mode 100644 index 0000000..6b0b9e7 --- /dev/null +++ b/includes/class-attachment-meta.php @@ -0,0 +1,71 @@ + '0.1.0', + * 'processed_at' => 'ISO-8601', + * 'tool_chain' => ['imagick','cjpeg','jpegoptim',...], + * 'sizes' => [ + * 'full' => ['before'=>123, 'after'=>78, 'webp'=>45, 'avif'=>30, 'percent'=>37], + * '800x524' => [...], + * ], + * 'backup_path' => 'wp-content/h4b-img-originals/2026/02/...', + * 'errors' => [], + * ] + * + * @package H4B\ImageOptim + */ + +namespace H4B\ImageOptim; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +final class Attachment_Meta { + + public const META_KEY = '_h4b_img_optim'; + + public static function get( int $attachment_id ): array { + $meta = get_post_meta( $attachment_id, self::META_KEY, true ); + return is_array( $meta ) ? $meta : []; + } + + public static function set( int $attachment_id, array $meta ): void { + update_post_meta( $attachment_id, self::META_KEY, $meta ); + } + + public static function is_processed( int $attachment_id ): bool { + $meta = self::get( $attachment_id ); + return ! empty( $meta['processed_at'] ); + } + + public static function record_size( int $attachment_id, string $size_key, array $stats ): void { + $meta = self::get( $attachment_id ); + $meta['sizes'] = $meta['sizes'] ?? []; + $meta['sizes'][ $size_key ] = $stats; + $meta['version'] = H4B_IMG_OPTIM_VERSION; + $meta['processed_at'] = current_time( 'c' ); + self::set( $attachment_id, $meta ); + } + + public static function mark_avif_pending( int $attachment_id, string $size_key ): void { + $meta = self::get( $attachment_id ); + $meta['avif_pending'] = $meta['avif_pending'] ?? []; + $meta['avif_pending'][ $size_key ] = current_time( 'c' ); + self::set( $attachment_id, $meta ); + } + + public static function clear_avif_pending( int $attachment_id, string $size_key ): void { + $meta = self::get( $attachment_id ); + if ( isset( $meta['avif_pending'][ $size_key ] ) ) { + unset( $meta['avif_pending'][ $size_key ] ); + if ( empty( $meta['avif_pending'] ) ) { + unset( $meta['avif_pending'] ); + } + self::set( $attachment_id, $meta ); + } + } +} diff --git a/includes/class-cli-bulk.php b/includes/class-cli-bulk.php new file mode 100644 index 0000000..4657822 --- /dev/null +++ b/includes/class-cli-bulk.php @@ -0,0 +1,283 @@ +] + * : unoptimised (default) | all + * + * [--mime=] + * : Comma-separated MIME types. Default: image/jpeg,image/png + * + * [--limit=] + * : Maximum attachments to process. 0 = no limit. + * + * [--batch=] + * : Batch size for memory hygiene. Default: from settings (10). + * + * [--pause=] + * : Sleep between batches. Default: from settings (1). + * + * [--dry-run] + * : Report counts only. + * + * [--force] + * : Re-optimise even if already processed. + * + * [--id=] + * : Process a single attachment (alias for `optimise --id=X`). + * + * ## EXAMPLES + * wp h4b-img bulk --dry-run + * wp h4b-img bulk --status=unoptimised + * wp h4b-img bulk --status=all --force --limit=50 + */ + public function __invoke( $args, $assoc ): void { + $status = $assoc['status'] ?? 'unoptimised'; + $mime = isset( $assoc['mime'] ) + ? array_map( 'trim', explode( ',', $assoc['mime'] ) ) + : [ 'image/jpeg', 'image/png' ]; + $limit = (int) ( $assoc['limit'] ?? 0 ); + $batch = (int) ( $assoc['batch'] ?? Settings::get( 'bulk_batch_size', 10 ) ); + $pause = (int) ( $assoc['pause'] ?? Settings::get( 'bulk_pause_seconds', 1 ) ); + $dry_run = ! empty( $assoc['dry-run'] ); + $force = ! empty( $assoc['force'] ); + + $ids = self::find_candidates( $status, $mime, $limit ); + $total = count( $ids ); + \WP_CLI::log( sprintf( + 'Found %d attachment(s) matching status=%s mime=%s', + $total, $status, implode( ',', $mime ) + ) ); + if ( $dry_run ) { + \WP_CLI::success( 'Dry run only — no changes made.' ); + return; + } + if ( $total === 0 ) { + \WP_CLI::success( 'Nothing to do.' ); + return; + } + + $progress = \WP_CLI\Utils\make_progress_bar( 'Optimising', $total ); + $summary = [ + 'attachments' => 0, + 'sizes' => 0, + 'skipped' => 0, + 'errors' => 0, + 'bytes_before' => 0, + 'bytes_after' => 0, + ]; + + $processed_since_pause = 0; + foreach ( $ids as $id ) { + $result = self::process_attachment( (int) $id, $force ); + $summary['attachments']++; + $summary['sizes'] += $result['sizes_processed']; + $summary['skipped'] += $result['sizes_skipped']; + $summary['errors'] += $result['errors']; + $summary['bytes_before'] += $result['bytes_before']; + $summary['bytes_after'] += $result['bytes_after']; + + $progress->tick(); + $processed_since_pause++; + if ( $processed_since_pause >= $batch ) { + if ( $pause > 0 ) { + sleep( $pause ); + } + $processed_since_pause = 0; + // Free memory accumulated by Imagick + WP postmeta cache + wp_cache_flush_runtime(); + } + } + $progress->finish(); + + $saved = $summary['bytes_before'] - $summary['bytes_after']; + $pct = $summary['bytes_before'] > 0 + ? round( $saved / $summary['bytes_before'] * 100, 1 ) + : 0; + \WP_CLI::success( sprintf( + "Bulk done.\n Attachments: %d\n Sizes processed: %d\n Sizes skipped (small): %d\n Errors: %d\n Bytes before: %s\n Bytes after: %s\n Saved: %s (%s%%)", + $summary['attachments'], + $summary['sizes'], + $summary['skipped'], + $summary['errors'], + size_format( $summary['bytes_before'], 2 ), + size_format( $summary['bytes_after'], 2 ), + size_format( $saved, 2 ), + $pct + ) ); + } + + /** + * Find attachment IDs to process. + * + * @return int[] + */ + private static function find_candidates( string $status, array $mime, int $limit ): array { + global $wpdb; + + $mime_placeholders = implode( ',', array_fill( 0, count( $mime ), '%s' ) ); + $base_query = "SELECT p.ID FROM {$wpdb->posts} p + WHERE p.post_type = 'attachment' + AND p.post_mime_type IN ($mime_placeholders)"; + + if ( $status === 'unoptimised' ) { + // LEFT JOIN postmeta; pick rows where our meta is missing or status pending + $base_query = "SELECT p.ID FROM {$wpdb->posts} p + LEFT JOIN {$wpdb->postmeta} pm + ON pm.post_id = p.ID AND pm.meta_key = %s + WHERE p.post_type = 'attachment' + AND p.post_mime_type IN ($mime_placeholders) + AND pm.meta_id IS NULL"; + $query_args = array_merge( [ Attachment_Meta::META_KEY ], $mime ); + } else { + $query_args = $mime; + } + + $base_query .= ' ORDER BY p.ID ASC'; + if ( $limit > 0 ) { + $base_query .= ' LIMIT ' . (int) $limit; + } + + $sql = $wpdb->prepare( $base_query, ...$query_args ); + return array_map( 'intval', $wpdb->get_col( $sql ) ); + } + + /** + * Process one attachment. + * + * @return array{sizes_processed:int, sizes_skipped:int, errors:int, bytes_before:int, bytes_after:int} + */ + private static function process_attachment( int $id, bool $force ): array { + $summary = [ + 'sizes_processed' => 0, + 'sizes_skipped' => 0, + 'errors' => 0, + 'bytes_before' => 0, + 'bytes_after' => 0, + ]; + + if ( ! $force && Attachment_Meta::is_processed( $id ) ) { + $summary['sizes_skipped']++; + return $summary; + } + + $metadata = wp_get_attachment_metadata( $id ); + $relative = get_post_meta( $id, '_wp_attached_file', true ); + if ( ! $metadata || ! $relative ) { + $summary['errors']++; + return $summary; + } + $uploads = wp_get_upload_dir(); + $basedir = trailingslashit( $uploads['basedir'] ); + $full = $basedir . $relative; + if ( ! is_readable( $full ) ) { + $summary['errors']++; + return $summary; + } + + // Process full + every size + $paths = [ 'full' => $full ]; + $dir = dirname( $full ); + foreach ( ( $metadata['sizes'] ?? [] ) as $size_key => $size_data ) { + if ( empty( $size_data['file'] ) ) { + continue; + } + $paths[ (string) $size_key ] = trailingslashit( $dir ) . $size_data['file']; + } + + foreach ( $paths as $size_key => $path ) { + if ( ! is_readable( $path ) ) { + $summary['errors']++; + continue; + } + $before = filesize( $path ); + $summary['bytes_before'] += $before; + + $opt = Optimizer::optimise( $path ); + if ( $opt['status'] === 'done' ) { + $summary['sizes_processed']++; + } elseif ( $opt['status'] === 'skipped' ) { + $summary['sizes_skipped']++; + } else { + $summary['errors']++; + } + + // Siblings + $webp_size = null; + $avif_status = 'skipped'; + $avif_size = null; + if ( in_array( $opt['status'], [ 'done', 'skipped' ], true ) ) { + if ( Settings::get( 'generate_webp', true ) ) { + $w = Format_Generator::make_webp( $path ); + if ( $w['status'] === 'done' ) { + $webp_size = $w['size']; + } + } + if ( Settings::get( 'generate_avif', true ) ) { + $a = Format_Generator::make_avif( $path ); + $avif_status = $a['status']; + if ( $a['status'] === 'done' ) { + $avif_size = $a['size']; + } elseif ( $a['status'] === 'queued' ) { + Attachment_Meta::mark_avif_pending( $id, (string) $size_key ); + } + } + } + + clearstatcache( true, $path ); + $after = filesize( $path ); + $summary['bytes_after'] += $after; + + Attachment_Meta::record_size( $id, (string) $size_key, [ + 'status' => $opt['status'], + 'before' => $before, + 'after' => $after, + 'percent' => $opt['percent'] ?? 0, + 'icc_preserved' => $opt['icc_preserved'] ?? false, + 'tool_chain' => $opt['tool_chain'] ?? [], + 'webp' => $webp_size, + 'avif' => $avif_size, + 'avif_status' => $avif_status, + 'backup' => $opt['backup_path'] ?? null, + 'error' => $opt['error'] ?? null, + ] ); + + // Update WP-core's filesize record so admin "media used" stats stay accurate + self::refresh_metadata_filesize( $id, (string) $size_key, $after ); + } + + return $summary; + } + + /** + * Update _wp_attachment_metadata.sizes[].filesize so WP admin stays accurate. + */ + private static function refresh_metadata_filesize( int $id, string $size_key, int $bytes ): void { + $meta = wp_get_attachment_metadata( $id ); + if ( ! is_array( $meta ) ) { + return; + } + if ( $size_key === 'full' ) { + $meta['filesize'] = $bytes; + } elseif ( isset( $meta['sizes'][ $size_key ] ) ) { + $meta['sizes'][ $size_key ]['filesize'] = $bytes; + } + wp_update_attachment_metadata( $id, $meta ); + } +} diff --git a/includes/class-cli-rescue.php b/includes/class-cli-rescue.php new file mode 100644 index 0000000..d525bbe --- /dev/null +++ b/includes/class-cli-rescue.php @@ -0,0 +1,299 @@ +] + * : Only rescue files with severity >= this. Default 10.0 + * + * [--limit=] + * : Maximum files to process. 0 = unlimited. + * + * [--csv=] + * : Write per-file scan results to CSV. + * + * [--manifest=] + * : Write JSON manifest of rescue actions. + * + * [--backup-dir=] + * : Where to put pre-rescue backups. Default: wp-content/h4b-img-originals/_rescue_/ + * + * ## EXAMPLES + * wp h4b-img rescue --scan + * wp h4b-img rescue --apply --min-severity=20 --limit=10 + * wp h4b-img rescue --apply --csv=/tmp/scan.csv --manifest=/tmp/manifest.json + */ + public function __invoke( $args, $assoc ): void { + $apply = ! empty( $assoc['apply'] ); + $min_sev = (float) ( $assoc['min-severity'] ?? 10.0 ); + $limit = (int) ( $assoc['limit'] ?? 0 ); + $csv_path = $assoc['csv'] ?? null; + $manifest_path = $assoc['manifest'] ?? null; + $backup_dir = $assoc['backup-dir'] ?? null; + + $uploads = wp_get_upload_dir(); + $basedir = trailingslashit( $uploads['basedir'] ); + // Smush's WebP siblings live in wp-content/smush-webp/, NOT inside uploads/. + // Mirror tree: uploads/2026/02/foo.jpg → smush-webp/2026/02/foo.jpg.webp + $webp_root = trailingslashit( WP_CONTENT_DIR ) . 'smush-webp/'; + + if ( ! is_dir( $webp_root ) ) { + \WP_CLI::error( "WebP root not found: $webp_root — rescue requires Smush's smush-webp/ directory to read clean WebP twins from." ); + } + + // Scan phase + \WP_CLI::log( "Scanning $basedir for .jpg files…" ); + $jpgs = self::find_jpgs( $basedir ); + \WP_CLI::log( sprintf( 'Found %d JPG files.', count( $jpgs ) ) ); + + $progress = \WP_CLI\Utils\make_progress_bar( 'Analysing', count( $jpgs ) ); + $results = []; + $broken_list = []; + foreach ( $jpgs as $jpg ) { + $rel = substr( $jpg, strlen( $basedir ) ); + $webp = $webp_root . $rel . '.webp'; + // Also try the symmetric layout where webp is alongside the jpg + if ( ! is_readable( $webp ) ) { + $webp_alt = $jpg . '.webp'; + if ( is_readable( $webp_alt ) ) { + $webp = $webp_alt; + } + } + $res = Rescue_Detector::analyse( $jpg, $webp ); + $res['path'] = $jpg; + $res['rel_path'] = $rel; + $res['has_webp'] = is_readable( $webp ); + $results[] = $res; + if ( $res['classification'] === 'broken' && $res['severity'] >= $min_sev ) { + $broken_list[] = $res; + } + $progress->tick(); + } + $progress->finish(); + + // Sort broken by severity desc + usort( $broken_list, fn( $a, $b ) => $b['severity'] <=> $a['severity'] ); + if ( $limit > 0 ) { + $broken_list = array_slice( $broken_list, 0, $limit ); + } + + $by_class = []; + foreach ( $results as $r ) { + $by_class[ $r['classification'] ] = ( $by_class[ $r['classification'] ] ?? 0 ) + 1; + } + + \WP_CLI::log( '' ); + \WP_CLI::log( 'Classification summary:' ); + foreach ( $by_class as $k => $v ) { + \WP_CLI::log( sprintf( ' %-35s %5d', $k, $v ) ); + } + \WP_CLI::log( sprintf( ' → %d to rescue at severity >= %.1f', count( $broken_list ), $min_sev ) ); + + // CSV + if ( $csv_path ) { + $fh = fopen( $csv_path, 'w' ); + fputcsv( $fh, [ 'path', 'classification', 'severity', 'delta_mean', 'jpg_stddev', 'webp_mean', 'jpg_mean', 'webp_white_pixels', 'has_webp', 'error' ] ); + foreach ( $results as $r ) { + fputcsv( $fh, [ + $r['rel_path'], + $r['classification'], + $r['severity'], + $r['delta_mean'], + $r['jpg_stddev'], + $r['webp_mean'], + $r['jpg_mean'], + $r['webp_white_pixels'], + $r['has_webp'] ? 1 : 0, + $r['error'], + ] ); + } + fclose( $fh ); + \WP_CLI::log( "Scan CSV written: $csv_path" ); + } + + if ( ! $apply ) { + \WP_CLI::success( 'Scan complete. Use --apply to actually rescue.' ); + return; + } + + if ( empty( $broken_list ) ) { + \WP_CLI::success( 'Nothing to rescue.' ); + return; + } + + // Rescue phase + $timestamp = date( 'Ymd_His' ); + $backup_dir = $backup_dir + ?: ( $basedir . Optimizer::ORIGINALS_DIRNAME . '/_rescue_' . $timestamp ); + wp_mkdir_p( $backup_dir ); + \WP_CLI::log( "Backup dir: $backup_dir" ); + + $progress = \WP_CLI\Utils\make_progress_bar( 'Rescuing', count( $broken_list ) ); + $actions = []; + $done = 0; + $errored = 0; + foreach ( $broken_list as $r ) { + $jpg = $r['path']; + // Re-resolve the WebP using the same fallback logic + $webp = $webp_root . $r['rel_path'] . '.webp'; + if ( ! is_readable( $webp ) ) { + $webp_alt = $jpg . '.webp'; + if ( is_readable( $webp_alt ) ) { + $webp = $webp_alt; + } + } + $backup = trailingslashit( $backup_dir ) . $r['rel_path']; + $res = self::rescue_one( $jpg, $webp, $backup ); + $res['path'] = $jpg; + $res['severity'] = $r['severity']; + $actions[] = $res; + if ( $res['status'] === 'done' ) { + $done++; + } else { + $errored++; + } + $progress->tick(); + } + $progress->finish(); + + if ( $manifest_path ) { + file_put_contents( $manifest_path, wp_json_encode( [ + 'timestamp' => $timestamp, + 'backup_dir' => $backup_dir, + 'min_severity' => $min_sev, + 'total' => count( $actions ), + 'done' => $done, + 'errored' => $errored, + 'actions' => $actions, + ], JSON_PRETTY_PRINT ) ); + \WP_CLI::log( "Manifest: $manifest_path" ); + } + + \WP_CLI::success( sprintf( + 'Rescued %d / %d (errors: %d). Backups in %s', + $done, count( $broken_list ), $errored, $backup_dir + ) ); + } + + /** + * @return string[] + */ + private static function find_jpgs( string $root ): array { + $out = []; + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $root, \FilesystemIterator::SKIP_DOTS ) + ); + foreach ( $it as $entry ) { + /** @var \SplFileInfo $entry */ + if ( ! $entry->isFile() ) { + continue; + } + $path = $entry->getPathname(); + // Skip our own working directories + if ( strpos( $path, '/' . Optimizer::ORIGINALS_DIRNAME . '/' ) !== false + || strpos( $path, '/smush-webp/' ) !== false ) { + continue; + } + $ext = strtolower( $entry->getExtension() ); + if ( $ext === 'jpg' || $ext === 'jpeg' ) { + $out[] = $path; + } + } + return $out; + } + + /** + * Rescue a single broken JPG by re-encoding from its clean WebP twin. + * + * @return array{status:string, new_size:int, original_size:int, error:?string, backup_path:string} + */ + private static function rescue_one( string $jpg, string $webp, string $backup_path ): array { + $res = [ + 'status' => 'pending', + 'new_size' => 0, + 'original_size' => is_readable( $jpg ) ? filesize( $jpg ) : 0, + 'error' => null, + 'backup_path' => $backup_path, + ]; + + if ( ! is_readable( $webp ) ) { + $res['status'] = 'error'; + $res['error'] = 'webp_not_readable'; + return $res; + } + + // Backup first + wp_mkdir_p( dirname( $backup_path ) ); + if ( ! @copy( $jpg, $backup_path ) ) { + $res['status'] = 'error'; + $res['error'] = 'backup_failed'; + return $res; + } + if ( filesize( $backup_path ) !== $res['original_size'] ) { + $res['status'] = 'error'; + $res['error'] = 'backup_size_mismatch'; + return $res; + } + + // Decode WebP → re-encode as JPEG q=90, 4:4:4, sRGB, ICC preserved + try { + $img = new Imagick( $webp ); + $img->setImageFormat( 'jpeg' ); + $img->setImageCompressionQuality( 90 ); + $img->setSamplingFactors( [ '1x1', '1x1', '1x1' ] ); // 4:4:4 — no chroma loss + $img->setInterlaceScheme( Imagick::INTERLACE_NO ); + $img->setImageColorspace( Imagick::COLORSPACE_SRGB ); + + // Ensure an ICC profile is attached + ICC_Profile::preserve_or_inject( $img ); + + // Atomic write + $tmp = $jpg . '.h4b.tmp'; + $img->writeImage( $tmp ); + $img->clear(); + + $stat = stat( $jpg ); + if ( $stat !== false ) { + @chown( $tmp, $stat['uid'] ); + @chgrp( $tmp, $stat['gid'] ); + @chmod( $tmp, $stat['mode'] & 0777 ); + } + rename( $tmp, $jpg ); + } catch ( ImagickException $e ) { + $res['status'] = 'error'; + $res['error'] = 'reencode_failed: ' . $e->getMessage(); + // Best-effort restore from backup + @copy( $backup_path, $jpg ); + return $res; + } + + clearstatcache( true, $jpg ); + $res['new_size'] = filesize( $jpg ); + $res['status'] = 'done'; + return $res; + } +} diff --git a/includes/class-cli.php b/includes/class-cli.php new file mode 100644 index 0000000..be64af1 --- /dev/null +++ b/includes/class-cli.php @@ -0,0 +1,180 @@ + + * + * @package H4B\ImageOptim + */ + +namespace H4B\ImageOptim; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +final class CLI { + + public static function register(): void { + \WP_CLI::add_command( 'h4b-img', __CLASS__ ); + \WP_CLI::add_command( 'h4b-img bulk', CLI_Bulk::class ); + \WP_CLI::add_command( 'h4b-img rescue', CLI_Rescue::class ); + } + + /** + * Show tool detection + plugin status. + * + * ## EXAMPLES + * wp h4b-img status + */ + public function status( $args, $assoc ): void { + $tools = Tools::detect(); + \WP_CLI::log( 'h4b-image-optim v' . H4B_IMG_OPTIM_VERSION ); + \WP_CLI::log( '' ); + \WP_CLI::log( 'External tools:' ); + foreach ( $tools as $name => $path ) { + $mark = $path ? '✓' : '✗'; + \WP_CLI::log( sprintf( ' %s %-12s %s', $mark, $name, $path ?: '(not found)' ) ); + } + $missing = Tools::missing_required(); + if ( ! empty( $missing ) ) { + \WP_CLI::warning( 'Missing required tools: ' . implode( ', ', $missing ) ); + } + + \WP_CLI::log( '' ); + \WP_CLI::log( 'Settings:' ); + foreach ( Settings::all() as $k => $v ) { + if ( is_array( $v ) ) { + $v = '[' . implode( ',', $v ) . ']'; + } elseif ( is_bool( $v ) ) { + $v = $v ? 'true' : 'false'; + } + \WP_CLI::log( sprintf( ' %-28s %s', $k, $v ) ); + } + + \WP_CLI::log( '' ); + \WP_CLI::log( 'Originals dir: ' . Optimizer::originals_root() ); + \WP_CLI::log( ' exists: ' . ( is_dir( Optimizer::originals_root() ) ? 'yes' : 'no' ) ); + } + + /** + * Optimise a single attachment by ID. + * + * ## OPTIONS + * + * --id= + * : Attachment post ID. + * + * [--dry-run] + * : Report what would happen without writing. + * + * ## EXAMPLES + * wp h4b-img optimise --id=12345 + * wp h4b-img optimise --id=12345 --dry-run + */ + public function optimise( $args, $assoc ): void { + $id = (int) ( $assoc['id'] ?? 0 ); + if ( $id <= 0 ) { + \WP_CLI::error( '--id required' ); + } + $dry_run = ! empty( $assoc['dry-run'] ); + + $metadata = wp_get_attachment_metadata( $id ); + if ( ! $metadata ) { + \WP_CLI::error( "No metadata for attachment $id" ); + } + $uploads = wp_get_upload_dir(); + $basedir = trailingslashit( $uploads['basedir'] ); + $relative = get_post_meta( $id, '_wp_attached_file', true ); + if ( ! $relative ) { + \WP_CLI::error( "Attachment $id has no _wp_attached_file" ); + } + $full_path = $basedir . $relative; + if ( ! is_readable( $full_path ) ) { + \WP_CLI::error( "Not readable: $full_path" ); + } + + \WP_CLI::log( "Attachment $id: $relative" ); + \WP_CLI::log( ' full path: ' . $full_path ); + + $summary = [ + 'full' => self::run_one( $id, 'full', $full_path, $dry_run ), + ]; + + $dir = dirname( $full_path ); + foreach ( ( $metadata['sizes'] ?? [] ) as $size_key => $size_data ) { + if ( empty( $size_data['file'] ) ) { + continue; + } + $path = trailingslashit( $dir ) . $size_data['file']; + if ( ! is_readable( $path ) ) { + continue; + } + $summary[ $size_key ] = self::run_one( $id, (string) $size_key, $path, $dry_run ); + } + + // Tabular summary + $rows = []; + foreach ( $summary as $size => $r ) { + $rows[] = [ + 'size' => $size, + 'status' => $r['status'] ?? '', + 'before' => $r['before'] ?? 0, + 'after' => $r['after'] ?? 0, + 'pct' => $r['percent'] ?? 0, + 'icc' => ! empty( $r['icc_preserved'] ) ? '✓' : '', + 'webp' => $r['webp_size'] ?? '', + 'avif' => $r['avif_status'] ?? '', + 'error' => $r['error'] ?? '', + ]; + } + \WP_CLI\Utils\format_items( 'table', $rows, [ 'size', 'status', 'before', 'after', 'pct', 'icc', 'webp', 'avif', 'error' ] ); + } + + private static function run_one( int $id, string $size_key, string $path, bool $dry_run ): array { + if ( $dry_run ) { + return [ + 'status' => 'dry-run', + 'before' => filesize( $path ), + 'after' => filesize( $path ), + 'percent' => 0, + ]; + } + $opt = Optimizer::optimise( $path ); + + $webp_stats = [ 'status' => 'skipped' ]; + $avif_stats = [ 'status' => 'skipped' ]; + $generate_siblings = in_array( $opt['status'], [ 'done', 'skipped' ], true ); + + if ( $generate_siblings && Settings::get( 'generate_webp', true ) ) { + $webp_stats = Format_Generator::make_webp( $path ); + } + if ( $generate_siblings && Settings::get( 'generate_avif', true ) ) { + $avif_stats = Format_Generator::make_avif( $path ); + } + + $record = [ + 'status' => $opt['status'], + 'before' => $opt['before'], + 'after' => $opt['after'], + 'percent' => $opt['percent'], + 'icc_preserved' => $opt['icc_preserved'] ?? false, + 'tool_chain' => $opt['tool_chain'] ?? [], + 'webp' => $webp_stats['status'] === 'done' ? $webp_stats['size'] : null, + 'avif' => $avif_stats['status'] === 'done' ? $avif_stats['size'] : null, + 'avif_status' => $avif_stats['status'], + 'backup' => $opt['backup_path'] ?? null, + 'error' => $opt['error'] ?? null, + ]; + Attachment_Meta::record_size( $id, $size_key, $record ); + + return [ + 'status' => $opt['status'], + 'before' => $opt['before'], + 'after' => $opt['after'], + 'percent' => $opt['percent'], + 'icc_preserved' => $opt['icc_preserved'] ?? false, + 'webp_size' => $webp_stats['status'] === 'done' ? $webp_stats['size'] : '', + 'avif_status' => $avif_stats['status'], + 'error' => $opt['error'] ?? null, + ]; + } +} diff --git a/includes/class-format-generator.php b/includes/class-format-generator.php new file mode 100644 index 0000000..23f7e62 --- /dev/null +++ b/includes/class-format-generator.php @@ -0,0 +1,185 @@ + 'pending', 'path' => null, 'size' => 0, 'error' => null ]; + + $bin = Tools::path( 'cwebp' ); + if ( ! $bin ) { + $res['status'] = 'error'; + $res['error'] = 'cwebp_not_installed'; + return $res; + } + + if ( ! is_readable( $source ) ) { + $res['status'] = 'error'; + $res['error'] = 'source_not_readable'; + return $res; + } + + $settings = array_replace( Settings::all(), $opts ); + $quality = (int) ( $settings['webp_quality'] ?? 80 ); + $dest = $source . '.webp'; + $tmp = $dest . '.h4b.tmp'; + + // -metadata icc preserves the ICC profile if present + // -mt enables multithreading + // -m 4 = method 4 (good balance of compression vs speed) + $cmd = sprintf( + '%s -quiet -q %d -m 4 -mt -metadata icc %s -o %s 2>&1', + escapeshellcmd( $bin ), + $quality, + escapeshellarg( $source ), + escapeshellarg( $tmp ) + ); + $out = @shell_exec( $cmd ); + + if ( ! is_readable( $tmp ) || filesize( $tmp ) <= 0 ) { + $res['status'] = 'error'; + $res['error'] = 'cwebp_failed: ' . trim( (string) $out ); + @unlink( $tmp ); + return $res; + } + + // Preserve ownership/perms + $stat = stat( $source ); + if ( $stat !== false ) { + @chown( $tmp, $stat['uid'] ); + @chgrp( $tmp, $stat['gid'] ); + @chmod( $tmp, $stat['mode'] & 0777 ); + } + rename( $tmp, $dest ); + + $res['status'] = 'done'; + $res['path'] = $dest; + $res['size'] = filesize( $dest ); + return $res; + } + + /** + * Generate the .avif sibling. + * + * If settings.avif_async is true, this enqueues a WP-Cron job and returns + * status=queued. Otherwise generates synchronously. + */ + public static function make_avif( string $source, array $opts = [] ): array { + $res = [ 'status' => 'pending', 'path' => null, 'size' => 0, 'error' => null ]; + + if ( ! Tools::has_avif() ) { + $res['status'] = 'error'; + $res['error'] = 'avifenc_not_installed'; + return $res; + } + + $settings = array_replace( Settings::all(), $opts ); + if ( empty( $settings['generate_avif'] ) ) { + $res['status'] = 'skipped'; + $res['error'] = 'avif_disabled_in_settings'; + return $res; + } + + if ( ! empty( $settings['avif_async'] ) && ! ( defined( 'DOING_CRON' ) && DOING_CRON ) ) { + // Schedule a one-off job in 30 seconds; let the upload return. + wp_schedule_single_event( time() + 30, self::CRON_HOOK, [ $source ] ); + $res['status'] = 'queued'; + return $res; + } + + return self::encode_avif_now( $source, $settings ); + } + + /** + * Cron entry point — encodes one queued path. + */ + public static function process_avif_job( string $source ): void { + if ( ! is_readable( $source ) ) { + return; + } + $settings = Settings::all(); + self::encode_avif_now( $source, $settings ); + } + + private static function encode_avif_now( string $source, array $settings ): array { + $res = [ 'status' => 'pending', 'path' => null, 'size' => 0, 'error' => null ]; + + $bin = Tools::path( 'avifenc' ); + if ( ! $bin ) { + $res['status'] = 'error'; + $res['error'] = 'avifenc_not_installed'; + return $res; + } + + $quality = (int) ( $settings['avif_quality'] ?? 65 ); + $speed = (int) ( $settings['avif_speed'] ?? 6 ); + + // libavif quality is 0-100 (higher = better). avifenc 0.11 uses --min / --max + // where lower values = higher quality (counterintuitive, inherited from av1). + // Map: quality 65 → --min 25 --max 35 (matches our smoke test settings) + $qmin = max( 0, (int) round( ( 100 - $quality ) * 0.6 ) ); + $qmax = max( $qmin + 5, $qmin + 10 ); + + $dest = $source . '.avif'; + $tmp = $dest . '.h4b.tmp'; + + $cmd = sprintf( + '%s --min %d --max %d -s %d %s %s 2>&1', + escapeshellcmd( $bin ), + $qmin, + $qmax, + $speed, + escapeshellarg( $source ), + escapeshellarg( $tmp ) + ); + $out = @shell_exec( $cmd ); + + if ( ! is_readable( $tmp ) || filesize( $tmp ) <= 0 ) { + $res['status'] = 'error'; + $res['error'] = 'avifenc_failed: ' . trim( (string) $out ); + @unlink( $tmp ); + return $res; + } + + $stat = stat( $source ); + if ( $stat !== false ) { + @chown( $tmp, $stat['uid'] ); + @chgrp( $tmp, $stat['gid'] ); + @chmod( $tmp, $stat['mode'] & 0777 ); + } + rename( $tmp, $dest ); + + $res['status'] = 'done'; + $res['path'] = $dest; + $res['size'] = filesize( $dest ); + return $res; + } +} diff --git a/includes/class-icc-profile.php b/includes/class-icc-profile.php new file mode 100644 index 0000000..9d53794 --- /dev/null +++ b/includes/class-icc-profile.php @@ -0,0 +1,67 @@ +getImageProfiles( 'icc', true ); + } catch ( ImagickException $e ) { + $profiles = []; + } + if ( ! empty( $profiles ) ) { + // Source has an ICC profile — leave it alone. This is the + // common case for camera JPEGs and properly-saved Photoshop exports. + return [ 'result' => true, 'source' => 'original' ]; + } + + // No profile. Try to inject sRGB v4 fallback. + $profile_path = self::srgb_profile_path(); + if ( ! is_readable( $profile_path ) ) { + return [ 'result' => false, 'source' => 'none' ]; + } + + try { + $img->setImageColorspace( Imagick::COLORSPACE_SRGB ); + $img->profileImage( 'icc', (string) file_get_contents( $profile_path ) ); + return [ 'result' => true, 'source' => 'injected' ]; + } catch ( ImagickException $e ) { + return [ 'result' => false, 'source' => 'none' ]; + } + } +} diff --git a/includes/class-optimizer.php b/includes/class-optimizer.php new file mode 100644 index 0000000..3d6abef --- /dev/null +++ b/includes/class-optimizer.php @@ -0,0 +1,353 @@ + + * 2. Optionally rotate based on EXIF Orientation + * 3. Re-encode with ICC preserved and 4:4:4 chroma (the bug fix) + * 4. Pass through jpegoptim/pngquant for lossless tail + * 5. Queue WebP + AVIF sibling generation (Format_Generator) + * + * @package H4B\ImageOptim + */ + +namespace H4B\ImageOptim; + +use Imagick; +use ImagickException; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +final class Optimizer { + + public const ORIGINALS_DIRNAME = 'h4b-img-originals'; + + public static function ensure_dirs(): void { + $root = self::originals_root(); + if ( ! is_dir( $root ) ) { + wp_mkdir_p( $root ); + } + // Prevent any web-side access; originals are operations-only. + $ht = $root . '/.htaccess'; + if ( ! file_exists( $ht ) ) { + file_put_contents( $ht, "Order allow,deny\nDeny from all\n" ); + } + } + + public static function originals_root(): string { + $uploads = wp_get_upload_dir(); + return trailingslashit( $uploads['basedir'] ) . self::ORIGINALS_DIRNAME; + } + + /** + * Optimise one file in place. + * + * @param string $path Absolute path to a JPEG or PNG file under uploads/. + * @param array $opts Override settings (jpeg_quality, etc). + * @return array{ + * status:string, + * path:string, + * mime:string, + * before:int, + * after:int, + * percent:float, + * icc_preserved:bool, + * backup_path:?string, + * tool_chain:array, + * error:?string + * } + */ + public static function optimise( string $path, array $opts = [] ): array { + $result = [ + 'status' => 'pending', + 'path' => $path, + 'mime' => '', + 'before' => 0, + 'after' => 0, + 'percent' => 0.0, + 'icc_preserved' => false, + 'backup_path' => null, + 'tool_chain' => [], + 'error' => null, + ]; + + if ( ! is_readable( $path ) || ! is_writable( $path ) ) { + $result['status'] = 'error'; + $result['error'] = 'not_readable_or_writable'; + return $result; + } + + $settings = array_replace( Settings::all(), $opts ); + $result['before'] = filesize( $path ); + + $mime = self::detect_mime( $path ); + $result['mime'] = $mime; + if ( ! in_array( $mime, [ 'image/jpeg', 'image/png' ], true ) ) { + $result['status'] = 'skipped'; + $result['error'] = 'unsupported_mime:' . $mime; + return $result; + } + + // Don't re-encode small thumbnails. JPEG q=85 + ICC profile on a tiny + // file INCREASES size (2.5KB profile is huge relative to <20KB images). + // At those dimensions humans can't see chroma artefacts anyway, so the + // Smush-bug fix isn't relevant. WebP/AVIF siblings still get made. + // 20KB threshold matches WordPress's typical thumbnail/medium sizes. + $threshold = (int) ( $settings['min_optimise_bytes'] ?? 20480 ); + if ( $result['before'] < $threshold ) { + $result['status'] = 'skipped'; + $result['error'] = 'too_small_keep_as_is'; + $result['after'] = $result['before']; + return $result; + } + + // 1. Backup the original (atomic copy, only once per file) + if ( ! empty( $settings['backup_originals'] ) ) { + $backup = self::backup( $path ); + if ( $backup === null ) { + $result['status'] = 'error'; + $result['error'] = 'backup_failed'; + return $result; + } + $result['backup_path'] = $backup; + } + + // 2. Re-encode via Imagick (handles ICC + orientation cleanly) + try { + $reencoded = self::reencode( $path, $mime, $settings ); + } catch ( \Throwable $e ) { + $result['status'] = 'error'; + $result['error'] = 'reencode_failed: ' . $e->getMessage(); + return $result; + } + + $result['icc_preserved'] = $reencoded['icc_preserved']; + $result['tool_chain'][] = 'imagick'; + + // 3. Lossless tail pass to squeeze the last few percent + if ( $mime === 'image/jpeg' && Tools::path( 'jpegoptim' ) ) { + self::jpegoptim_pass( $path, $settings ); + $result['tool_chain'][] = 'jpegoptim'; + } + if ( $mime === 'image/png' && Tools::path( 'pngquant' ) ) { + self::pngquant_pass( $path, $settings ); + $result['tool_chain'][] = 'pngquant'; + } + + clearstatcache( true, $path ); + $result['after'] = filesize( $path ); + $result['percent'] = $result['before'] > 0 + ? round( ( $result['before'] - $result['after'] ) / $result['before'] * 100, 2 ) + : 0.0; + $result['status'] = 'done'; + + return $result; + } + + /** + * Re-encode through Imagick to enforce: ICC preserved, 4:4:4 chroma, + * EXIF orientation applied, sRGB colorspace. + * + * @return array{icc_preserved:bool} + */ + private static function reencode( string $path, string $mime, array $settings ): array { + $img = new Imagick( $path ); + + // Apply EXIF orientation so the raw pixels are upright, then strip orientation tag. + try { + $orient = $img->getImageOrientation(); + switch ( $orient ) { + case Imagick::ORIENTATION_BOTTOMRIGHT: + $img->rotateImage( 'transparent', 180 ); break; + case Imagick::ORIENTATION_RIGHTTOP: + $img->rotateImage( 'transparent', 90 ); break; + case Imagick::ORIENTATION_LEFTBOTTOM: + $img->rotateImage( 'transparent', -90 ); break; + } + if ( $orient !== Imagick::ORIENTATION_TOPLEFT ) { + $img->setImageOrientation( Imagick::ORIENTATION_TOPLEFT ); + } + } catch ( ImagickException $e ) { + // Some images have no orientation tag — that's fine. + } + + // Preserve ICC profile — the critical fix + $icc = ICC_Profile::preserve_or_inject( $img ); + $icc_ok = $icc['result']; + $had_source_profile = $icc['source'] === 'original'; + + // Strip EXIF privacy fields but KEEP ICC profile. + // Imagick stripImage() removes everything including ICC, so we don't call it. + // Instead we remove specific profiles selectively. + if ( ! empty( $settings['strip_gps_exif'] ) ) { + try { $img->removeImageProfile( 'exif' ); } catch ( ImagickException $e ) {} + // Note: this drops ALL exif including camera. For art use-case we + // might want to re-add ICC. ICC is already attached above. + } + + if ( $mime === 'image/jpeg' ) { + $quality = (int) ( $settings['jpeg_quality'] ?? 85 ); + // For small thumbnails, the grey-wash bug doesn't matter (browser + // renders them too small to see ringing). Use the standard 4:2:0 + // subsampling to keep file size reasonable. For larger images use + // 4:4:4 (the bug-fix subsampling). Threshold: 400x400 = 160000 px. + $width = $img->getImageWidth(); + $height = $img->getImageHeight(); + $is_small = ( $width * $height ) < 160000; + $subsamp = $is_small + ? '4:2:0' + : ( $settings['jpeg_chroma_subsampling'] ?? '4:4:4' ); + $img->setImageCompressionQuality( $quality ); + $img->setSamplingFactors( self::sampling_factors( $subsamp ) ); + $img->setInterlaceScheme( Imagick::INTERLACE_NO ); + $img->setImageColorspace( Imagick::COLORSPACE_SRGB ); + + // Skip ICC injection on tiny files — the profile costs 2.5KB which + // dwarfs the actual image bytes for thumbnails under 20KB + if ( $is_small && empty( $had_source_profile ) ) { + try { $img->removeImageProfile( 'icc' ); } catch ( ImagickException $e ) {} + } + } elseif ( $mime === 'image/png' ) { + // Lossless PNG via Imagick — actual compression happens in pngquant pass. + $img->setImageCompressionQuality( 95 ); + } + + // Write back to the same path atomically + $tmp = $path . '.h4b.tmp'; + if ( $mime === 'image/jpeg' ) { + $img->writeImage( $tmp ); + } else { + $img->writeImage( $tmp ); + } + $img->clear(); + $img->destroy(); + + // Preserve original ownership and mtime + $stat = stat( $path ); + if ( $stat !== false ) { + @chown( $tmp, $stat['uid'] ); + @chgrp( $tmp, $stat['gid'] ); + @chmod( $tmp, $stat['mode'] & 0777 ); + } + rename( $tmp, $path ); + + return [ 'icc_preserved' => $icc_ok ]; + } + + private static function sampling_factors( string $mode ): array { + switch ( $mode ) { + case '4:2:0': return [ '2x2', '1x1', '1x1' ]; + case '4:2:2': return [ '2x1', '1x1', '1x1' ]; + case '4:4:4': + default: return [ '1x1', '1x1', '1x1' ]; + } + } + + private static function jpegoptim_pass( string $path, array $settings ): void { + $bin = Tools::path( 'jpegoptim' ); + if ( ! $bin ) { + return; + } + // --strip-none keeps EXIF + ICC intact. -P preserves file timestamps. + // -o overwrites only if smaller. -q quiet. + $cmd = sprintf( + '%s --strip-none -P -o -q -m%d %s 2>&1', + escapeshellcmd( $bin ), + (int) ( $settings['jpeg_quality'] ?? 85 ), + escapeshellarg( $path ) + ); + @shell_exec( $cmd ); + } + + private static function pngquant_pass( string $path, array $settings ): void { + $bin = Tools::path( 'pngquant' ); + if ( ! $bin ) { + return; + } + $min = (int) ( $settings['png_quality_min'] ?? 70 ); + $max = (int) ( $settings['png_quality_max'] ?? 90 ); + $cmd = sprintf( + '%s --force --skip-if-larger --speed 3 --quality=%d-%d --output %s -- %s 2>&1', + escapeshellcmd( $bin ), + $min, + $max, + escapeshellarg( $path . '.pngq.tmp' ), + escapeshellarg( $path ) + ); + @shell_exec( $cmd ); + if ( is_readable( $path . '.pngq.tmp' ) && filesize( $path . '.pngq.tmp' ) > 0 ) { + $stat = stat( $path ); + if ( $stat !== false ) { + @chown( $path . '.pngq.tmp', $stat['uid'] ); + @chgrp( $path . '.pngq.tmp', $stat['gid'] ); + @chmod( $path . '.pngq.tmp', $stat['mode'] & 0777 ); + } + rename( $path . '.pngq.tmp', $path ); + } + } + + private static function detect_mime( string $path ): string { + $info = @getimagesize( $path ); + return $info['mime'] ?? ''; + } + + /** + * Copy the original to wp-content/h4b-img-originals/, preserving mtime. + * Skips if a backup already exists (don't overwrite the *original* with a re-processed file). + */ + private static function backup( string $path ): ?string { + $uploads = wp_get_upload_dir(); + $basedir = trailingslashit( $uploads['basedir'] ); + + if ( strpos( $path, $basedir ) !== 0 ) { + return null; // outside uploads/, refuse + } + $rel = substr( $path, strlen( $basedir ) ); + $dest = trailingslashit( self::originals_root() ) . $rel; + + if ( file_exists( $dest ) ) { + return $dest; // already backed up — don't overwrite + } + + wp_mkdir_p( dirname( $dest ) ); + if ( ! copy( $path, $dest ) ) { + return null; + } + // Preserve mtime so prune_originals can age files reliably + @touch( $dest, filemtime( $path ) ); + return $dest; + } + + /** + * Cron callback: delete originals older than settings.backup_prune_days. + */ + public static function prune_originals(): void { + $days = (int) Settings::get( 'backup_prune_days', 90 ); + if ( $days <= 0 ) { + return; + } + $cutoff = time() - $days * DAY_IN_SECONDS; + $root = self::originals_root(); + if ( ! is_dir( $root ) ) { + return; + } + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $root, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST + ); + $deleted = 0; + foreach ( $it as $entry ) { + /** @var \SplFileInfo $entry */ + if ( $entry->isFile() && $entry->getMTime() < $cutoff ) { + @unlink( $entry->getPathname() ); + $deleted++; + } elseif ( $entry->isDir() ) { + @rmdir( $entry->getPathname() ); // succeeds only if empty + } + } + do_action( 'h4b_img_originals_pruned', $deleted, $days ); + } +} diff --git a/includes/class-plugin.php b/includes/class-plugin.php new file mode 100644 index 0000000..5283bac --- /dev/null +++ b/includes/class-plugin.php @@ -0,0 +1,80 @@ += WEBP_WHITE_RGB_MIN (i.e. should-be-white regions). + * 2. Sample those same pixel coordinates in the JPG. + * 3. If the JPG average there is significantly darker AND the stddev is + * high, classify as `broken` (the Smush grey-wash bug fingerprint). + * + * @package H4B\ImageOptim + */ + +namespace H4B\ImageOptim; + +use Imagick; +use ImagickPixel; +use ImagickException; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +final class Rescue_Detector { + + // Calibrated thresholds (validated against rds.ink + dayboro + tawnytrails) + public const WEBP_WHITE_RGB_MIN = 248; + public const JPG_DARKER_THAN_WEBP = 10; + public const JPG_WHITE_STDDEV_BROKEN = 4.0; + public const MIN_WHITE_PIXELS = 100; + + /** + * @return array{ + * classification:string, + * severity:float, + * delta_mean:float, + * jpg_stddev:float, + * webp_mean:float, + * jpg_mean:float, + * webp_white_pixels:int, + * error:?string + * } + */ + public static function analyse( string $jpg_path, string $webp_path ): array { + $result = [ + 'classification' => 'unknown', + 'severity' => 0.0, + 'delta_mean' => 0.0, + 'jpg_stddev' => 0.0, + 'webp_mean' => 0.0, + 'jpg_mean' => 0.0, + 'webp_white_pixels' => 0, + 'error' => null, + ]; + + if ( ! is_readable( $jpg_path ) ) { + $result['classification'] = 'error'; + $result['error'] = 'jpg_not_readable'; + return $result; + } + if ( ! is_readable( $webp_path ) ) { + $result['classification'] = 'no_webp'; + return $result; + } + + try { + $jpg = new Imagick( $jpg_path ); + $webp = new Imagick( $webp_path ); + } catch ( ImagickException $e ) { + $result['classification'] = 'error'; + $result['error'] = 'imagick_open: ' . $e->getMessage(); + return $result; + } + + // Resize webp to match jpg if needed (handles minor dimension drift) + $jw = $jpg->getImageWidth(); + $jh = $jpg->getImageHeight(); + if ( $webp->getImageWidth() !== $jw || $webp->getImageHeight() !== $jh ) { + $webp->resizeImage( $jw, $jh, Imagick::FILTER_LANCZOS, 1.0 ); + } + + // Pull raw pixel data as RGB bytes + try { + $jpg_bytes = $jpg->exportImagePixels( 0, 0, $jw, $jh, 'RGB', Imagick::PIXEL_CHAR ); + $webp_bytes = $webp->exportImagePixels( 0, 0, $jw, $jh, 'RGB', Imagick::PIXEL_CHAR ); + } catch ( ImagickException $e ) { + $jpg->clear(); $webp->clear(); + $result['classification'] = 'error'; + $result['error'] = 'pixel_export: ' . $e->getMessage(); + return $result; + } + $jpg->clear(); + $webp->clear(); + + $n_pixels = $jw * $jh; + $threshold = self::WEBP_WHITE_RGB_MIN; + + $webp_white_count = 0; + $jpg_sum = 0.0; + $webp_sum = 0.0; + $jpg_sum_sq = 0.0; + + // Iterate; PHP arrays from exportImagePixels are 0-indexed integers + for ( $i = 0; $i < $n_pixels; $i++ ) { + $base = $i * 3; + $wr = $webp_bytes[ $base ]; + $wg = $webp_bytes[ $base + 1 ]; + $wb = $webp_bytes[ $base + 2 ]; + if ( $wr < $threshold || $wg < $threshold || $wb < $threshold ) { + continue; + } + $jr = $jpg_bytes[ $base ]; + $jg = $jpg_bytes[ $base + 1 ]; + $jb = $jpg_bytes[ $base + 2 ]; + $jpg_mean_pixel = ( $jr + $jg + $jb ) / 3.0; + $webp_mean_pixel = ( $wr + $wg + $wb ) / 3.0; + + $webp_white_count++; + $jpg_sum += $jpg_mean_pixel; + $webp_sum += $webp_mean_pixel; + $jpg_sum_sq += $jpg_mean_pixel * $jpg_mean_pixel; + } + + $result['webp_white_pixels'] = $webp_white_count; + + if ( $webp_white_count < self::MIN_WHITE_PIXELS ) { + $result['classification'] = 'no_white_area'; + return $result; + } + + $jpg_mean = $jpg_sum / $webp_white_count; + $webp_mean = $webp_sum / $webp_white_count; + $variance = ( $jpg_sum_sq / $webp_white_count ) - ( $jpg_mean * $jpg_mean ); + $jpg_stddev = $variance > 0 ? sqrt( $variance ) : 0.0; + + $result['jpg_mean'] = $jpg_mean; + $result['webp_mean'] = $webp_mean; + $result['delta_mean'] = $webp_mean - $jpg_mean; + $result['jpg_stddev'] = $jpg_stddev; + + $is_darker = $result['delta_mean'] > self::JPG_DARKER_THAN_WEBP; + $is_mottled = $jpg_stddev > self::JPG_WHITE_STDDEV_BROKEN; + + if ( $is_darker && $is_mottled ) { + $result['classification'] = 'broken'; + $result['severity'] = $result['delta_mean'] + * ( $jpg_stddev / self::JPG_WHITE_STDDEV_BROKEN ); + } elseif ( $is_darker ) { + $result['classification'] = 'uniformly_darker'; + $result['severity'] = $result['delta_mean']; + } elseif ( $is_mottled ) { + $result['classification'] = 'mottled_but_correct_brightness'; + $result['severity'] = $jpg_stddev; + } else { + $result['classification'] = 'clean'; + } + + return $result; + } +} diff --git a/includes/class-settings.php b/includes/class-settings.php new file mode 100644 index 0000000..be9052b --- /dev/null +++ b/includes/class-settings.php @@ -0,0 +1,89 @@ + 85, + 'png_quality_min' => 70, + 'png_quality_max' => 90, + 'jpeg_chroma_subsampling' => '4:4:4', // preserve edges; the fix for the Smush bug + 'preserve_icc_profile' => true, // CRITICAL — the bug fix + 'strip_gps_exif' => true, + 'strip_camera_exif' => false, // keep camera info for art provenance + + // Format generation + 'generate_webp' => true, + 'generate_avif' => true, // design decision 1: on by default everywhere + 'webp_quality' => 80, + 'avif_quality' => 65, // ≈ JPEG q=85 visually + 'avif_speed' => 6, // 0-10; 6 is balanced + 'avif_async' => true, // background WP-Cron so upload UI is responsive + + // Behaviour + 'optimise_on_upload' => true, + 'backup_originals' => true, // design decision 4: always + 'backup_prune_days' => 90, // 0 = keep forever + 'resize_max_width' => 2560, + 'resize_max_height' => 2560, + 'skip_already_processed' => true, + 'min_optimise_bytes' => 20480, // skip files < 20KB (thumbnails) + + // Serving + 'rewrite_content_images' => true, // Picture tag rewriting + 'use_htaccess_fallback' => true, // .htaccess content negotiation + + // Bulk + 'bulk_batch_size' => 10, + 'bulk_pause_seconds' => 1, + + // Exclusions + 'exclude_mime_types' => [ 'image/svg+xml', 'image/x-icon', 'image/gif' ], + 'exclude_paths_regex' => '', + ]; + } + + public static function install_defaults(): void { + $existing = get_option( self::OPTION_KEY ); + if ( false === $existing ) { + add_option( self::OPTION_KEY, self::defaults(), '', false ); + return; + } + // Merge new keys without clobbering user changes + $merged = array_replace( self::defaults(), is_array( $existing ) ? $existing : [] ); + update_option( self::OPTION_KEY, $merged, false ); + } + + public static function get( string $key, mixed $default = null ): mixed { + $settings = get_option( self::OPTION_KEY, self::defaults() ); + return $settings[ $key ] ?? $default; + } + + public static function set( string $key, mixed $value ): void { + $settings = get_option( self::OPTION_KEY, self::defaults() ); + $settings[ $key ] = $value; + update_option( self::OPTION_KEY, $settings, false ); + } + + public static function all(): array { + return get_option( self::OPTION_KEY, self::defaults() ); + } +} diff --git a/includes/class-tools.php b/includes/class-tools.php new file mode 100644 index 0000000..2324dbf --- /dev/null +++ b/includes/class-tools.php @@ -0,0 +1,68 @@ + Map of tool name → absolute path or null. + */ + public static function detect(): array { + static $cache = null; + if ( null !== $cache ) { + return $cache; + } + $cache = []; + foreach ( array_merge( self::REQUIRED, self::OPTIONAL ) as $bin ) { + $cache[ $bin ] = self::which( $bin ); + } + return $cache; + } + + public static function path( string $bin ): ?string { + $tools = self::detect(); + return $tools[ $bin ] ?? null; + } + + public static function missing_required(): array { + $tools = self::detect(); + $missing = []; + foreach ( self::REQUIRED as $bin ) { + if ( empty( $tools[ $bin ] ) ) { + $missing[] = $bin; + } + } + return $missing; + } + + public static function has_avif(): bool { + return ! empty( self::path( 'avifenc' ) ); + } + + private static function which( string $bin ): ?string { + // Common locations first, then fall back to PATH lookup. + foreach ( [ '/usr/bin/', '/usr/local/bin/', '/opt/local/bin/' ] as $prefix ) { + $candidate = $prefix . $bin; + if ( is_executable( $candidate ) ) { + return $candidate; + } + } + if ( ! function_exists( 'shell_exec' ) ) { + return null; + } + $out = trim( (string) @shell_exec( 'command -v ' . escapeshellarg( $bin ) . ' 2>/dev/null' ) ); + return $out !== '' && is_executable( $out ) ? $out : null; + } +} diff --git a/includes/class-uploader-hook.php b/includes/class-uploader-hook.php new file mode 100644 index 0000000..01c4803 --- /dev/null +++ b/includes/class-uploader-hook.php @@ -0,0 +1,112 @@ + $size_data ) { + if ( empty( $size_data['file'] ) ) { + continue; + } + $size_path = trailingslashit( $dir ) . $size_data['file']; + if ( ! is_readable( $size_path ) ) { + continue; + } + self::process_single( (int) $attachment_id, (string) $size_key, $size_path ); + // Refresh filesize in metadata after re-encode + clearstatcache( true, $size_path ); + $metadata['sizes'][ $size_key ]['filesize'] = filesize( $size_path ); + } + + // Refresh full filesize too + clearstatcache( true, $full_path ); + $metadata['filesize'] = filesize( $full_path ); + + return $metadata; + } + + private static function process_single( int $attachment_id, string $size_key, string $path ): void { + $opt = Optimizer::optimise( $path ); + + // Generate WebP/AVIF siblings regardless of whether Optimizer touched + // the source JPEG (e.g. it might have skipped tiny thumbnails). + // Sibling generation is still valuable for those. + $webp_stats = [ 'status' => 'skipped' ]; + $avif_stats = [ 'status' => 'skipped' ]; + $generate_siblings = in_array( $opt['status'], [ 'done', 'skipped' ], true ); + + if ( $generate_siblings && Settings::get( 'generate_webp', true ) ) { + $webp_stats = Format_Generator::make_webp( $path ); + } + if ( $generate_siblings && Settings::get( 'generate_avif', true ) ) { + $avif_stats = Format_Generator::make_avif( $path ); + if ( $avif_stats['status'] === 'queued' ) { + Attachment_Meta::mark_avif_pending( $attachment_id, $size_key ); + } + } + + Attachment_Meta::record_size( $attachment_id, $size_key, [ + 'status' => $opt['status'], + 'before' => $opt['before'], + 'after' => $opt['after'], + 'percent' => $opt['percent'], + 'icc_preserved' => $opt['icc_preserved'] ?? false, + 'tool_chain' => $opt['tool_chain'] ?? [], + 'webp' => $webp_stats['status'] === 'done' ? $webp_stats['size'] : null, + 'avif' => $avif_stats['status'] === 'done' ? $avif_stats['size'] : null, + 'avif_status' => $avif_stats['status'], + 'backup' => $opt['backup_path'] ?? null, + 'error' => $opt['error'] ?? null, + ] ); + } +} diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..42ee04b --- /dev/null +++ b/uninstall.php @@ -0,0 +1,21 @@ +