Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Big ticket about Image handling #2866

Open
Levdbas opened this issue Dec 15, 2023 · 4 comments
Open

Big ticket about Image handling #2866

Levdbas opened this issue Dec 15, 2023 · 4 comments

Comments

@Levdbas
Copy link
Member

Levdbas commented Dec 15, 2023

As discussed recently with the Timber core team, we have come to the conclusion that a lot of tickets/feature requests are about how we handle image in Timber. This ticket will serve as an archive for multiple tickets/pr's that are still based on the 1.x branch but might be helpful for development moving forward. Also some more recent issues and tickets.

Bottom line is that there are quite some issues with generation/deleting images and some requests regarding image manipulations via filters.

Image modification via filters

Images are not deleted

Image transformations error handling and edge cases

Image transformations feature requests

Image & Attachement classes

Sideloading

@nlemoine
Copy link
Member

Thank you @Levdbas! I'll post this issue I wrote some years ago and never posted. While reading it again, I feel that it's still relevant and can provide a starter base to discuss future Timber image handling.


It's been a long time that I wanted to share my thoughts on image manipulation.

Indeed, images handling has changed over the last years. Developers work sometimes very hard to win a few bytes on other assets (js, css, etc.) while images have an incredible reduction weight and performance gain potential.

The ideal Twig image manipulation API

Here's my insights on an image manipulation API.

  • manipulations are defined in templates, at the image level. Having image sizes defined in a some config file is tedious and becomes a nightmare at scale. The solution I'll come with will still be compatible with a global config approach.
  • basic resize features: width/height/crop/fit
  • filters: blur/greyscale/sepia/etc.
  • format conversion: jpg/png/gif/webp/avif
  • ability to output a base64 encoded image (LQIP)
  • automatic optimization: jpegtran, pngquant, etc.
  • easy srcset generation (e.g. not having to define each image size), this a almost mandatory nowadays and is really time consuming with current API. This can be done by setting a range and a step sizer (say [300, 1200] with step size of 100 will generate 300, 400, 500, 600, 700, ..., up to 1200 or defined sizes [300, 600, 900]
  • generated images are stored in a different directory than the source directory (uploads). I think it's important to clearly distinguish source images and manipulated images. Thus they can safely deleted and regenerated if something changed in the API.
  • fluent API, that can be used as array and/or method chaining, you should not have to choose (jpeg compression / quality #857)

So I came up building my own image manipulation library. It was built upon spatie/image which stands on thephpleague/glide which itself uses Intervention/image. Yes, I know that's a lot of dependencies but those are pretty solid ones and spatie/image very recently removed those dependencies (but requires PHP 8.2).

The twig filter can be used by chaining methods:

{{ img.file|width(300)|brightness(30)|blur(5)|to('webp') }}

or as an array:

{{ img.file|manipulate({
    width: 300,
    brightness: 30,
    blur: 5,
    format: 'webp'
}) }}

or both at the same time:

{{ img.file|manipulate({
    width: 300,
    brightness: 30,
    blur: 5,
})|to('webp') }}

You can generate an srcset by specifying a width range:

{{ img.file|manipulate({
    widths: {
        min: 300, 
        max: 1200,
        step: 100,
    }
}) }}
// or
{{ img.file|widths({min: 300, max: 1200}) }}

Or defined sizes:

{{ img.file|widths([300, 768, 1024]) }}

A "scaler" will generate an image every 100px (the step can be changed in the image factory instanciation or overriden at image level). So the output will look like:

<img srcset="image.jpg 300w, image.jpg 400w, image.jpg 500w, image.jpg 600w, image.jpg 700w, image.jpg 800w, image.jpg 900w, image.jpg 1000w, image.jpg 1200w" 
/>

You can optimize an image (if your server have the necessary binaries):

{{ img.file|width(300)|optimize }}

Optimization can also be set as true so every images will go through an optimizer unless explicitly set to false at manipulation level.

Here's what it looks like in practice:

{% set operations = {
    width: 600,
    height: 600,
    crop: 'crop-center',
    srcset: [300, 800],
} %}
{% set operations_lqip = {
    width: 30,
    height: 30,
    crop: 'crop-center',
    blur: 10
} %}
<picture>
    <source
        data-srcset="{{ img.file|manipulate(operations)|to('webp') }}"
        type="image/webp"
    >
    <img
        src="{{ img.file|manipulate(operations_lqip)|datauri }}"
        data-srcset="{{ img.file|manipulate(operations) }}"
        class="lazyload"
    />
</picture>

More abstraction can be done for multiple format generation, I'm currently using this:

{{ picture(
    img.file,
    {
    	 widths: {
    	     min: 300,
    	     max: 700,
    	 },
        width: 600,
        height: 600,
        crop: 'crop-center',
    },
    {
        alt: img.alt|default(post.title),
        class: 'mix-blend-multiply',
        width: 600,
        height: 600,
    },
    ['webp', 'avif]
) }}

Will ouput:

<picture>
    <source
		srcset="image.avif 300w, image.avif 400w, image.avif 500w, image.avif 600w, image.avif 700w, image.avif 800w"
		type="image/avif"
    >
    <source
		srcset="image.webp 300w, image.webp 400w, image.webp 500w, image.webp 600w, image.webp 700w, image.webp 800w"
		type="image/webp"
    >
    <img
		src="image.jpg"
		srcset="image.jpg 300w, image.jpg 400w, image.jpg 500w, image.jpg 600w, image.jpg 700w, image.jpg 800w"
		class="mix-blend-multiply"
		width="600"
		height="600"
    />
</picture>

Here is the signature:

{{ picture(
	realtive or absolute path to image,
    {
		manipulations
    },
    {
		attributes
    },
    [
    	formats
    ]
) }}

Bottom lines

Manipulations can be very greedy depending of many factors (number of images generated, number of manipulations applied, source file size, server capabilities, etc.), espacially if you generate a lot of sizes for your srcset attributes and offer multiple formats:

{% set operations = {
    width: 600,
    height: 600,
    crop: 'crop-center',
    widths: {
    	min: 300, 
    	max: 1100,
    	step: 100,
    },
} %}
<picture>
    <source
        data-srcset="{{ img.file|manipulate(operations)|to('webp') }}"
        type="image/webp"
    >
    <img
        data-srcset="{{ img.file|manipulate(operations) }}"
        class="lazyload"
    />
</picture>

With a step set to 100px, the example above will generate 10 images per format. Which leads to 20 images, for one displayed image 😱. As a workaround, I introduced a kind of throttle mecanism which only generate 3 images per manipulation group per load (configurable), starting with the bigger ones. So on the first load:

srcset="image.webp 900w, image.webp 1000w, image.webp 1100w"

On the second load:

srcset="image.webp 600w, image.webp 700w, image.webp 800w, image.webp 900w, image.webp 1000w, image.webp 1100w"

On the third load:

srcset="image.webp 300w, image.webp 400w, image.webp 500w, image.webp 600w, image.webp 700w, image.webp 800w, image.webp 900w, image.webp 1000w, image.webp 1100w"

The downside is that if you make use of static cache, the first loaded version will be cached and the set of images will only be generated next time the cache expires.

@Levdbas
Copy link
Member Author

Levdbas commented Dec 18, 2023

Hi @nlemoine ,

You went all in with this! Love the ideas!

Manipulations are defined in templates, at the image level. Having image sizes defined in a some config file is tedious and becomes a nightmare at scale. The solution I'll come with will still be compatible with a global config approach.

Totally agree

basic resize features: width/height/crop/fit
filters: blur/greyscale/sepia/etc.
format conversion: jpg/png/gif/webp/avif
ability to output a base64 encoded image (LQIP)
automatic optimization: jpegtran, pngquant, etc.

Yes please!

easy srcset generation (e.g. not having to define each image size), this a almost mandatory nowadays and is really time consuming with current API. This can be done by setting a range and a step sizer (say [300, 1200] with step size of 100 will generate 300, 400, 500, 600, 700, ..., up to 1200 or defined sizes [300, 600, 900]

For me, this would be the biggest win personally.

generated images are stored in a different directory than the source directory (uploads). I think it's important to clearly distinguish source images and manipulated images. Thus they can safely deleted and regenerated if something changed in the API.

I totally agree with you on this one if it would be easy to load that image fluently from that directory.

fluent API, that can be used as array and/or method chaining, you should not have to choose (#857)

Mostly agree with you here, but maybe there should be a specific order in which manipulations are processed. With the chaining method, not sure this could case issues? In the end we want to make it as easy as possible for the end user. Not sure if two methods that do the same are the way to go there?

With a step set to 100px, the example above will generate 10 images per format. Which leads to 20 images, for one displayed image 😱. As a workaround, I introduced a kind of throttle mecanism which only generate 3 images per manipulation group per load (configurable), starting with the bigger ones. So on the first load:

This is simply amazing!

The downside is that if you make use of static cache, the first loaded version will be cached and the set of images will only be generated next time the cache expires.

We should just provide an action here were you can insert your own caching clearing method of choice.

@gchtr
Copy link
Member

gchtr commented Jan 7, 2024

The proposal from @nlemoine is very much on point and I think just what we need in Timber.

Five years ago, I also tried to summarize some of the issues that came up for image manipulation:

I think the solution proposed by @nlemoine could solve a lot of problems developers ran into. I don’t think we have to go through all the issues again and see if we can solve them. I just want to list them here to give an overview and to make the link to the discussions from 2018.

Changing quality

We should be able to change the quality of images, either for all images or per instance.

Upscaling and Retina

We should be able to control if an image should be upscaled or not.

Better control of resizing process

Feature parity with WordPress

  • Extend image manipulation #629: This issue calls for flip and rotation functionality, which are actually missing pieces that would make the Timber image manipulation functionality match the requirements of a WP_Image_Editor class.

@Levdbas
Copy link
Member Author

Levdbas commented Feb 27, 2024

We definitely need to discern the file types in the file names. Thanks for taking this on @expedition-robin-martijn!

As with all the PR involving file name changes for images, I wonder how we should handle this properly. Because this will cause a lot of files to be regenerated. It could cause lots of max execution errors and could quickly fill up space on some hosts and leave the uploads folder with lots of unused files.

Could we somehow add a migration path so that developers have a way to fix this on their sites?

  • Provide a way to delete all deprecated (or even all) WebP files using the WP CLI.
  • But then again, not all hosts allow you to run WP CLI. So maybe we could add integrations for popular image regeneration plugins that could handle the migration as well.
  • Maybe save generated WebP files to a different folder, as envisioned in Big ticket about Image handling #2866

I wonder whether we even should put this change behind a filter hook. The filter could be applied for all new Timber installations. For existing installations, we could maybe work with a flag that is saved in the wp_options database and is deleted once the change is through. Depending on the flag, we could show a notification in the admin dashboard.

I’m just thinking out loud here and am happy about better ideas.

Originally posted by @gchtr in #2876 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants