Switch image references to use picture elements and AVIF encoded files
Up to now, image references, eg. [::ImageAlt](img:β:5f39dd4e)
have functioned like so:
- The source section (between parentheses) is substituted directly from the asset manifest, in this case referenced by the first 8-chars of its UUIDv4.
- If the Alt-text [between square brackets] begins with β::β, it is also replaced by the description from the corresponding asset manifest, else it is left as is.
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"><</button>
<button class="lightbox-next">></button>
</div>
<button class="lightbox-close">×</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.