Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ This writes `config/sproutset.php`, read throughout via `config('sproutset.*')`.

## Configuration

`config/sproutset.php` has three blocks.
`config/sproutset.php` has four blocks.

### Image sizes

Expand Down Expand Up @@ -69,6 +69,14 @@ The package ships `thumbnail`, `medium`, `medium_large`, and `large`. The four W
'focal_point' => true,
```

### Defer generation

`defer_generation` (default `false`) defers the `@Nx` srcset variants — the widest, most expensive resizes — to on-demand generation at render instead of eagerly at upload. Every base size stays eager, so each size always has a real file for admin, the editor and WooCommerce, and the front end never falls back to the full-size original. Enable it to avoid `max_execution_time` timeouts on large sources or batch imports; the deferred variants are generated on first render through `<x-sproutset-image>`. Disabled is a no-op.

```php
'defer_generation' => false,
```

## Usage

Render an attachment with the `<x-sproutset-image>` Blade component:
Expand Down
13 changes: 13 additions & 0 deletions config/sproutset.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@
],
],

/*
|--------------------------------------------------------------------------
| Defer Upload-Time Generation
|--------------------------------------------------------------------------
|
| Defer the @Nx srcset variants to on-demand generation at render instead
| of eagerly at upload. Base sizes stay eager. Avoids max_execution_time
| timeouts on large sources and batch imports. Disabled is a no-op.
|
*/

'defer_generation' => false,

/*
|--------------------------------------------------------------------------
| AVIF Conversion
Expand Down
140 changes: 140 additions & 0 deletions specs/defer-generation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Deferred Upload-Time Size Generation

An opt-in mode that stops WordPress from eagerly generating Sproutset's `@Nx` srcset variants
at upload, letting the existing on-demand path produce them lazily at render instead. It
exists because eager generation of the whole roster (30+ Imagick passes off one large source,
the widest of them the `@Nx` retina variants) can exhaust PHP's `max_execution_time` — most
visibly on batch imports that loop `media_handle_sideload()`. Backward-compatible: default
off, current behavior unchanged until a project opts in.

Every configured size's **base** entry stays eager, so a size always has at least its base
file after upload; only the `@Nx` variants are deferred. That keeps the front end from ever
falling back to the full-size original, and keeps the deferred work off the widest, most
expensive resizes.

## Behavior

The top-level config key `defer_generation` (bool, default `false`) gates the feature.

- **`false` (default):** no behavior change. Every registered size is generated eagerly at
upload exactly as before. The feature is a guaranteed no-op when off — no filter is
registered.

- **`true`:** Sproutset registers an `intermediate_image_sizes_advanced` filter that removes
its **deferrable** sizes from the set WordPress is about to generate during
`wp_generate_attachment_metadata()`. Removed sizes are *not* unregistered — they remain in
`wp_get_registered_image_subsizes()`, so the on-demand path still finds and generates them
at render time when `<x-sproutset-image>` requests the size.

### The deferrable set

The library decides what is safe to defer; the consumer does not choose per size.

- **Kept eager:** every configured size's **base** entry — the four WordPress core sizes and
every custom size (e.g. `hero`, `product`), but only the base, never the `@Nx` variants.
Base sizes are cheap relative to the retina variants and must exist after upload so that WP
core render paths (admin media library, block/classic editor, WooCommerce emails) and a
cap-exhausted front end always have a real intermediate size to fall back to rather than the
full-size original.

- **Deferred:** every `@Nx` srcset variant (`large@2x`, `hero@1.5x`, `product@2x`, …). These
are the widest, most expensive resizes and are only ever consumed through the component's
srcset, which the on-demand path rebuilds at render.

- **Never touched:** sizes Sproutset did not register (WooCommerce `woocommerce_*`, Gravity
Forms `gform-*`, any other plugin/theme size). The filter only removes keys matching
Sproutset's own normalized roster; all other keys pass through untouched.

If the roster has no `@Nx` variants, enabling the feature registers no filter — there is
nothing to defer, so it is a no-op.

### Render-time family generation

When `<x-sproutset-image>` resolves a raster image, the on-demand path ensures the requested
size's whole **family** — its base entry and every registered `@Nx` variant — before building
the srcset, so a deferred variant is generated the first time the size is rendered and the
front-end srcset stays complete (retina candidates included). The base is ensured first, so if
the per-request generation cap is reached the base is the size most likely to already exist.

### Boot safety

Registering the filter is front-end safe; its callback only runs inside
`wp_generate_attachment_metadata()` (upload / regenerate / CLI). The callback does pure array
manipulation — no admin-only WP calls, no I/O — and never fatals a request.

### Known tradeoffs

- `OnDemandSizeGenerator` caps at 10 generations per request. On a cold, image-dense page
(e.g. a large product grid) the `@Nx` variants beyond the cap are not generated on that
request; because every base size is eager, those images still render at their base size
(srcset simply lacks the not-yet-generated retina candidates) and the missing variants are
produced on subsequent requests (self-healing). The front end never falls back to the
full-size original.
- AVIF is already fully on-demand and is unaffected.

## Scenarios

```gherkin
Scenario: only @Nx variants are deferrable
Given a roster with core sizes, custom sizes and @Nx srcset variants
When the deferrable set is computed
Then every @Nx variant is listed
And no base size (core or custom) is listed

Scenario: applying the filter removes only @Nx variants
Given a size set containing base sizes, @Nx variants and foreign (plugin) sizes
When the deferrable removal is applied
Then every @Nx variant is removed
And every base size (core and custom) remains
And foreign sizes (woocommerce_*, gform-*) remain

Scenario: a roster without variants removes nothing
Given a roster whose sizes declare no srcset multipliers
When the deferrable removal is applied to a size set
Then the size set is returned unchanged

Scenario: enabled registers the filter and defers the variants
Given defer_generation is true
When register runs and wp_generate_attachment_metadata applies the filter
Then the @Nx variants are removed from the eager set
And base sizes and foreign sizes remain

Scenario: disabled registers no filter
Given defer_generation is false
When register runs
Then Sproutset adds no intermediate_image_sizes_advanced filter

Scenario: enabled with no variants registers no filter
Given defer_generation is true
And the roster declares no srcset multipliers
When register runs
Then Sproutset adds no intermediate_image_sizes_advanced filter

Scenario: deferred variants stay registered for on-demand
Given defer_generation is true and the roster is registered
When an @Nx variant is deferred from eager generation
Then it is still present in wp_get_registered_image_subsizes()

Scenario: render generates the whole family on-demand
Given a size whose @Nx variants have not been generated
When the component resolves that size
Then the base size and every registered @Nx variant are generated
And the base size is generated first
```

## Acceptance criteria

Each scenario maps 1:1 to a test. Pure classification/removal logic lives in the fast **Unit**
lane (no WordPress); filter registration, firing, family generation and WP interaction live in
the **Integration** lane (real WordPress), mirroring `SrcsetWidthLimit`.

| Scenario | Test |
| --- | --- |
| `only @Nx variants are deferrable` | `tests/Unit/EagerGenerationDeferralTest.php` → `it('lists only @Nx variants as deferrable, never base sizes')` |
| `applying the filter removes only @Nx variants` | `tests/Unit/EagerGenerationDeferralTest.php` → `it('removes only @Nx variants, keeping base and foreign sizes')` |
| `a roster without variants removes nothing` | `tests/Unit/EagerGenerationDeferralTest.php` → `it('returns the size set unchanged for a roster without variants')` |
| `enabled registers the filter and defers the variants` | `tests/Integration/EagerGenerationDeferralTest.php` → `test_enabled_defers_variant_sizes_from_eager_generation` |
| `disabled registers no filter` | `tests/Integration/EagerGenerationDeferralTest.php` → `test_disabled_registers_no_filter` |
| `enabled with no variants registers no filter` | `tests/Integration/EagerGenerationDeferralTest.php` → `test_enabled_registers_no_filter_when_roster_has_no_variants` |
| `deferred variants stay registered for on-demand` | `tests/Integration/EagerGenerationDeferralTest.php` → `test_deferred_variants_remain_registered_for_on_demand` |
| `render generates the whole family on-demand` | `tests/Integration/OnDemandSizeGeneratorTest.php` → `test_ensure_family_generates_base_and_variants` |
68 changes: 68 additions & 0 deletions src/Images/EagerGenerationDeferral.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace Webkinder\Sproutset\Images;

final readonly class EagerGenerationDeferral
{
public function __construct(private ImageSizeConfigNormalizer $normalizer) {}

/**
* @param array<array-key, mixed> $rawConfig
* @return list<string>
*/
public function deferrableSizes(array $rawConfig): array
{
$registered = array_keys($this->normalizer->normalize($rawConfig));

return array_values(array_diff($registered, $this->baseSizeNames($rawConfig)));
}

/**
* @param array<array-key, mixed> $sizes
* @param array<array-key, mixed> $rawConfig
* @return array<array-key, mixed>
*/
public function apply(array $sizes, array $rawConfig): array
{
foreach ($this->deferrableSizes($rawConfig) as $sizeName) {
unset($sizes[$sizeName]);
}

return $sizes;
}

/**
* @param array<array-key, mixed> $rawConfig
*/
public function register(bool $enabled, array $rawConfig): void
{
if (! $enabled) {
return;
}

if ($this->deferrableSizes($rawConfig) === []) {
return;
}

add_filter('intermediate_image_sizes_advanced', fn (mixed $sizes): mixed => is_array($sizes) ? $this->apply($sizes, $rawConfig) : $sizes);
}

/**
* @param array<array-key, mixed> $rawConfig
* @return list<string>
*/
private function baseSizeNames(array $rawConfig): array
{
$baseNames = [];

foreach ($rawConfig as $sizeName => $sizeConfig) {
if (is_string($sizeName) && is_array($sizeConfig)) {
$baseNames[] = $sizeName;
}
}

return $baseNames;
}
}
13 changes: 13 additions & 0 deletions src/Images/OnDemandSizeGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ final class OnDemandSizeGenerator

private int $generations = 0;

public function ensureFamily(int $attachmentId, string $sizeName): void
{
$this->ensure($attachmentId, $sizeName);

$prefix = $sizeName.'@';

foreach (array_keys(wp_get_registered_image_subsizes()) as $registeredName) {
if (str_starts_with($registeredName, $prefix)) {
$this->ensure($attachmentId, $registeredName);
}
}
}

public function ensure(int $attachmentId, string $sizeName): void
{
if ($this->generations >= self::MAX_GENERATIONS_PER_REQUEST) {
Expand Down
2 changes: 1 addition & 1 deletion src/Images/WpImageResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ private function resolveRaster(ImageRequest $request): ?ResolvedImage
return null;
}

$this->sizeGenerator->ensure($attachment->id, $request->sizeName);
$this->sizeGenerator->ensureFamily($attachment->id, $request->sizeName);

if ($this->focalConfig->enabled) {
$metaFocal = FocalPointMeta::read($attachment->id);
Expand Down
5 changes: 5 additions & 0 deletions src/SproutsetServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Webkinder\Sproutset\Images\Avif\AvifVariantGenerator;
use Webkinder\Sproutset\Images\Avif\WpAvifSupport;
use Webkinder\Sproutset\Images\CoreImageSizeOptions;
use Webkinder\Sproutset\Images\EagerGenerationDeferral;
use Webkinder\Sproutset\Images\FocalPointConfig;
use Webkinder\Sproutset\Images\FocalPointCropper;
use Webkinder\Sproutset\Images\FocalPointMeta;
Expand Down Expand Up @@ -64,6 +65,10 @@ public function packageBooted(): void
$this->app->make(CoreImageSizeOptions::class)->register($this->imageSizesConfig());
$this->app->make(MediaSettingsLock::class)->register($this->imageSizesConfig());
$this->app->make(SrcsetWidthLimit::class)->register($this->imageSizesConfig());
$this->app->make(EagerGenerationDeferral::class)->register(
(bool) config('sproutset.defer_generation', false),
$this->imageSizesConfig(),
);

add_action('delete_attachment', function (int $attachmentId): void {
$this->app->make(AvifCleanup::class)->forget($attachmentId);
Expand Down
Loading
Loading