Skip to content

Feature: CSS Gradient Low Quality Image Placeholders - #2550

Open
nickchomey wants to merge 28 commits into
WordPress:trunkfrom
nickchomey:feature/css-gradient-lqip
Open

Feature: CSS Gradient Low Quality Image Placeholders#2550
nickchomey wants to merge 28 commits into
WordPress:trunkfrom
nickchomey:feature/css-gradient-lqip

Conversation

@nickchomey

@nickchomey nickchomey commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #2519 and #2535

Note:

This is stacked on/includes #2524, which isn't totally necessary but I think made improvements to the test functions. I can rebase this without any of that if desired.

Relevant technical choices

Implemented the CSS Gradient-based LQIP mechanism described in #2519. Extensive testing was done with various ways to generate the dominant color (the original used ColorThief, which has a php equivalent). On rare occasions, the fancier methods produced somewhat better gradients, but at great processing cost. Ultimately I found that the 1x1 pixel was sufficiently-good - especially given that it is already implemented.

I also changed the GD and Imagick editors to extract the color in linear rgb, as per #2535. Updated some tests to reflect the different results. They all pass for me.

Here's a video that shows the gradients using the previous colorspace (left) and the new colorspace (right), generated by imagick. Generally no difference, but its somewhat better in a few cases.

Kooha-2026-06-25-15-22-14.mp4

Nothing was done to accomodate any sort of transition. To use this, all images would need to be reprocessed. Both the dominant color hex and lqip values are generated and stored in postmeta.

Use of AI Tools

I used Deepseek V4 Flash via Github Copilot to assist with this. I reviewed and iterated on everything.

@github-actions github-actions Bot added the [Plugin] Image Placeholders Issues for the Image Placeholders plugin (formerly Dominant Color Images) label Jun 25, 2026
@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: nickchomey <nickchomey@git.wordpress.org>
Co-authored-by: westonruter <westonruter@git.wordpress.org>
Co-authored-by: adamsilverstein <adamsilverstein@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@codecov

codecov Bot commented Jun 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 55.48173% with 134 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.79%. Comparing base (cedbbac) to head (6db1b1b).
⚠️ Report is 4 commits behind head on trunk.

Files with missing lines Patch % Lines
...or-images/class-dominant-color-image-editor-gd.php 4.91% 116 Missing ⚠️
plugins/dominant-color-images/hooks.php 66.66% 7 Missing ⚠️
plugins/dominant-color-images/helper.php 81.81% 6 Missing ⚠️
...ages/class-dominant-color-image-editor-imagick.php 88.88% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##            trunk    #2550      +/-   ##
==========================================
- Coverage   70.35%   69.79%   -0.57%     
==========================================
  Files          91       92       +1     
  Lines        7867     8121     +254     
==========================================
+ Hits         5535     5668     +133     
- Misses       2332     2453     +121     
Flag Coverage Δ
multisite 69.79% <55.48%> (-0.57%) ⬇️
single 35.68% <50.83%> (+0.51%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread plugins/dominant-color-images/assets/lqip.css Outdated
Comment thread plugins/dominant-color-images/class-dominant-color-image-editor-gd.php Outdated
Comment thread plugins/dominant-color-images/hooks.php Outdated
Comment thread plugins/dominant-color-images/hooks.php Outdated
Comment thread plugins/dominant-color-images/hooks.php Outdated
Comment thread plugins/dominant-color-images/class-dominant-color-image-editor-imagick.php Outdated
Comment thread plugins/dominant-color-images/class-dominant-color-image-editor-gd.php Outdated
Comment thread plugins/dominant-color-images/class-dominant-color-image-editor-gd.php Outdated
Comment thread plugins/dominant-color-images/helper.php Outdated
Comment thread plugins/dominant-color-images/hooks.php Outdated
@nickchomey
nickchomey force-pushed the feature/css-gradient-lqip branch 2 times, most recently from bf90a65 to 893feee Compare July 4, 2026 14:25
@nickchomey

Copy link
Copy Markdown
Contributor Author

@westonruter i've addressed your comments now. Two comments remain unresolved - minified css and the inline style for hovering to reveal the lqip css gradient. I responded to those, happy to handle them however you want.

@nickchomey

Copy link
Copy Markdown
Contributor Author

hover css removed now

@nickchomey

Copy link
Copy Markdown
Contributor Author

oh weird, the min.css file i created got skipped by the repo-level gitignore

performance/.gitignore

Lines 29 to 38 in 8d70987

############
## Build
############
build
.wp-env.override.json
.wp-env.test.override.json
*.min.js
*.min.css
*.asset.php

Should I just remove the code that loads it? Evidently they arent used anywhere. That's probably something to address in another issue and PR

@westonruter westonruter added the [Type] Enhancement A suggestion for improvement of an existing feature label Jul 5, 2026
@westonruter

Copy link
Copy Markdown
Member

No, it's good that the minified file wasn't committed. We need to add CSS minification to the build process the same way we have for JS.

@westonruter

Copy link
Copy Markdown
Member

With 6636887 the CSS file should get automatically re-minified whenever the plugin is built (via npm run build:plugin:dominant-color-images).

Comment thread plugins/dominant-color-images/assets/lqip.css Outdated
Comment thread plugins/dominant-color-images/lqip-generator.php Outdated
Comment thread plugins/dominant-color-images/tests/test-dominant-color.php
Comment thread plugins/dominant-color-images/tests/test-dominant-color.php Outdated
* @return string|WP_Error Dominant hex color string, or an error on failure.
* @return array{r: int, g: int, b: int}|WP_Error RGB values (0-255), or WP_Error on failure.
*/
public function get_dominant_color() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feedback from Claude Opus 4.8:

Performance: full-resolution pixel scan when no medium subsize exists

Dominant_Color_Image_Editor_GD::get_dominant_color() now iterates every pixel of the source image in pure PHP (imagecolorat + sRGB→linear LUT + float accumulation), replacing the old resize-to-1×1 approach whose cost was independent of image dimensions. At the same time, dominant_color_get_attachment_file_path() no longer returns false when the requested medium size is missing — the if ( ! isset( $imagedata['sizes'][ $size ] ) ) { return false; } guard was dropped, so it now falls back to the full-resolution original.

In the normal upload flow this is fine: the medium subsize (~300px) is persisted before metadata generation runs, so the loop is ~67k iterations. But when medium is unavailable — e.g. medium_size_w/medium_size_h set to 0, or an original smaller than the medium threshold — a large original (a multi-megapixel -scaled image or bigger) gets scanned pixel-by-pixel in PHP during wp_generate_attachment_metadata. On a several-megapixel image that's multiple seconds of CPU and can exceed max_execution_time, blocking the upload/regeneration. The get_lqip_grid_values() path adds a further redundant has_transparency() pass.

Suggested fix: resize a working copy down to a small fixed size (as the original did) before the per-pixel averaging loop, so cost stays bounded regardless of source resolution.

Severity: conditional (only bites when medium is absent) — worth addressing but not CI-blocking. Not covered by the test suite, since fixtures are small.


The pixel-scan concern is GD-specific. Imagick is not affected.

The difference is in how each engine averages:

  • GD (get_dominant_color) does the averaging itself, in a pure-PHP nested for loop over every pixel (imagecolorat per pixel). Cost scales with width × height in the PHP layer → the timeout risk.
  • Imagick (get_dominant_color) offloads the averaging to ImageMagick's C core: it clones, then resizeImage( 1, 1, FILTER_LANCZOS, 1 ) and reads that single pixel. The averaging happens during the resize, so cost is essentially bounded regardless of source resolution — the same O(1)-from-PHP's-view approach the old GD code used before this PR.

The shared part of the change — dominant_color_get_attachment_file_path() falling back to the full-resolution original when medium is absent — applies to both engines, but it only turns into a problem for GD, because only GD then walks that full-resolution image pixel-by-pixel in PHP. For Imagick, feeding a larger image into a C-level resize-to-1×1 stays cheap (Imagick already holds the decoded image in memory regardless).

get_lqip_grid_values() is bounded for both (GD imagecopyresampled → 3×2, Imagick resizeImage → 3×2), so it doesn't add to the concern.

So the finding applies specifically to the GD editor; sites using Imagick won't hit it.

@nickchomey nickchomey Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder whether we should even be doing this looping over pixels at all... It was added to address #2535 because GD didnt seem to have such a mechanism as Imagick did.

It seems to me that:

  1. GD is uncommon
  2. As noted in the OP, the dominant color used generally doesnt have much effect on the final css gradient that's generated, which is why the 1x1 approach remained in use rather than something like ColorThief.
  3. This feature is so... minor... (just a flash of a gradient while the image downloads for the first time). If the gradient is slightly off from a non-perfect initial dominant color, people wont notice or even care.
  4. Sticking with 1x1 method wouldnt break any dominant color values that have already been extracted and stored in anyone's db.

Perhaps I should just revert the GD editor to 1x1 to avoid the complexity and overhead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh, now the fixes have broken tests because the generated color is different. All the more reason to just go back to basics.

@nickchomey
nickchomey force-pushed the feature/css-gradient-lqip branch from ea86570 to 02c937c Compare August 28, 2026 12:51
@nickchomey

Copy link
Copy Markdown
Contributor Author

fixing the unit test failure now

@nickchomey
nickchomey force-pushed the feature/css-gradient-lqip branch 2 times, most recently from 2295153 to f8e98c7 Compare August 28, 2026 21:22
- Wrap wp_image_editors filter registration in load.php with a callback
  that ensures the core WP_Image_Editor_* classes are loaded before
  delegating to dominant_color_set_image_editors(), avoiding a potential
  fatal error if the filter runs early (e.g. during wp_image_editor_supports()).
- Cast ImagickPixel::getColor() channel values to int in the Imagick editor
  to keep the int return contract stable under strict_types when ImageMagick
  returns floats.
- Use the n.e.x.t @SInCE placeholder for the newly added
  dominant_color_get_attachment_metadata() helper.
- Fix typo/grammar in inline comments in the GD and Imagick editor tests.
@nickchomey
nickchomey force-pushed the feature/css-gradient-lqip branch from f8e98c7 to eb05659 Compare August 29, 2026 16:15
…back compat and wasn't even necessary in the end
@nickchomey

Copy link
Copy Markdown
Contributor Author

This is broken while we resolve how to handle get_dominant_color() in #2527

@nickchomey
nickchomey force-pushed the feature/css-gradient-lqip branch from 669d7ac to 290972f Compare August 31, 2026 13:38
nickchomey and others added 16 commits August 31, 2026 11:37
…k compat while allowing for css lqip to get the rgb values for the dominant color rather than hex string
Register a webpack config for the dominant-color-images plugin that
generates assets/lqip.min.css via the shared cssMinifyTransformer, and
add the plugin to pluginsWithBuild so the distribution build depends on
it. This produces the lqip.min.css that hooks.php enqueues when
SCRIPT_DEBUG is off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review via westonruter

asked Claude Opus 5 to take a look:

> Both tools are wrong, and about the same thing: the two‑argument `round(down, A)` form where the rounding interval `B` is omitted.
> 
> ## What the spec says
> 
> css-values-4 §10.3:
> 
> > The `round(<rounding-strategy>?, A, B?)` function … If the type of A matches `<number>`, then B may be omitted, and defaults to 1. … In all other cases, omitting B is invalid.
> 
> `--lqip` is registered as `syntax: "<number>"` (`lqip.css:12-16`), so `A` is a `<number>` and omitting `B` is legal. The code is correct.
> 
> ## Why the error points at `mod()` instead of `round()`
> 
> Neither tool can establish a type for the `round()` result, so it falls through to `mod()`'s "both arguments must be the same type" check against the literal `4`/`8`. That's also why `lqip.css:27` — `mod(calc(var(--lqip) + 524288), 8)`, no `round()` — is clean in both tools while lines 19‑26 are not.
> 
> ## The two tools fail for different reasons
> 
> I probed both with variants:
> 
> | Expression                              | PhpStorm  | W3C       |
> | --------------------------------------- | --------- | --------- |
> | `round(down, 5)`                        | ok        | **error** |
> | `round(down, var(--lqip))`              | ok        | —         |
> | `round(down, calc(var(--lqip) + 1))`    | **error** | **error** |
> | `round(down, …, 1)` (explicit interval) | ok        | ok        |
> 
> - **PhpStorm** does implement the number default — it even reads your `@property` to type a bare `var(--lqip)`. It just loses the type when the argument is a `calc()` containing a `var()`. Dropping the redundant `calc()` wrapper (`round(down, (var(--lqip) + 524288) / 262144)`) doesn't help.
> - **W3C/Jigsaw** doesn't implement the omitted-`B` rule at all — even `round(down, 5)` reports "The types are incompatible."
> 
> ## Browsers disagree with both
> 
> In Chrome 152, with `--lqip: 192900`, the omitted-interval and explicit-interval forms both compute `--lqip-ca: 2`, and `CSS.supports('opacity', 'round(down, 0.5)')` is `true`.
> 
> ## If you want the warnings gone
> 
> Adding the explicit `, 1` to the eight `round()` calls is a semantic no-op that satisfies both tools. Verified on a copy: W3C goes from 9 errors to 1, PhpStorm to 0.
> 
> ```css
> --lqip-ca: mod(round(down, calc((var(--lqip) + 524288) / 262144), 1), 4);
> ```
> 
> The remaining W3C error is `Unrecognized at-rule "@Property"` at `lqip.css:12` — a separate Jigsaw gap (it predates css-properties-values-api), and nothing you can or should work around.
@nickchomey
nickchomey force-pushed the feature/css-gradient-lqip branch from 290972f to 6db1b1b Compare August 31, 2026 17:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Plugin] Image Placeholders Issues for the Image Placeholders plugin (formerly Dominant Color Images) [Type] Enhancement A suggestion for improvement of an existing feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CSS Gradient Image Placeholders (ie CSS-only Blur/Thumbhash)

2 participants