Switch image references to use picture elements and AVIF encoded files

Up to now, image references, eg. [::ImageAlt](img:​:5f39dd4e) have functioned like so:

This has worked well, but I’ve been wanting to add AVIF support in order to take advantage of its better compression performance. AVIF is not universally supported, so a fallback (eg. JPEG) is necessary. The HTML <picture> element is well suited to this. The Djot image syntax has been bypassed, image references are now directly replaced with a final HTML snippet like so,

replacement = f"""`​`​`=html
<picture>
<source srcset="{Path(ref_slug).with_suffix(".avif")}" type="image/avif" />
<img alt="{ref_text}" src="{ref_slug}">
</picture>
`​`​`"""

The actual AVIF creation support was added using pillow_avif-plugin, a plugin for the Pillow library I was already using for compressing JPEGs. Most of the relevant code is in the process_image_parallel function,

def process_image_parallel(input_data: Tuple[Path, Path, int, str]) -> None:
    workaround_import = pillow_avif.AvifImagePlugin
    input_image, output_path, output_width, uid = input_data
    lock_path = output_path.with_suffix(".lock")
    lock = FileLock(str(lock_path))

    # Define AVIF output path
    avif_output_path = output_path.with_suffix(".avif")

    # Check if AVIF support is available
    avif_available = "AVIF" in Image.SAVE

    try:
        with lock:
            if output_path.exists() and avif_output_path.exists():
                return

            os.makedirs(output_path.parent, exist_ok=True)

            with Image.open(input_image) as im:
                original_format = im.format
                im = ImageOps.exif_transpose(im)
                output_height = int(im.size[1] * (output_width / im.size[0]))

                with im.resize(
                    (output_width, output_height), Image.Resampling.LANCZOS
                ) as output_image:
                    # Save JPEG version
                    if (
                        original_format != "JPEG"
                        and str(output_path).endswith("jpg")
                        and output_image.mode in ("RGBA", "P")
                    ):
                        output_image = output_image.convert("RGB")

                    output_image.save(output_path, quality=85, optimize=True)

                    # Save AVIF version only if support is available
                    if avif_available:
                        try:
                            if output_image.mode in ("RGBA", "P"):
                                avif_image = output_image.convert("RGB")
                            else:
                                avif_image = output_image.copy()

                            avif_image.save(
                                avif_output_path,
                                format="AVIF",
                                quality=60,  # Lower quality for better compression, still maintains good visual quality
                                speed=5,  # Slowest speed = best compression (0 is slowest, 10 is fastest)
                                bits=10,  # Use 10-bit color depth for better quality-to-size ratio
                                compress_level=8,  # Highest compression level (range 0-8)
                                color_space="bt709",  # Use YUV BT.709 color space
                                chroma=0,  # 4:4:4 chroma sampling (0=4:4:4, 1=4:2:0, 2=4:2:2)
                                num_threads=0,  # Use all available CPU threads for encoding
                            )
                            logger.debug(
                                f"Processed image: {input_image} -> {output_path} and {avif_output_path}"
                            )
                        except Exception as e:
                            logger.error(
                                f"Error saving AVIF version of {input_image}: {e}"
                            )
                    else:
                        logger.error(
                            "AVIF support not available. Skipping AVIF conversion."
                        )
                        logger.debug(f"Processed image: {input_image} -> {output_path}")

    except OSError as e:
        logger.error(f"OS error processing {input_image}: {e}")
    except Exception as e:
        logger.error(f"Error processing {input_image}: {e}")
    finally:
        if lock_path.exists():
            try:
                lock_path.unlink()
            except OSError:
                pass

It was also necessary to update my gallery/lightbox JavaScript code, as the previous version was loading the JPEG fallback when the lightbox opened, wasting bandwidth.

class Lightbox {
  constructor(galleryElement) {
    this.galleryElement = galleryElement;
    this.galleryItems = this.galleryElement.querySelectorAll('img, picture');
    this.currentIndex = 0;
    this.overlay = null;
    this.content = null;
    this.image = null;
    this.caption = null;
    this.closeButton = null;
    this.prevButton = null;
    this.nextButton = null;
    this.isOpen = false;
    this.init();
    this.imageCount = galleryElement.querySelectorAll('img').length;
  }

  init() {
    this.createLightbox();
    this.addEventListeners();
  }

  createLightbox() {
    this.overlay = document.createElement('div');
    this.overlay.className = 'lightbox-overlay';
    this.overlay.innerHTML = `
      <div class="lightbox-content">
        <img src="" alt="" class="lightbox-image">
        <p class="lightbox-caption"></p>
        <button class="lightbox-prev">&lt;</button>
        <button class="lightbox-next">&gt;</button>
      </div>
      <button class="lightbox-close">&times;</button>
    `;
    document.body.appendChild(this.overlay);

    this.content = this.overlay.querySelector('.lightbox-content');
    this.image = this.overlay.querySelector('.lightbox-image');
    this.caption = this.overlay.querySelector('.lightbox-caption');
    this.closeButton = this.overlay.querySelector('.lightbox-close');
    this.prevButton = this.overlay.querySelector('.lightbox-prev');
    this.nextButton = this.overlay.querySelector('.lightbox-next');
  }

  getImageDetails(element) {
    if (element.tagName.toLowerCase() === 'picture') {
      const img = element.querySelector('img');
      return {
        // currentSrc gives us the source that the browser actually loaded
        src: img.currentSrc || img.src,
        alt: img.alt
      };
    } else {
      return {
        src: element.currentSrc || element.src,
        alt: element.alt
      };
    }
  }

  addEventListeners() {
    this.galleryItems.forEach((item, index) => {
      // For picture elements, we need to attach the listener to the img inside
      const clickTarget = item.tagName.toLowerCase() === 'picture'
        ? item.querySelector('img')
        : item;

      clickTarget.addEventListener('click', (e) => {
        e.preventDefault();
        this.openLightbox(index);
      });
    });

    this.overlay.addEventListener('click', (e) => {
      if (!this.content.contains(e.target) || e.target === this.content) {
        this.closeLightbox();
      }
    });

    this.closeButton.addEventListener('click', () => this.closeLightbox());

    document.addEventListener('keydown', (e) => {
      if (this.isOpen) {
        if (e.key === 'Escape') this.closeLightbox();
        if (e.key === 'ArrowLeft') this.showPrevious();
        if (e.key === 'ArrowRight') this.showNext();
      }
    });

    window.addEventListener('popstate', () => {
      if (this.isOpen) {
        this.closeLightbox(false);
      }
    });

    this.prevButton.addEventListener('click', () => this.showPrevious());
    this.nextButton.addEventListener('click', () => this.showNext());
  }

  openLightbox(index) {
    this.currentIndex = index;
    this.updateLightboxContent();
    this.overlay.style.display = 'block';
    document.body.style.overflow = 'hidden';
    this.isOpen = true;
    history.pushState({ lightboxOpen: true }, '');
  }

  closeLightbox(pushState = true) {
    this.overlay.style.display = 'none';
    document.body.style.overflow = '';
    this.isOpen = false;
    if (pushState) {
      history.pushState({ lightboxOpen: false }, '');
    }
  }

updateLightboxContent() {
  const currentItem = this.galleryItems[this.currentIndex];
  const { src, alt } = this.getImageDetails(currentItem);

  this.image.src = src;
  this.image.alt = alt;
  this.caption.textContent = alt;

  // Use imageCount instead of galleryItems.length
  if (this.imageCount <= 1) {
    this.prevButton.style.display = 'none';
    this.nextButton.style.display = 'none';
  } else {
    this.prevButton.style.display = 'block';
    this.nextButton.style.display = 'block';
  }
}

  showPrevious() {
    this.currentIndex = (this.currentIndex - 1 + this.galleryItems.length) % this.galleryItems.length;
    this.updateLightboxContent();
  }

  showNext() {
    this.currentIndex = (this.currentIndex + 1) % this.galleryItems.length;
    this.updateLightboxContent();
  }
}

// Initialize the lightbox for each gallery
document.addEventListener('DOMContentLoaded', () => {
  const galleries = document.querySelectorAll('.gallery');
  galleries.forEach(gallery => new Lightbox(gallery));
});

The result: higher image quality (raised image default width from 1400px to 1600px) and half the file-size, eg. my nonsense page has been cut from 7.8MB transferred to load, to 3.45MB. Or, to compare only the actual images, 6.8MB to 2.94MB, a 57% saving. At higher quality! Madness.

Coupled with the previous change β€” Convert heavy PNGs to JPEG where appropriate β€” that page has been cut from 15.9 to 3.45MB. Happy with that.

Small problem with the new image reference solution: classes specified in Djot are no longer passed through as the Djot rendering is bypassed. Most images on the site are wrapped in a β€˜gallery’ div, so I can still class that, but it does create a small annoyance for bare images.