Coin web de Frédéric Péters

fpeters@0d.be

Miniatures modernes (sorl-thumbnail & nginx & webp)

21 avril 2022, 09:02

Via Planet Debian je lisais un billet sur la prise en charge du format AV1, qui est un nouveau format pour les vidéos, ça semble plutôt bien géré mais surtout ça m’a rappelé que ça existait aussi pour les images (avec l’extension .avif), que ça offrirait meilleure compression/etc. que les autres formats. C’est encore un peu jeune et pas intégré dans tous les navigateurs, et avant d’y plonger peut-être qu’avancer sur le précédent « nouveau format » pouvait déjà être intéressant.

Ce précédent format c’est WebP. À lire la page Wikipédia on voit que ça existe depuis 2010, les avantages sont les mêmes, meilleure compression que les historiques JPEG et PNG, ce qui se traduit en fichiers plus petits, transférés plus rapidement, etc. Pour ce format ça fait quelques années que c’est pris en charge correctement par la plupart des navigateurs.

Sur le site de Radio Panik (ou Studio Néau, ou radio Esperanzah!) (qui a récemment pris les couleurs de la nouvelle édition), une image peut être associée aux émissions et épisodes et celle-ci se trouve déclinée en diverses tailles, par exemple toute petite miniature pour l’horaire de la journée et image plus grande quand c’est en focus. Pour le calcul de ces formats c’est le module sorl-thumbnail qui est utilisé, module Django assez classique, il permet d’écrire :

{% thumbnail emission.image "450x450" crop="50% 50%" as im %}
<img alt="" src="{{im.url}}">
{% endthumbnail %}

et ça affichera une image carrée, cadrée sur le centre de l’image initiale, de taille 450×450. (on note l’attribut alt vide parce que c’est juste une image d’illustration, le nom de l’émission apparait pus loin).

Ce module sorl-thumbnail peut créer des images au format WebP, ça a été ajouté via leur ticket numéro 267, Optional WebP images for browsers that support it,  dedans on y apprend qu’on peut désormais écrire:

<picture>
  {% thumbnail model.image "200x200" format="WEBP" as im %}
    <source srcset="{{ im.url }}" type="image/webp">
  {% endthumbnail %}
  {% thumbnail model.image "200x200" format="JPEG" as im %}
    <source srcset="{{ im.url }}" type="image/jpeg">
    <img src="{{ im.url }}" alt="">
  {% endthumbnail %}
</picture>

Ça passe par l’utilisation de l’élément <picture>, plutôt que <img>, c’est une balise dans laquelle on peut préciser différentes images et le navigateur prendra la plus adaptée. (ici avec le <img> final utilisé en secours si jamais ça n’est pas géré par le navigateur).

Je ne voulais pas vraiment aller modifier partout, code et style, il fallait une autre idée et une pratique assez répandue est de laisser la négociation du format au serveur : le navigateur demandera telle image et se faisant dira aussi qu’il gère le webp, ce sera détecté et s’il demandait /image.jpeg en réponse on lui donnera le contenu de /image.webp et il sera content. En soit c’est un peu moche d’avoir des fichiers dont l’extension ne correspond au final pas au format réel mais ça ne bloque rien.

Avec le serveur web nginx ça se fait en deux temps, le premier c’est la détection du fait que le navigateur gère le format, l’exemple qui revient souvent c’est :

map $http_accept $webp_suffix {
   default "";
   "~*webp" ".webp";
}

Ça dit que s’il y a "webp" mentionné dans l’entête HTTP-Accept alors dans la variable $webp_suffix il y aura ".webp" et sinon rien.

Le deuxième temps est au moment de servir les fichiers, j’avais une configuration la plus simple qui disait d’aller prendre les fichiers dans un répertoire donné,

location /media/ { alias /srv/radio.esperanzah.be/media/; }

Il faut remplacer le simple alias par une recherche sur plusieurs fichiers, ça peut donner :

location ~ ^/media/(.+)$ {
    root /;
    set $orig_uri $1;
    set $webp_uri $orig_uri$webp_suffix;
    try_files
      /srv/radio.esperanzah.be/media/$webp_uri
      /srv/radio.esperanzah.be/media/$orig_uri
      =404;
}

Ici pour toutes les adresses /media/, on prend le bout qui suit /media/,  (ex: /media/image.jpeg → image.jpeg) et on met ça dans une variable nommée $orig_uri, puis dans une variable $webp_uri on met ça + le contenu de la variable $webp_suffix créée plus haut, ça fait qu’on aura, pour un navigateur qui ne gère pas le format, $webp_uri avec la même valeur que $orig_uri, mais pour un navigateur qui gère le format, on ira d’un côté image.jpeg et de l’autre image.jpeg.webp.

Dessous, l’instruction try_files, pour dire d’aller essayer l’une puis l’autre et si jamais ça ne donne rien de retourner une erreur 404.

Ici j’ai perdu du temps à ne pas passer par des variables intermédiaires, à directement écrire

try_files
  /srv/radio.esperanzah.be/media/$1$webp_suffix
  /srv/radio.esperanzah.be/media/$1
  =404;

ça ne marche pas, j’ai essayé d’un peu creuser les raisons, pas trouvé. (j’obtenais systématiquement une erreur 404).

Une fois qu’on a tout ça on a l’infrastructure pour les servir mais pas encore les images.

Le module sorl-thumbnail est prévu pour s’étendre facilement, tellement facilement même qu’il y aurait plusieurs approches possibles ici. Comme de bien entendu j’en prends une pas pile officielle, à voir dans le temps long si je le regrette. Le module est déjà prévu pour générer des variations des vignettes, pour en sortir des versions « HD », c’est une option officielle (THUMBNAIL_ALTERNATIVE_RESOLUTIONS) mais elle est traitée dans du code considéré interne, pas prévu pour être étendu (ça se voit à l’underscore en premier caractère), je fais donc fi.

Résultat totalement basique, très proche de ce qui existait déjà, j’ajoute juste options['format'] = 'WEBP' pour forcer le format, ça donne :

 

import sorl.thumbnail.base
from sorl.thumbnail import default
from sorl.thumbnail.images import ImageFile
from sorl.thumbnail.parsers import parse_geometry

class ThumbnailBackend(sorl.thumbnail.base.ThumbnailBackend):
    def _create_alternative_resolutions(
        self, source_image, geometry_string, options, name
    ):
        super()._create_alternative_resolutions(
            source_image, geometry_string, options, name
        )
        # create .webp alternative, to be served to appropriate browsers
        ratio = default.engine.get_image_ratio(source_image, options)
        geometry = parse_geometry(geometry_string, ratio)
        options = options.copy()
        options['format'] = 'WEBP'
        image = default.engine.create(source_image, geometry, options)
        thumbnail_name = '%(file_name)s.webp' % {'file_name': name}
        thumbnail = ImageFile(thumbnail_name, default.storage)
        default.engine.write(image, options, thumbnail)
        size = default.engine.get_image_size(image)
        thumbnail.set_size(size)

Il y a alors juste à déclarer qu’il faut utiliser ça,

THUMBNAIL_BACKEND = 'panikweb.utils.ThumbnailBackend'

et voilà, quand une miniature va être générée, il y a aura une version au format WebP posée à côté.

C’est ok pour les images à venir mais les images déjà présentes ? J’ai eu deux méthodes, la première c’était de  supprimer toutes les vignettes et attendre qu’elles soient regénérées au fil des visites sur les pages, j’ai fait ça pour radio Esperanzah!, il n’y a pas tant de pages et je suis allé visiter celles de l’édition 2021 pour au moins générer celles-là. La seconde pour le site de radio Panik ça a été une conversion en masse, en partant des fichiers déjà présents,

for F in $(find …/media/cache/ -type f -name '*.jpg')
do
  echo $F
  gm convert -quality 95 $F $F.webp
done

(passer sur tous les fichiers .jpg et utiliser l’outil gm (GraphicsMagick) pour leur créer une versons au format WebP). (95% c’est la qualité employée par défaut dans sorl-thumbnail)

C’est moins bien parce que ça part de ce qui était déjà une vignette compressée, plutôt que partir de l’image d’origine, mais personne ne verra la différence.

Il y avait un peu plus de 45000 fichiers, pour un total de 2,18 Go, une petite demi-heure plus tard il y avait autant de fichiers WebP, pour un total de 1,82 Go, soit 83%. Ce n’est pas un gain aussi massif que celui repris sur la page Wikipédia (« procurerait de 30 % à 80 % de réduction d'espace ») mais c’est quand même un gain.

Le code est dans le dépôt (lien vers le commit) mais vraiment tout est déjà écrit ici.

Capture d’écran des requêtes réseau où on voit un fichier .jpg servi au format WebP