Repository: wagtail/wagtail

Issue:
Create a tag for the picture element + support for responsive image sets
The picture element which allows different sizes of image for different screen resolutions is becoming the widely accepted standard for responsive images, especially as polyfills are now available for it.

Given that wagtail allows resizing of images on the fly by the server it would be nice to see it supporting the picture element by providing a tag for it.


Discussion:
squash-labs[bot]: <h2>Manage this branch in <a href="https://app.squash.io/accounts/">Squash</a></h2>Test this branch here: <a href='https://paarthagarwalreponsive-images-zvyux.squash.io' target='_blank'>https://paarthagarwalreponsive-images-zvyux.squash.io</a><br/>
PaarthAgarwal: @thibaudcolas I've updated the PR based on your suggestions
PaarthAgarwal: > Thanks for continuing your work on this! Is there an example project or a bakerydemo branch where we can test this?

Haven't created any specific version for it, been trying to test with the existing bakerydemo with tags
lb-: Just a drive by, this is very very cool. Thanks Paarth and Thibaud for your work on this. 
thibaudcolas: Thank you @laymonage! I have created https://github.com/wagtail/wagtail/issues/11068 to track your suggestions here.

Patch:
diff --git a/docs/advanced_topics/images/renditions.md b/docs/advanced_topics/images/renditions.md
index 2bd110d3b0..b660fbc222 100644
--- a/docs/advanced_topics/images/renditions.md
+++ b/docs/advanced_topics/images/renditions.md
@@ -38,13 +38,13 @@ See also: [](image_tag)
 
 ## Generating multiple renditions for an image
 
-You can generate multiple renditions of the same image from Python using the native `get_renditions()` method. It will accept any number of 'specification' strings, and will generate a set of matching renditions much more efficiently than generating each one individually. For example:
+You can generate multiple renditions of the same image from Python using the native `get_renditions()` method. It will accept any number of 'specification' strings or `Filter instances`, and will generate a set of matching renditions much more efficiently than generating each one individually. For example:
 
 ```python
 image.get_renditions('width-600', 'height-400', 'fill-300x186|jpegquality-60')
 ```
 
-The return value is a dictionary of renditions keyed by the specification strings that were provided to the method. The return value from the above example would look something like this:
+The return value is a dictionary of renditions keyed by the specifications that were provided to the method. The return value from the above example would look something like this:
 
 ```python
 {
diff --git a/docs/reference/jinja2.md b/docs/reference/jinja2.md
index 55546e9263..201dca55df 100644
--- a/docs/reference/jinja2.md
+++ b/docs/reference/jinja2.md
@@ -70,19 +70,62 @@ See [](slugurl_tag) for more information
 
 ### `image()`
 
-Resize an image, and print an `<img>` tag:
+Resize an image, and render an `<img>` tag:
 
 ```html+jinja
-{# Print an image tag #}
 {{ image(page.header_image, "fill-1024x200", class="header-image") }}
+```
+
+Or resize an image and retrieve the resized image object (rendition) for more bespoke use:
 
-{# Resize an image #}
+```html+jinja
 {% set background=image(page.background_image, "max-1024x1024") %}
-<div class="wrapper" style="background-image: url({{ background.url }});">
+<div class="wrapper" style="background-image: url({{ background.url }});"></div>
 ```
 
 See [](image_tag) for more information
 
+### `srcset_image()`
+
+Resize an image, and render an `<img>` tag including `srcset` with multiple sizes.
+Browsers will select the most appropriate image to load based on [responsive image rules](https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images).
+The [`sizes`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#sizes) attribute is essential unless you store the output of `srcset_image` for later use.
+
+```html+jinja
+{{ srcset_image(page.header_image, "fill-{512x100,1024x200}", sizes="100vw", class="header-image") }}
+```
+
+Or resize an image and retrieve the renditions for more bespoke use:
+
+```html+jinja
+{% set bg=srcset_image(page.background_image, "max-{512x512,1024x1024}") %}
+<div class="wrapper" style="background-image: image-set(url({{ bg.renditions[0].url }}) 1x, url({{ bg.renditions[1].url }}) 2x);"></div>
+```
+
+### `picture()`
+
+Resize or convert an image, rendering a `<picture>` tag including multiple `source` formats with `srcset` for multiple sizes.
+Browsers will select the [first supported image format](https://web.dev/learn/design/picture-element/#image-formats), and pick a size based on [responsive image rules](https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images).
+
+`picture` can render an image in multiple formats:
+
+```html+jinja
+{{ picture(page.header_image, "format-{avif,jpeg}") }}
+```
+
+Or render multiple formats and multiple sizes like `srcset_image` does. The [`sizes`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#sizes) attribute is essential when the picture tag renders images in multiple sizes:
+
+```html+jinja
+{{ picture(page.header_image, "format-{avif,jpeg}|fill-{512x100,1024x200}", sizes="100vw") }}
+```
+
+Or resize an image and retrieve the renditions for more bespoke use:
+
+```html+jinja
+{% set bg=picture(page.background_image, "format-{avif,jpeg}|max-{512x512,1024x1024}") %}
+<div class="wrapper" style="background-image: image-set(url({{ bg.formats['avif'][0].url }}) 1x type('image/avif'), url({{ bg.formats['avif'][1].url }}) 2x type('image/avif'), url({{ bg.formats['jpeg'][0].url }}) 1x type('image/jpeg'), url({{ bg.formats['jpeg'][1].url }}) 2x type('image/jpeg'));"></div>
+```
+
 ### `|richtext`
 
 Transform Wagtail's internal HTML representation, expanding internal references to pages and images.
diff --git a/docs/topics/images.md b/docs/topics/images.md
index 151ab8e37b..396e81c69a 100644
--- a/docs/topics/images.md
+++ b/docs/topics/images.md
@@ -29,6 +29,43 @@ In the above syntax example `[image]` is the Django object referring to the imag
 
 Note that a space separates `[image]` and `[resize-rule]`, but the resize rule must not contain spaces. The width is always specified before the height. Resized images will maintain their original aspect ratio unless the `fill` rule is used, which may result in some pixels being cropped.
 
+(multiple_formats)=
+
+## Multiple formats
+
+To render an image in multiple formats, you can use the `picture` tag:
+
+```html+django
+{% picture page.photo width-400 format-{avif,jpeg} %}
+```
+
+Compared to `image`, this will render a `<picture>` element with one `<source>` element per format. The browser [picks the first format it supports](https://web.dev/learn/design/picture-element/#source), or defaults to a fallback `<img>` element.
+
+`picture` can also be used with responsive image resize rules.
+
+(responsive_images)=
+
+## Responsive images
+
+Wagtail provides `picture` and `srcset_image` template tags which can generate an `<img>` tag with a `srcset` attribute. This allows browsers to select the most appropriate image file to load based on [responsive image rules](https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images).
+
+The syntax for `srcset_image` is the same as `image, with two exceptions:
+
+```html+django
+{% srcset_image [image] [resize-rule-with-brace-expansion] sizes="100vw" %}
+```
+
+- The resize rule should be provided with multiple sizes in a brace-expansion pattern, like `width-{200,400}`. This will generate the `srcset` attribute, with as many URLs as there are sizes defined in the resize rule.
+- The [`sizes`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#sizes) attribute is essential. This tells the browser how large the image will be displayed on the page, so that it can select the most appropriate image to load.
+
+Here is an example with the `picture` tag:
+
+```html+django
+{% picture page.photo fill-{512x100,1024x200} format-{avif,jpeg} sizes="100vw" %}
+```
+
+This will generate a `<picture>` element with one `<source>` for AVIF and one fallback `<img>` with JPEG. Both the source and fallback image will have the `sizes` attribute, and a `srcset` with two URLs each.
+
 (available_resizing_methods)=
 
 ## Available resizing methods
@@ -177,7 +214,7 @@ You can also add default attributes to all images (a default class or data attri
 
 ### 2. Generating the image "as foo" to access individual properties
 
-Wagtail can assign the image data to another variable using Django's `as` syntax:
+Wagtail can assign the image data to another variable using Django's `as` syntax, to access the underlying image Rendition (`tmp_photo`):
 
 ```html+django
 {% image page.photo width-400 as tmp_photo %}
@@ -186,11 +223,36 @@ Wagtail can assign the image data to another variable using Django's `as` syntax
     height="{{ tmp_photo.height }}" alt="{{ tmp_photo.alt }}" class="my-custom-class" />
 ```
 
+This is also possible with the `srcset_image` tag, to retrieve multiple size renditions:
+
+```html+django
+{% srcset_image page.photo width-{200,400} as tmp_photo %}
+
+<img
+    src="{{ tmp_photo.renditions.0.url }}"
+    width="{{ tmp_photo.renditions.0.width }}"
+    height="{{ tmp_photo.renditions.0.height }}"
+    alt="{{ tmp_photo.renditions.0.alt }}"
+    srcset="{{ tmp_photo.renditions.0.url }} 200w, {{ tmp_photo.renditions.1.url }} 400w"
+    sizes="100vw"
+    class="my-custom-class"
+/>
+```
+
+And with the picture tag, to retrieve multiple formats:
+
+```html+django
+{% picture page.photo format-{avif,jpeg} as tmp_photo %}
+
+{{ tmp_photo.avif.0.url }}
+{{ tmp_photo.jpeg.0.url }}
+```
+
 ```{note}
 The image property used for the `src` attribute is `image.url`, not `image.src`.
 ```
 
-This syntax exposes the underlying image Rendition (`tmp_photo`) to the developer. A "Rendition" contains the information specific to the way you've requested to format the image using the resize-rule, dimensions, and source URL. The following properties are available:
+Renditions contain the information specific to the way you've requested to format the image using the resize-rule, dimensions, and source URL. The following properties are available:
 
 ### `url`
 
diff --git a/wagtail/images/image_operations.py b/wagtail/images/image_operations.py
index 6aeaf7a328..2d0df53c2b 100644
--- a/wagtail/images/image_operations.py
+++ b/wagtail/images/image_operations.py
@@ -409,13 +409,15 @@ class WebPQualityOperation(FilterOperation):
 
 
 class FormatOperation(FilterOperation):
+    supported_formats = ["jpeg", "png", "gif", "webp", "avif"]
+
     def construct(self, format, *options):
         self.format = format
         self.options = options
 
-        if self.format not in ["jpeg", "png", "gif", "webp", "avif"]:
+        if self.format not in self.supported_formats:
             raise ValueError(
-                "Format must be either 'jpeg', 'png', 'gif', 'webp' or 'avif'"
+                f"Format must be one of: {', '.join(self.supported_formats)}. Got: {self.format}"
             )
 
     def run(self, willow, image, env):
diff --git a/wagtail/images/jinja2tags.py b/wagtail/images/jinja2tags.py
index 49ea0bde6f..a2a121608e 100644
--- a/wagtail/images/jinja2tags.py
+++ b/wagtail/images/jinja2tags.py
@@ -1,19 +1,16 @@
-import re
-
 from django import template
 from jinja2.ext import Extension
 
-from .shortcuts import get_rendition_or_not_found
+from .models import Filter, Picture, ResponsiveImage
+from .shortcuts import get_rendition_or_not_found, get_renditions_or_not_found
 from .templatetags.wagtailimages_tags import image_url
 
-allowed_filter_pattern = re.compile(r"^[A-Za-z0-9_\-\.\|]+$")
-
 
 def image(image, filterspec, **attrs):
     if not image:
         return ""
 
-    if not allowed_filter_pattern.match(filterspec):
+    if not Filter.pipe_spec_pattern.match(filterspec):
         raise template.TemplateSyntaxError(
             "filter specs in 'image' tag may only contain A-Z, a-z, 0-9, dots, hyphens, pipes and underscores. "
             "(given filter: {})".format(filterspec)
@@ -27,6 +24,38 @@ def image(image, filterspec, **attrs):
         return rendition
 
 
+def srcset_image(image, filterspec, **attrs):
+    if not image:
+        return ""
+
+    if not Filter.pipe_expanding_spec_pattern.match(filterspec):
+        raise template.TemplateSyntaxError(
+            "filter specs in 'srcset_image' tag may only contain A-Z, a-z, 0-9, dots, hyphens, curly braces, commas, pipes and underscores. "
+            "(given filter: {})".format(filterspec)
+        )
+
+    specs = Filter.expand_spec(filterspec)
+    renditions = get_renditions_or_not_found(image, specs)
+
+    return ResponsiveImage(renditions, attrs)
+
+
+def picture(image, filterspec, **attrs):
+    if not image:
+        return ""
+
+    if not Filter.pipe_expanding_spec_pattern.match(filterspec):
+        raise template.TemplateSyntaxError(
+            "filter specs in 'picture' tag may only contain A-Z, a-z, 0-9, dots, hyphens, curly braces, commas, pipes and underscores. "
+            "(given filter: {})".format(filterspec)
+        )
+
+    specs = Filter.expand_spec(filterspec)
+    renditions = get_renditions_or_not_found(image, specs)
+
+    return Picture(renditions, attrs)
+
+
 class WagtailImagesExtension(Extension):
     def __init__(self, environment):
         super().__init__(environment)
@@ -35,6 +64,8 @@ class WagtailImagesExtension(Extension):
             {
                 "image": image,
                 "image_url": image_url,
+                "srcset_image": srcset_image,
+                "picture": picture,
             }
         )
 
diff --git a/wagtail/images/models.py b/wagtail/images/models.py
index 303c6ad313..d0a59230f9 100644
--- a/wagtail/images/models.py
+++ b/wagtail/images/models.py
@@ -1,13 +1,15 @@
 import hashlib
+import itertools
 import logging
 import os.path
+import re
 import time
 from collections import OrderedDict, defaultdict
 from concurrent.futures import ThreadPoolExecutor
 from contextlib import contextmanager
 from io import BytesIO
 from tempfile import SpooledTemporaryFile
-from typing import Dict, Iterable, List, Union
+from typing import Any, Dict, Iterable, List, Optional, Union
 
 import willow
 from django.apps import apps
@@ -34,8 +36,10 @@ from wagtail.images.exceptions import (
     InvalidFilterSpecError,
     UnknownOutputImageFormatError,
 )
+from wagtail.images.fields import image_format_name_to_content_type
 from wagtail.images.image_operations import (
     FilterOperation,
+    FormatOperation,
     ImageTransform,
     TransformOperation,
 )
@@ -496,16 +500,20 @@ class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Mode
         )
         return rendition
 
-    def get_renditions(self, *filter_specs: str) -> Dict[str, "AbstractRendition"]:
+    def get_renditions(
+        self, *filters: Union["Filter", str]
+    ) -> Dict[str, "AbstractRendition"]:
         """
         Returns a ``dict`` of ``Rendition`` instances with image files reflecting
-        the supplied ``filter_specs``, keyed by the relevant ``filter_spec`` string.
+        the supplied ``filters``, keyed by filter spec patterns.
 
         Note: If using custom image models, instances of the custom rendition
         model will be returned.
         """
         Rendition = self.get_rendition_model()
-        filters = [Filter(spec) for spec in dict.fromkeys(filter_specs).keys()]
+        # We don’t support providing mixed Filter and string arguments in the same call.
+        if isinstance(filters[0], str):
+            filters = [Filter(spec) for spec in dict.fromkeys(filters).keys()]
 
         # Find existing renditions where possible
         renditions = self.find_existing_renditions(*filters)
@@ -528,8 +536,8 @@ class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Mode
         if cache_additions:
             Rendition.cache_backend.set_many(cache_additions)
 
-        # Return a dict in the expected format
-        return {filter.spec: rendition for filter, rendition in renditions.items()}
+        # Make sure key insertion order matches the input order.
+        return {filter.spec: renditions[filter] for filter in filters}
 
     def find_existing_renditions(
         self, *filters: "Filter"
@@ -822,10 +830,45 @@ class Filter:
     but could potentially involve colour processing, etc.
     """
 
+    spec_pattern = re.compile(r"^[A-Za-z0-9_\-\.]+$")
+    pipe_spec_pattern = re.compile(r"^[A-Za-z0-9_\-\.\|]+$")
+    expanding_spec_pattern = re.compile(r"^[A-Za-z0-9_\-\.{},]+$")
+    pipe_expanding_spec_pattern = re.compile(r"^[A-Za-z0-9_\-\.{},\|]+$")
+
     def __init__(self, spec=None):
         # The spec pattern is operation1-var1-var2|operation2-var1
         self.spec = spec
 
+    @classmethod
+    def expand_spec(self, spec: Union["str", Iterable["str"]]) -> List["str"]:
+        """
+        Converts a spec pattern with brace-expansions, into a list of spec patterns.
+        For example, "width-{100,200}" becomes ["width-100", "width-200"].
+
+        Supports providing filter specs already split, or pipe or space-separated.
+        """
+        if isinstance(spec, str):
+            separator = "|" if "|" in spec else " "
+            spec = spec.split(separator)
+
+        expanded_segments = []
+        for segment in spec:
+            # Check if segment has braces to expand
+            if "{" in segment and "}" in segment:
+                prefix, options_suffixed = segment.split("{")
+                options_pattern, suffix = options_suffixed.split("}")
+                options = options_pattern.split(",")
+                expanded_segments.append(
+                    [prefix + option + suffix for option in options]
+                )
+            else:
+                expanded_segments.append([segment])
+
+        # Cartesian product of all expanded segments (equivalent to nested for loops).
+        combinations = itertools.product(*expanded_segments)
+
+        return ["|".join(combination) for combination in combinations]
+
     @cached_property
     def operations(self):
         # Search for operations
@@ -1002,6 +1045,121 @@ class Filter:
         return hashlib.sha1(vary_string.encode("utf-8")).hexdigest()[:8]
 
 
+class ResponsiveImage:
+    """
+    A custom object used to represent a collection of renditions.
+    Provides a 'renditions' property to access the renditions,
+    and renders to the front-end HTML.
+    """
+
+    def __init__(
+        self,
+        renditions: Dict[str, "AbstractRendition"],
+        attrs: Optional[Dict[str, Any]] = None,
+    ):
+        self.renditions = list(renditions.values())
+        self.attrs = attrs
+
+    @classmethod
+    def get_width_srcset(cls, renditions_list: List["AbstractRendition"]):
+        if len(renditions_list) == 1:
+            # No point in using width descriptors if there is a single image.
+            return renditions_list[0].url
+
+        return ", ".join([f"{r.url} {r.width}w" for r in renditions_list])
+
+    def __html__(self):
+        attrs = self.attrs or {}
+
+        # No point in adding a srcset if there is a single image.
+        if len(self.renditions) > 1:
+            attrs["srcset"] = self.get_width_srcset(self.renditions)
+
+        # The first rendition is the "base" / "fallback" image.
+        return self.renditions[0].img_tag(attrs)
+
+    def __str__(self):
+        return mark_safe(self.__html__())
+
+    def __bool__(self):
+        return bool(self.renditions)
+
+    def __eq__(self, other: "ResponsiveImage"):
+        if isinstance(other, ResponsiveImage):
+            return self.renditions == other.renditions and self.attrs == other.attrs
+        return False
+
+
+class Picture(ResponsiveImage):
+    # Keep this separate from FormatOperation.supported_formats,
+    # as the order our formats are defined in is essential for the picture tag.
+    # Defines the order of <source> elements in the tag when format operations
+    # are in use, and the priority order to identify the "fallback" format.
+    # The browser will pick the first supported format in this list.
+    source_format_order = ["avif", "webp", "jpeg", "png", "gif"]
+
+    def __init__(
+        self,
+        renditions: Dict[str, "AbstractRendition"],
+        attrs: Optional[Dict[str, Any]] = None,
+    ):
+        super().__init__(renditions, attrs)
+        # Store renditions grouped by format separately for access from templates.
+        self.formats = self.get_formats(renditions)
+
+    def get_formats(
+        self, renditions: Dict[str, "AbstractRendition"]
+    ) -> Dict[str, List["AbstractRendition"]]:
+        """
+        Group renditions by the format they are for, if any.
+        If there is only one format, no grouping is required.
+        """
+        formats = defaultdict(list)
+        for spec, rendition in renditions.items():
+            for fmt in FormatOperation.supported_formats:
+                # Identify the spec’s format (if any).
+                if f"format-{fmt}" in spec:
+                    formats[fmt].append(rendition)
+                    break
+        # Avoid the split by format if there is only one.
+        if len(formats.keys()) < 2:
+            return {}
+
+        return formats
+
+    def get_fallback_format(self):
+        for fmt in reversed(self.source_format_order):
+            if fmt in self.formats:
+                return fmt
+
+    def __html__(self):
+        # If there aren’t multiple formats, render a vanilla img tag with srcset.
+        if not self.formats:
+            return mark_safe(f"<picture>{super().__html__()}</picture>")
+
+        attrs = self.attrs or {}
+
+        sizes = f'sizes="{attrs["sizes"]}" ' if "sizes" in attrs else ""
+        fallback_format = self.get_fallback_format()
+        fallback_renditions = self.formats[fallback_format]
+
+        sources = []
+
+        for fmt in self.source_format_order:
+            if fmt != fallback_format and fmt in self.formats:
+                srcset = self.get_width_srcset(self.formats[fmt])
+                mime = image_format_name_to_content_type(fmt)
+                sources.append(f'<source srcset="{srcset}" {sizes}type="{mime}">')
+
+        if len(fallback_renditions) > 1:
+            attrs["srcset"] = self.get_width_srcset(fallback_renditions)
+
+        # The first rendition is the "base" / "fallback" image.
+        fallback = fallback_renditions[0].img_tag(attrs)
+
+        return mark_safe(f"<picture>{''.join(sources)}{fallback}</picture>")
+
+
 class AbstractRendition(ImageFileMixin, models.Model):
     filter_spec = models.CharField(max_length=255, db_index=True)
     """ Use local ImageField with Willow support.  """
diff --git a/wagtail/images/shortcuts.py b/wagtail/images/shortcuts.py
index 604cd12370..9ffddf615a 100644
--- a/wagtail/images/shortcuts.py
+++ b/wagtail/images/shortcuts.py
@@ -21,3 +21,22 @@ def get_rendition_or_not_found(image, specs):
         rendition = Rendition(image=image, width=0, height=0)
         rendition.file.name = "not-found"
         return rendition
+
+
+def get_renditions_or_not_found(image, specs):
+    """
+    Like get_rendition_or_not_found, but for multiple renditions.
+    Tries to get / create the renditions for the image or renders not-found images if the image does not exist.
+
+    :param image: AbstractImage
+    :param specs: iterable of str or Filter
+    """
+    try:
+        return image.get_renditions(*specs)
+    except SourceImageIOError:
+        Rendition = image.renditions.model
+        rendition = Rendition(image=image, width=0, height=0)
+        rendition.file.name = "not-found"
+        return {
+            spec if isinstance(spec, str) else spec.spec: rendition for spec in specs
+        }
diff --git a/wagtail/images/templatetags/wagtailimages_tags.py b/wagtail/images/templatetags/wagtailimages_tags.py
index a0640dad68..2808739497 100644
--- a/wagtail/images/templatetags/wagtailimages_tags.py
+++ b/wagtail/images/templatetags/wagtailimages_tags.py
@@ -1,21 +1,24 @@
-import re
-
 from django import template
 from django.core.exceptions import ImproperlyConfigured
 from django.urls import NoReverseMatch
 
-from wagtail.images.models import Filter
-from wagtail.images.shortcuts import get_rendition_or_not_found
+from wagtail.images.models import Filter, Picture, ResponsiveImage
+from wagtail.images.shortcuts import (
+    get_rendition_or_not_found,
+    get_renditions_or_not_found,
+)
 from wagtail.images.utils import to_svg_safe_spec
 from wagtail.images.views.serve import generate_image_url
 
 register = template.Library()
-allowed_filter_pattern = re.compile(r"^[A-Za-z0-9_\-\.]+$")
 
 
-@register.tag(name="image")
 def image(parser, token):
-    bits = token.split_contents()[1:]
+    """
+    Image tag parser implementation. Shared between all image tags supporting filter specs
+    as space-separated arguments.
+    """
+    tag_name, *bits = token.split_contents()
     image_expr = parser.compile_filter(bits[0])
     bits = bits[1:]
 
@@ -24,8 +27,9 @@ def image(parser, token):
     output_var_name = None
 
     as_context = False  # if True, the next bit to be read is the output variable name
-    is_valid = True
+    error_messages = []
 
+    multi_rendition = tag_name != "image"
     preserve_svg = False
 
     for bit in bits:
@@ -37,7 +41,7 @@ def image(parser, token):
                 output_var_name = bit
             else:
                 # more than one item exists after 'as' - reject as invalid
-                is_valid = False
+                error_messages.append("More than one variable name after 'as'")
         elif bit == "preserve-svg":
             preserve_svg = True
         else:
@@ -47,36 +51,38 @@ def image(parser, token):
                     value
                 )  # setup to resolve context variables as value
             except ValueError:
-                if allowed_filter_pattern.match(bit):
+                allowed_pattern = (
+                    Filter.expanding_spec_pattern
+                    if multi_rendition
+                    else Filter.spec_pattern
+                )
+                if allowed_pattern.match(bit):
                     filter_specs.append(bit)
                 else:
                     raise template.TemplateSyntaxError(
-                        "filter specs in 'image' tag may only contain A-Z, a-z, 0-9, dots, hyphens and underscores. "
+                        "filter specs in image tags may only contain A-Z, a-z, 0-9, dots, hyphens and underscores (and commas and curly braces for multi-image tags). "
                         "(given filter: {})".format(bit)
                     )
 
     if as_context and output_var_name is None:
         # context was introduced but no variable given ...
-        is_valid = False
+        error_messages.append("Missing a variable name after 'as'")
 
     if output_var_name and attrs:
         # attributes are not valid when using the 'as img' form of the tag
-        is_valid = False
+        error_messages.append("Do not use attributes with 'as' context assignments")
 
     if len(filter_specs) == 0:
         # there must always be at least one filter spec provided
-        is_valid = False
-
-    if len(bits) == 0:
-        # no resize rule provided eg. {% image page.image %}
-        raise template.TemplateSyntaxError(
-            "no resize rule provided. "
-            "'image' tag should be of the form {% image self.photo max-320x200 [ custom-attr=\"value\" ... ] %} "
-            "or {% image self.photo max-320x200 as img %}"
-        )
-
-    if is_valid:
-        return ImageNode(
+        error_messages.append("Image tags must be used with at least one filter spec")
+
+    if len(error_messages) == 0:
+        Node = {
+            "image": ImageNode,
+            "srcset_image": SrcsetImageNode,
+            "picture": PictureNode,
+        }
+        return Node[tag_name](
             image_expr,
             filter_specs,
             attrs=attrs,
@@ -84,12 +90,19 @@ def image(parser, token):
             preserve_svg=preserve_svg,
         )
     else:
+        errors = "; ".join(error_messages)
         raise template.TemplateSyntaxError(
-            "'image' tag should be of the form {% image self.photo max-320x200 [ custom-attr=\"value\" ... ] %} "
-            "or {% image self.photo max-320x200 as img %}"
+            f"Invalid arguments provided to {tag_name}: {errors}. "
+            'Image tags should be of the form {% image self.photo max-320x200 [ custom-attr="value" ... ] %} '
+            "or {% image self.photo max-320x200 as img %}. "
         )
 
 
+register.tag("image", image)
+register.tag("srcset_image", image)
+register.tag("picture", image)
+
+
 class ImageNode(template.Node):
     def __init__(
         self,
@@ -110,19 +123,29 @@ class ImageNode(template.Node):
             return Filter(to_svg_safe_spec(self.filter_specs))
         return Filter(spec="|".join(self.filter_specs))
 
-    def render(self, context):
+    def validate_image(self, context):
         try:
             image = self.image_expr.resolve(context)
         except template.VariableDoesNotExist:
-            return ""
+            return
 
         if not image:
             if self.output_var_name:
                 context[self.output_var_name] = None
-            return ""
+            return
 
         if not hasattr(image, "get_rendition"):
-            raise ValueError("image tag expected an Image object, got %r" % image)
+            raise ValueError(
+                "Image template tags expect an Image object, got %r" % image
+            )
+
+        return image
+
+    def render(self, context):
+        image = self.validate_image(context)
+
+        if not image:
+            return ""
 
         rendition = get_rendition_or_not_found(
             image,
@@ -141,6 +164,60 @@ class ImageNode(template.Node):
             return rendition.img_tag(resolved_attrs)
 
 
+class SrcsetImageNode(ImageNode):
+    def get_filters(self, preserve_svg=False):
+        filter_specs = Filter.expand_spec(self.filter_specs)
+        if preserve_svg:
+            return [Filter(to_svg_safe_spec(f)) for f in filter_specs]
+        return [Filter(spec=f) for f in filter_specs]
+
+    def render(self, context):
+        image = self.validate_image(context)
+
+        if not image:
+            return ""
+
+        specs = self.get_filters(preserve_svg=self.preserve_svg and image.is_svg())
+        renditions = get_renditions_or_not_found(image, specs)
+
+        if self.output_var_name:
+            # Wrap the renditions in ResponsiveImage object, to support both
+            # rendering as-is and access to the data.
+            context[self.output_var_name] = ResponsiveImage(renditions)
+            return ""
+
+        resolved_attrs = {}
+        for key in self.attrs:
+            resolved_attrs[key] = self.attrs[key].resolve(context)
+
+        return ResponsiveImage(renditions, resolved_attrs).__html__()
+
+
+class PictureNode(SrcsetImageNode):
+    def render(self, context):
+        image = self.validate_image(context)
+
+        if not image:
+            return ""
+
+        renditions = get_renditions_or_not_found(
+            image,
+            self.get_filters(preserve_svg=self.preserve_svg and image.is_svg()),
+        )
+
+        if self.output_var_name:
+            # Wrap the renditions in Picture object, to support both
+            # rendering as-is and access to the data.
+            context[self.output_var_name] = Picture(renditions)
+            return ""
+
+        resolved_attrs = {}
+        for key in self.attrs:
+            resolved_attrs[key] = self.attrs[key].resolve(context)
+
+        return Picture(renditions, resolved_attrs).__html__()
+
+
 @register.simple_tag()
 def image_url(image, filter_spec, viewname="wagtailimages_serve"):
     try:


Test patch:
diff --git a/wagtail/images/tests/test_blocks.py b/wagtail/images/tests/test_blocks.py
index ec246ba879..3f1a844f7f 100644
--- a/wagtail/images/tests/test_blocks.py
+++ b/wagtail/images/tests/test_blocks.py
@@ -1,14 +1,16 @@
-import os
 import unittest.mock
 
 from django.apps import apps
-from django.conf import settings
-from django.core import serializers
 from django.test import TestCase
 
 from wagtail.images.blocks import ImageChooserBlock
 
-from .utils import Image, get_test_image_file
+from .utils import (
+    Image,
+    get_test_bad_image,
+    get_test_image_file,
+    get_test_image_filename,
+)
 
 
 class TestImageChooserBlock(TestCase):
@@ -18,39 +20,15 @@ class TestImageChooserBlock(TestCase):
             file=get_test_image_file(),
         )
 
-        # Create an image with a missing file, by deserializing fom a python object
-        # (which bypasses FileField's attempt to read the file)
-        self.bad_image = list(
-            serializers.deserialize(
-                "python",
-                [
-                    {
-                        "fields": {
-                            "title": "missing image",
-                            "height": 100,
-                            "file": "original_images/missing-image.jpg",
-                            "width": 100,
-                        },
-                        "model": "wagtailimages.image",
-                    }
-                ],
-            )
-        )[0].object
+        self.bad_image = get_test_bad_image()
         self.bad_image.save()
 
-    def get_image_filename(self, image, filterspec):
-        """
-        Get the generated filename for a resized image
-        """
-        name, ext = os.path.splitext(os.path.basename(image.file.name))
-        return f"{settings.MEDIA_URL}images/{name}.{filterspec}{ext}"
-
     def test_render(self):
         block = ImageChooserBlock()
         html = block.render(self.image)
         expected_html = (
             '<img alt="Test image" src="{}" width="640" height="480">'.format(
-                self.get_image_filename(self.image, "original")
+                get_test_image_filename(self.image, "original")
             )
         )
 
diff --git a/wagtail/images/tests/test_jinja2.py b/wagtail/images/tests/test_jinja2.py
index 1cf851a083..674fcdb1bb 100644
--- a/wagtail/images/tests/test_jinja2.py
+++ b/wagtail/images/tests/test_jinja2.py
@@ -1,19 +1,22 @@
-import os
 import unittest.mock
 
-from django import template
 from django.apps import apps
-from django.conf import settings
-from django.core import serializers
-from django.template import engines
+from django.template import TemplateSyntaxError, engines
 from django.test import TestCase
 
 from wagtail.models import Site
 
-from .utils import Image, get_test_image_file
+from .utils import (
+    Image,
+    get_test_bad_image,
+    get_test_image_file,
+    get_test_image_filename,
+)
 
 
-class TestImagesJinja(TestCase):
+class JinjaImagesTestCase(TestCase):
+    maxDiff = None
+
     def setUp(self):
         self.engine = engines["jinja2"]
 
@@ -22,24 +25,7 @@ class TestImagesJinja(TestCase):
             file=get_test_image_file(),
         )
 
-        # Create an image with a missing file, by deserializing fom a python object
-        # (which bypasses FileField's attempt to read the file)
-        self.bad_image = list(
-            serializers.deserialize(
-                "python",
-                [
-                    {
-                        "fields": {
-                            "title": "missing image",
-                            "height": 100,
-                            "file": "original_images/missing-image.jpg",
-                            "width": 100,
-                        },
-                        "model": "wagtailimages.image",
-                    }
-                ],
-            )
-        )[0].object
+        self.bad_image = get_test_bad_image()
         self.bad_image.save()
 
     def render(self, string, context=None, request_context=True):
@@ -55,21 +41,20 @@ class TestImagesJinja(TestCase):
         template = self.engine.from_string(string)
         return template.render(context)
 
-    def get_image_filename(self, image, filterspec):
-        """
-        Get the generated filename for a resized image
-        """
-        name, ext = os.path.splitext(os.path.basename(image.file.name))
-        return f"{settings.MEDIA_URL}images/{name}.{filterspec}{ext}"
 
+class TestImageJinja(JinjaImagesTestCase):
     def test_image(self):
         self.assertHTMLEqual(
             self.render('{{ image(myimage, "width-200") }}', {"myimage": self.image}),
             '<img alt="Test image" src="{}" width="200" height="150">'.format(
-                self.get_image_filename(self.image, "width-200")
+                get_test_image_filename(self.image, "width-200")
             ),
         )
 
+    def test_no_image(self):
+        rendered = self.render('{{ image(myimage, "width-2") }}', {"myimage": None})
+        self.assertEqual(rendered, "")
+
     def test_image_attributes(self):
         self.assertHTMLEqual(
             self.render(
@@ -77,18 +62,29 @@ class TestImagesJinja(TestCase):
                 {"myimage": self.image},
             ),
             '<img alt="alternate" src="{}" width="200" height="150" class="test">'.format(
-                self.get_image_filename(self.image, "width-200")
+                get_test_image_filename(self.image, "width-200")
             ),
         )
 
     def test_image_assignment(self):
         template = (
-            '{% set background=image(myimage, "width-200") %}'
-            "width: {{ background.width }}, url: {{ background.url }}"
+            '{% set bg=image(myimage, "width-200") %}'
+            "width: {{ bg.width }}, url: {{ bg.url }}"
         )
-        output = "width: 200, url: " + self.get_image_filename(self.image, "width-200")
+        output = "width: 200, url: " + get_test_image_filename(self.image, "width-200")
         self.assertHTMLEqual(self.render(template, {"myimage": self.image}), output)
 
+    def test_image_assignment_render_as_is(self):
+        self.assertHTMLEqual(
+            self.render(
+                '{% set bg=image(myimage, "width-200") %}{{ bg }}',
+                {"myimage": self.image},
+            ),
+            '<img alt="Test image" src="{}" width="200" height="150">'.format(
+                get_test_image_filename(self.image, "width-200")
+            ),
+        )
+
     def test_missing_image(self):
         self.assertHTMLEqual(
             self.render(
@@ -98,7 +94,9 @@ class TestImagesJinja(TestCase):
         )
 
     def test_invalid_character(self):
-        with self.assertRaises(template.TemplateSyntaxError):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "filter specs in 'image' tag may only"
+        ):
             self.render('{{ image(myimage, "fill-200×200") }}', {"myimage": self.image})
 
     def test_custom_default_attrs(self):
@@ -121,10 +119,12 @@ class TestImagesJinja(TestCase):
                 {"myimage": self.image},
             ),
             '<img alt="Test image" src="{}" width="200" height="150">'.format(
-                self.get_image_filename(self.image, "width-200.jpegquality-40")
+                get_test_image_filename(self.image, "width-200.jpegquality-40")
             ),
         )
 
+
+class TestImageURLJinja(JinjaImagesTestCase):
     def test_image_url(self):
         self.assertRegex(
             self.render(
@@ -143,3 +143,320 @@ class TestImagesJinja(TestCase):
                 self.image.file.name.split("/")[-1]
             ),
         )
+
+
+class TestSrcsetImageJinja(JinjaImagesTestCase):
+    def test_srcset_image(self):
+        filename_200 = get_test_image_filename(self.image, "width-200")
+        filename_400 = get_test_image_filename(self.image, "width-400")
+        rendered = self.render(
+            '{{ srcset_image(myimage, "width-{200,400}", sizes="100vw") }}',
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <img
+                sizes="100vw"
+                src="{filename_200}"
+                srcset="{filename_200} 200w, {filename_400} 400w"
+                alt="Test image"
+                width="200"
+                height="150"
+            >
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_no_image(self):
+        rendered = self.render(
+            '{{ srcset_image(myimage, "width-2") }}', {"myimage": None}
+        )
+        self.assertEqual(rendered, "")
+
+    def test_srcset_output_single_image(self):
+        self.assertHTMLEqual(
+            self.render(
+                '{{ srcset_image(myimage, "width-200") }}',
+                {"myimage": self.image},
+            ),
+            self.render(
+                '{{ image(myimage, "width-200") }}',
+                {"myimage": self.image},
+            ),
+        )
+
+    def test_srcset_image_assignment(self):
+        template = (
+            '{% set bg=srcset_image(myimage, "width-{200,400}") %}'
+            "width: {{ bg.renditions[0].width }}, url: {{ bg.renditions[0].url }} "
+            "width: {{ bg.renditions[1].width }}, url: {{ bg.renditions[1].url }} "
+        )
+        rendered = self.render(template, {"myimage": self.image})
+        expected = f"""
+            width: 200, url: {get_test_image_filename(self.image, "width-200")}
+            width: 400, url: {get_test_image_filename(self.image, "width-400")}
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_srcset_image_assignment_render_as_is(self):
+        rendered = self.render(
+            '{% set bg=srcset_image(myimage, "width-{200,400}") %}{{ bg }}',
+            {"myimage": self.image},
+        )
+        expected = self.render(
+            '{{ srcset_image(myimage, "width-{200,400}") }}',
+            {"myimage": self.image},
+        )
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_missing_srcset_image(self):
+        rendered = self.render(
+            '{{ srcset_image(myimage, "width-{200,400}", sizes="100vw") }}',
+            {"myimage": self.bad_image},
+        )
+        expected = """
+            <img
+                sizes="100vw"
+                src="/media/not-found"
+                srcset="/media/not-found 0w, /media/not-found 0w"
+                alt="missing image"
+                width="0"
+                height="0"
+            >
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_invalid_character(self):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "filter specs in 'srcset_image' tag may only"
+        ):
+            self.render(
+                '{{ srcset_image(myimage, "fill-{20×20,40×40}", sizes="100vw") }}',
+                {"myimage": self.image},
+            )
+
+    def test_custom_default_attrs(self):
+        with unittest.mock.patch.object(
+            apps.get_app_config("wagtailimages"),
+            "default_attrs",
+            new={"decoding": "async", "loading": "lazy"},
+        ):
+            rendered = self.render(
+                '{{ srcset_image(myimage, "width-{20,40}", sizes="100vw") }}',
+                {"myimage": self.bad_image},
+            )
+            expected = """
+                <img
+                    sizes="100vw"
+                    src="/media/not-found"
+                    srcset="/media/not-found 0w, /media/not-found 0w"
+                    alt="missing image"
+                    width="0"
+                    height="0"
+                    decoding="async"
+                    loading="lazy"
+                >
+            """
+            self.assertHTMLEqual(rendered, expected)
+
+    def test_chaining_filterspecs(self):
+        filenames = [
+            get_test_image_filename(self.image, "width-200.jpegquality-40"),
+            get_test_image_filename(self.image, "width-400.jpegquality-40"),
+        ]
+        rendered = self.render(
+            '{{ srcset_image(myimage, "width-{200,400}|jpegquality-40", sizes="100vw") }}',
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <img
+                sizes="100vw"
+                src="{filenames[0]}"
+                srcset="{filenames[0]} 200w, {filenames[1]} 400w"
+                alt="Test image"
+                width="200"
+                height="150"
+            >
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+
+class TestPictureJinja(JinjaImagesTestCase):
+    def test_picture_formats_multi_sizes(self):
+        filenames = [
+            get_test_image_filename(self.image, "width-200.format-jpeg"),
+            get_test_image_filename(self.image, "width-400.format-jpeg"),
+            get_test_image_filename(self.image, "width-200.format-webp"),
+            get_test_image_filename(self.image, "width-400.format-webp"),
+            get_test_image_filename(self.image, "width-200.format-gif"),
+            get_test_image_filename(self.image, "width-400.format-gif"),
+        ]
+
+        rendered = self.render(
+            '{{ picture(myimage, "width-{200,400}|format-{jpeg,webp,gif}", sizes="100vw") }}',
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <picture>
+            <source srcset="{filenames[2]} 200w, {filenames[3]} 400w" sizes="100vw" type="image/webp">
+            <source srcset="{filenames[0]} 200w, {filenames[1]} 400w" sizes="100vw" type="image/jpeg">
+            <img
+                sizes="100vw"
+                src="{filenames[4]}"
+                srcset="{filenames[4]} 200w, {filenames[5]} 400w"
+                alt="Test image"
+                width="200"
+                height="150"
+            >
+            </picture>
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_picture_formats_only(self):
+        filename_jpeg = get_test_image_filename(self.image, "format-jpeg")
+        filename_webp = get_test_image_filename(self.image, "format-webp")
+
+        rendered = self.render(
+            '{{ picture(myimage, "format-{jpeg,webp}") }}',
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <picture>
+            <source srcset="{filename_webp}" type="image/webp">
+            <img
+                src="{filename_jpeg}"
+                alt="Test image"
+                width="640"
+                height="480"
+            >
+            </picture>
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_picture_sizes_only(self):
+        rendered = self.render(
+            '{{ picture(myimage, "width-{200,400}", sizes="100vw") }}',
+            {"myimage": self.image},
+        )
+        expected = self.render(
+            '<picture>{{ srcset_image(myimage, "width-{200,400}", sizes="100vw") }}</picture>',
+            {"myimage": self.image},
+        )
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_picture_single_format(self):
+        rendered = self.render(
+            '{{ picture(myimage, "format-jpeg") }}',
+            {"myimage": self.image},
+        )
+        expected = self.render(
+            '<picture>{{ image(myimage, "format-jpeg") }}</picture>',
+            {"myimage": self.image},
+        )
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_no_image(self):
+        rendered = self.render('{{ picture(myimage, "width-2") }}', {"myimage": None})
+        self.assertEqual(rendered, "")
+
+    def test_picture_assignment(self):
+        template = (
+            '{% set bg=picture(myimage, "width-{200,400}|format-{jpeg,webp}") %}'
+            "width: {{ bg.formats['jpeg'][0].width }}, url: {{ bg.formats['jpeg'][0].url }} "
+            "width: {{ bg.formats['jpeg'][1].width }}, url: {{ bg.formats['jpeg'][1].url }} "
+            "width: {{ bg.formats['webp'][0].width }}, url: {{ bg.formats['webp'][0].url }} "
+            "width: {{ bg.formats['webp'][1].width }}, url: {{ bg.formats['webp'][1].url }} "
+        )
+        rendered = self.render(template, {"myimage": self.image})
+        expected = f"""
+            width: 200, url: {get_test_image_filename(self.image, "width-200.format-jpeg")}
+            width: 400, url: {get_test_image_filename(self.image, "width-400.format-jpeg")}
+            width: 200, url: {get_test_image_filename(self.image, "width-200.format-webp")}
+            width: 400, url: {get_test_image_filename(self.image, "width-400.format-webp")}
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_picture_assignment_render_as_is(self):
+        rendered = self.render(
+            '{% set bg=picture(myimage, "width-{200,400}|format-{jpeg,webp,gif}", sizes="100vw") %}{{ bg }}',
+            {"myimage": self.image},
+        )
+        expected = self.render(
+            '{{ picture(myimage, "width-{200,400}|format-{jpeg,webp,gif}", sizes="100vw") }}',
+            {"myimage": self.image},
+        )
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_missing_picture(self):
+        rendered = self.render(
+            '{{ picture(myimage, "format-{jpeg,webp}") }}',
+            {"myimage": self.bad_image},
+        )
+        expected = """
+            <picture>
+                <source srcset="/media/not-found" type="image/webp">
+                <img
+                    src="/media/not-found"
+                    alt="missing image"
+                    width="0"
+                    height="0"
+                >
+            </picture>
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_invalid_character(self):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "filter specs in 'picture' tag may only"
+        ):
+            self.render(
+                '{{ picture(myimage, "fill-{20×20,40×40}", sizes="100vw") }}',
+                {"myimage": self.image},
+            )
+
+    def test_custom_default_attrs(self):
+        with unittest.mock.patch.object(
+            apps.get_app_config("wagtailimages"),
+            "default_attrs",
+            new={"decoding": "async", "loading": "lazy"},
+        ):
+            rendered = self.render(
+                '{{ picture(myimage, "format-{jpeg,webp}") }}',
+                {"myimage": self.bad_image},
+            )
+            expected = """
+                <picture>
+                    <source srcset="/media/not-found" type="image/webp">
+                    <img
+                        src="/media/not-found"
+                        alt="missing image"
+                        width="0"
+                        height="0"
+                        decoding="async"
+                        loading="lazy"
+                    >
+                </picture>
+            """
+            self.assertHTMLEqual(rendered, expected)
+
+    def test_chaining_filterspecs(self):
+        filename_jpeg = get_test_image_filename(
+            self.image, "format-jpeg.jpegquality-40.webpquality-40"
+        )
+        filename_webp = get_test_image_filename(
+            self.image, "format-webp.jpegquality-40.webpquality-40"
+        )
+        rendered = self.render(
+            '{{ picture(myimage, "format-{jpeg,webp}|jpegquality-40|webpquality-40") }}',
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <picture>
+            <source srcset="{filename_webp}" type="image/webp">
+            <img
+                src="{filename_jpeg}"
+                alt="Test image"
+                width="640"
+                height="480"
+            >
+            </picture>
+        """
+        self.assertHTMLEqual(rendered, expected)
diff --git a/wagtail/images/tests/test_models.py b/wagtail/images/tests/test_models.py
index edccc56d3c..e67648b1d9 100644
--- a/wagtail/images/tests/test_models.py
+++ b/wagtail/images/tests/test_models.py
@@ -7,13 +7,15 @@ from django.core.files.storage import DefaultStorage, Storage
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.db.models import Prefetch
 from django.db.utils import IntegrityError
-from django.test import TestCase, TransactionTestCase
+from django.test import SimpleTestCase, TestCase, TransactionTestCase
 from django.urls import reverse
 from willow.image import Image as WillowImage
 
 from wagtail.images.models import (
     Filter,
+    Picture,
     Rendition,
+    ResponsiveImage,
     SourceImageIOError,
     get_rendition_storage,
 )
@@ -26,7 +28,7 @@ from wagtail.test.testapp.models import (
 )
 from wagtail.test.utils import WagtailTestUtils, override_settings
 
-from .utils import Image, get_test_image_file
+from .utils import Image, get_test_image_file, get_test_image_filename
 
 
 class CustomStorage(Storage):
@@ -236,6 +238,233 @@ class TestImagePermissions(WagtailTestUtils, TestCase):
         self.assertFalse(self.image.is_editable_by_user(self.user))
 
 
+class TestFilters(SimpleTestCase):
+    def test_expand_spec_single(self):
+        self.assertEqual(Filter.expand_spec("width-100"), ["width-100"])
+
+    def test_expand_spec_flat(self):
+        self.assertEqual(
+            Filter.expand_spec("width-100 jpegquality-20"), ["width-100|jpegquality-20"]
+        )
+
+    def test_expand_spec_pipe(self):
+        self.assertEqual(
+            Filter.expand_spec("width-100|jpegquality-20"), ["width-100|jpegquality-20"]
+        )
+
+    def test_expand_spec_list(self):
+        self.assertEqual(
+            Filter.expand_spec(["width-100", "jpegquality-20"]),
+            ["width-100|jpegquality-20"],
+        )
+
+    def test_expand_spec_braced(self):
+        self.assertEqual(
+            Filter.expand_spec("width-{100,200}"), ["width-100", "width-200"]
+        )
+
+    def test_expand_spec_mixed(self):
+        self.assertEqual(
+            Filter.expand_spec("width-{100,200} jpegquality-40"),
+            ["width-100|jpegquality-40", "width-200|jpegquality-40"],
+        )
+
+    def test_expand_spec_mixed_pipe(self):
+        self.assertEqual(
+            Filter.expand_spec("width-{100,200}|jpegquality-40"),
+            ["width-100|jpegquality-40", "width-200|jpegquality-40"],
+        )
+
+    def test_expand_spec_multiple_braces(self):
+        self.assertEqual(
+            Filter.expand_spec("width-{100,200} jpegquality-{40,80} grayscale"),
+            [
+                "width-100|jpegquality-40|grayscale",
+                "width-100|jpegquality-80|grayscale",
+                "width-200|jpegquality-40|grayscale",
+                "width-200|jpegquality-80|grayscale",
+            ],
+        )
+
+
+class TestResponsiveImage(TestCase):
+    def setUp(self):
+        # Create an image for running tests on
+        self.image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file(),
+        )
+        self.rendition_10 = self.image.get_rendition("width-10")
+
+    def test_construct_empty(self):
+        img = ResponsiveImage({})
+        self.assertEqual(img.renditions, [])
+        self.assertEqual(img.attrs, None)
+
+    def test_construct_with_renditions(self):
+        renditions = {"a": self.rendition_10}
+        img = ResponsiveImage(renditions)
+        self.assertEqual(img.renditions, [self.rendition_10])
+
+    def test_evaluate_value(self):
+        self.assertFalse(ResponsiveImage({}))
+        self.assertFalse(ResponsiveImage({}, {"sizes": "100vw"}))
+
+        renditions = {"a": self.rendition_10}
+        self.assertTrue(ResponsiveImage(renditions))
+
+    def test_compare_value(self):
+        renditions = {"a": self.rendition_10}
+        value1 = ResponsiveImage(renditions)
+        value2 = ResponsiveImage(renditions)
+        value3 = ResponsiveImage({"a": self.image.get_rendition("width-15")})
+        value4 = ResponsiveImage(renditions, {"sizes": "100vw"})
+        self.assertNotEqual(value1, value3)
+        self.assertNotEqual(value1, 12345)
+        self.assertEqual(value1, value2)
+        self.assertNotEqual(value1, value4)
+
+    def test_get_width_srcset(self):
+        renditions = {
+            "width-10": self.rendition_10,
+            "width-90": self.image.get_rendition("width-90"),
+        }
+        filenames = [
+            get_test_image_filename(self.image, "width-10"),
+            get_test_image_filename(self.image, "width-90"),
+        ]
+        self.assertEqual(
+            ResponsiveImage.get_width_srcset(list(renditions.values())),
+            f"{filenames[0]} 10w, {filenames[1]} 90w",
+        )
+
+    def test_get_width_srcset_single_rendition(self):
+        renditions = {"width-10": self.rendition_10}
+        self.assertEqual(
+            ResponsiveImage.get_width_srcset(list(renditions.values())),
+            get_test_image_filename(self.image, "width-10"),
+        )
+
+    def test_render(self):
+        renditions = {
+            "width-10": self.rendition_10,
+            "width-90": self.image.get_rendition("width-90"),
+        }
+        img = ResponsiveImage(renditions)
+        filenames = [
+            get_test_image_filename(self.image, "width-10"),
+            get_test_image_filename(self.image, "width-90"),
+        ]
+        self.assertHTMLEqual(
+            img.__html__(),
+            f"""
+                <img
+                    alt="Test image"
+                    src="{filenames[0]}"
+                    srcset="{filenames[0]} 10w, {filenames[1]} 90w"
+                    width="10"
+                    height="7"
+                >
+            """,
+        )
+
+    def test_render_single_image_same_as_img_tag(self):
+        img = ResponsiveImage({"width-10": self.rendition_10})
+        self.assertHTMLEqual(img.__html__(), self.rendition_10.img_tag())
+
+
+class TestPicture(TestCase):
+    def setUp(self):
+        # Create an image for running tests on
+        self.image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file(),
+        )
+        self.rendition_10 = self.image.get_rendition("width-10")
+
+    def test_formats(self):
+        renditions = {
+            "format-jpeg": self.rendition_10,
+            "format-webp": self.rendition_10,
+        }
+        img = Picture(renditions)
+        self.assertEqual(
+            img.formats, {"jpeg": [self.rendition_10], "webp": [self.rendition_10]}
+        )
+
+    def test_single_format(self):
+        renditions = {"format-jpeg": self.rendition_10}
+        img = Picture(renditions)
+        self.assertEqual(img.formats, {})
+
+    def test_mixed_format(self):
+        renditions = {
+            "format-jpeg": self.rendition_10,
+            "format-webp": self.rendition_10,
+            "format-webp-lossless": self.rendition_10,
+        }
+        img = Picture(renditions)
+        self.assertEqual(
+            img.formats,
+            {
+                "jpeg": [self.rendition_10],
+                "webp": [self.rendition_10, self.rendition_10],
+            },
+        )
+
+    def test_fallback_format(self):
+        avif = {"format-avif": self.rendition_10}
+        webp = {"format-webp": self.rendition_10}
+        jpeg = {"format-jpeg": self.rendition_10}
+        png = {"format-png": self.rendition_10}
+        gif = {"format-gif": self.rendition_10}
+        fallbacks = {
+            "gif": {**avif, **webp, **jpeg, **png, **gif},
+            "png": {**avif, **webp, **jpeg, **png},
+            "jpeg": {**avif, **webp, **jpeg},
+            "webp": {**avif, **webp},
+        }
+        for fmt, renditions in fallbacks.items():
+            self.assertEqual(Picture(renditions).get_fallback_format(), fmt)
+
+    def test_render_multi_format_sizes(self):
+        renditions = {
+            "format-jpeg|width-10": self.image.get_rendition("format-jpeg|width-10"),
+            "format-jpeg|width-90": self.image.get_rendition("format-jpeg|width-90"),
+            "format-webp|width-10": self.image.get_rendition("format-webp|width-10"),
+            "format-webp|width-90": self.image.get_rendition("format-webp|width-90"),
+        }
+        img = Picture(renditions, {"sizes": "100vw"})
+        filenames = [
+            get_test_image_filename(self.image, "format-jpeg.width-10"),
+            get_test_image_filename(self.image, "format-jpeg.width-90"),
+            get_test_image_filename(self.image, "format-webp.width-10"),
+            get_test_image_filename(self.image, "format-webp.width-90"),
+        ]
+        self.assertHTMLEqual(
+            img.__html__(),
+            f"""
+                <picture>
+                    <source srcset="{filenames[2]} 10w, {filenames[3]} 90w" sizes="100vw" type="image/webp">
+                    <img
+                        alt="Test image"
+                        sizes="100vw"
+                        src="{filenames[0]}"
+                        srcset="{filenames[0]} 10w, {filenames[1]} 90w"
+                        width="10"
+                        height="7"
+                    >
+                </picture>
+            """,
+        )
+
+    def test_render_single_image_same_as_img_tag(self):
+        img = Picture({"width-10": self.rendition_10})
+        self.assertHTMLEqual(
+            img.__html__(), f"<picture>{self.rendition_10.img_tag()}</picture>"
+        )
+
+
 @override_settings(
     CACHES={"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}}
 )
@@ -292,6 +521,14 @@ class TestRenditions(TestCase):
         # Check that they are the same object
         self.assertEqual(first_rendition, second_rendition)
 
+    def test_get_with_filter_instance(self):
+        # Get two renditions with the same filter
+        first_rendition = self.image.get_rendition("width-400")
+        second_rendition = self.image.get_rendition(Filter("width-400"))
+
+        # Check that they are the same object
+        self.assertEqual(first_rendition, second_rendition)
+
     def test_prefetched_rendition_found(self):
         # Request a rendition that does not exist yet
         with self.assertNumQueries(5):
@@ -364,6 +601,21 @@ class TestRenditions(TestCase):
 
         self.assertIs(second_rendition, third_rendition)
 
+    def test_get_renditions_with_filter_instance(self):
+        # Get two renditions with the same filter
+        first = list(self.image.get_renditions("width-400").values())
+        second = list(self.image.get_renditions(Filter("width-400")).values())
+
+        # Check that they are the same object
+        self.assertEqual(first[0], second[0])
+
+    def test_get_renditions_key_order(self):
+        # Fetch one of the renditions so it exists before the other two.
+        self.image.get_rendition("width-40")
+        specs = ["width-30", "width-40", "width-50"]
+        renditions_keys = list(self.image.get_renditions(*specs).keys())
+        self.assertEqual(renditions_keys, specs)
+
     def _test_get_renditions_performance(
         self,
         db_queries_expected: int,
diff --git a/wagtail/images/tests/test_shortcuts.py b/wagtail/images/tests/test_shortcuts.py
index b628477592..7ec1b4ab01 100644
--- a/wagtail/images/tests/test_shortcuts.py
+++ b/wagtail/images/tests/test_shortcuts.py
@@ -1,6 +1,10 @@
 from django.test import TestCase
 
-from wagtail.images.shortcuts import get_rendition_or_not_found
+from wagtail.images.models import Filter
+from wagtail.images.shortcuts import (
+    get_rendition_or_not_found,
+    get_renditions_or_not_found,
+)
 
 from .utils import Image, get_test_image_file
 
@@ -21,3 +25,41 @@ class TestShortcuts(TestCase):
 
         rendition = get_rendition_or_not_found(bad_image, "width-400")
         self.assertEqual(rendition.file.name, "not-found")
+
+    def test_multiple_fallback_to_not_found(self):
+        bad_image = Image.objects.get(id=1)
+        good_image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file(),
+        )
+
+        renditions = get_renditions_or_not_found(good_image, ("width-200", "width-400"))
+        self.assertEqual(tuple(renditions.keys()), ("width-200", "width-400"))
+        self.assertEqual(renditions["width-200"].width, 200)
+        self.assertEqual(renditions["width-400"].width, 400)
+
+        renditions = get_renditions_or_not_found(bad_image, ("width-200", "width-400"))
+        self.assertEqual(tuple(renditions.keys()), ("width-200", "width-400"))
+        self.assertEqual(renditions["width-200"].file.name, "not-found")
+        self.assertEqual(renditions["width-400"].file.name, "not-found")
+
+    def test_multiple_fallback_to_not_found_with_filters(self):
+        bad_image = Image.objects.get(id=1)
+        good_image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file(),
+        )
+
+        renditions = get_renditions_or_not_found(
+            good_image, (Filter("width-200"), Filter("width-400"))
+        )
+        self.assertEqual(tuple(renditions.keys()), ("width-200", "width-400"))
+        self.assertEqual(renditions["width-200"].width, 200)
+        self.assertEqual(renditions["width-400"].width, 400)
+
+        renditions = get_renditions_or_not_found(
+            bad_image, (Filter("width-200"), Filter("width-400"))
+        )
+        self.assertEqual(tuple(renditions.keys()), ("width-200", "width-400"))
+        self.assertEqual(renditions["width-200"].file.name, "not-found")
+        self.assertEqual(renditions["width-400"].file.name, "not-found")
diff --git a/wagtail/images/tests/test_templatetags.py b/wagtail/images/tests/test_templatetags.py
index 57b9c03653..5de0ba0cad 100644
--- a/wagtail/images/tests/test_templatetags.py
+++ b/wagtail/images/tests/test_templatetags.py
@@ -1,9 +1,18 @@
-from django.template import Variable
+from django.template import Context, Engine, TemplateSyntaxError, Variable
 from django.test import TestCase
 
 from wagtail.images.models import Image, Rendition
 from wagtail.images.templatetags.wagtailimages_tags import ImageNode
-from wagtail.images.tests.utils import get_test_image_file, get_test_image_file_svg
+from wagtail.images.tests.utils import (
+    get_test_bad_image,
+    get_test_image_file,
+    get_test_image_file_svg,
+    get_test_image_filename,
+)
+
+LIBRARIES = {
+    "wagtailimages_tags": "wagtail.images.templatetags.wagtailimages_tags",
+}
 
 
 class ImageNodeTestCase(TestCase):
@@ -103,3 +112,369 @@ class ImageNodeTestCase(TestCase):
                 self.assertEqual(
                     node.get_filter(preserve_svg=image.is_svg()).spec, expected
                 )
+
+
+class ImagesTestCase(TestCase):
+    maxDiff = None
+
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        cls.engine = Engine(
+            app_dirs=True,
+            libraries=LIBRARIES,
+            builtins=[LIBRARIES["wagtailimages_tags"]],
+        )
+
+    @classmethod
+    def setUpTestData(cls):
+        # Create an image for running tests on
+        cls.image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file(),
+        )
+        cls.svg_image = Image.objects.create(
+            title="Test SVG image",
+            file=get_test_image_file_svg(),
+        )
+        cls.bad_image = get_test_bad_image()
+        cls.bad_image.save()
+
+    def render(self, string, context=None):
+        if context is None:
+            context = {}
+
+        template = self.engine.from_string(string)
+        return template.render(Context(context, autoescape=False))
+
+
+class ImageTagTestCase(ImagesTestCase):
+    def test_image(self):
+        filename_200 = get_test_image_filename(self.image, "width-200")
+
+        rendered = self.render("{% image myimage width-200 %}", {"myimage": self.image})
+        self.assertHTMLEqual(
+            rendered,
+            f'<img alt="Test image" height="150" src="{filename_200}" width="200" />',
+        )
+
+    def test_none(self):
+        rendered = self.render("{% image myimage width-2 %}", {"myimage": None})
+        self.assertEqual(rendered, "")
+
+    def test_missing_image(self):
+        rendered = self.render(
+            "{% image myimage width-200 %}", {"myimage": self.bad_image}
+        )
+        self.assertHTMLEqual(
+            rendered,
+            '<img alt="missing image" src="/media/not-found" width="0" height="0">',
+        )
+
+    def test_not_an_image(self):
+        with self.assertRaisesMessage(
+            ValueError, "Image template tags expect an Image object, got 'not a pipe'"
+        ):
+            self.render(
+                "{% image myimage width-200 %}",
+                {"myimage": "not a pipe"},
+            )
+
+    def test_invalid_character(self):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "filter specs in image tags may only"
+        ):
+            self.render(
+                "{% image myimage fill-200×200 %}",
+                {"myimage": self.image},
+            )
+
+    def test_multiple_as_variable(self):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "More than one variable name after 'as'"
+        ):
+            self.render(
+                "{% image myimage width-200 as a b %}",
+                {"myimage": self.image},
+            )
+
+    def test_missing_as_variable(self):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "Missing a variable name after 'as'"
+        ):
+            self.render(
+                "{% image myimage width-200 as %}",
+                {"myimage": self.image},
+            )
+
+    def test_mixing_as_variable_and_attrs(self):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "Do not use attributes with 'as' context assignments"
+        ):
+            self.render(
+                "{% image myimage width-200 alt='Test' as test %}",
+                {"myimage": self.image},
+            )
+
+    def test_missing_filter_spec(self):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "Image tags must be used with at least one filter spec"
+        ):
+            self.render(
+                "{% image myimage %}",
+                {"myimage": self.image},
+            )
+
+
+class SrcsetImageTagTestCase(ImagesTestCase):
+    def test_srcset_image(self):
+        filename_20 = get_test_image_filename(self.image, "width-20")
+        filename_40 = get_test_image_filename(self.image, "width-40")
+
+        rendered = self.render(
+            "{% srcset_image myimage width-{20,40} sizes='100vw' %}",
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <img
+                sizes="100vw"
+                src="{filename_20}"
+                srcset="{filename_20} 20w, {filename_40} 40w"
+                alt="Test image"
+                width="20"
+                height="15"
+            >
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_srcset_output_single_image(self):
+        self.assertHTMLEqual(
+            self.render(
+                "{% srcset_image myimage width-20 %}",
+                {"myimage": self.image},
+            ),
+            self.render(
+                "{% image myimage width-20 %}",
+                {"myimage": self.image},
+            ),
+        )
+
+    def test_none(self):
+        rendered = self.render("{% srcset_image myimage width-2 %}", {"myimage": None})
+        self.assertEqual(rendered, "")
+
+    def test_invalid_character(self):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "filter specs in image tags may only contain"
+        ):
+            self.render(
+                "{% srcset_image myimage fill-{200×200,400×400} sizes='100vw' %}",
+                {"myimage": self.image},
+            )
+
+    def test_srcset_image_assignment(self):
+        template = (
+            "{% srcset_image myimage width-{30,60} as bg %}"
+            "width: {{ bg.renditions.0.width }}, url: {{ bg.renditions.0.url }} "
+            "width: {{ bg.renditions.1.width }}, url: {{ bg.renditions.1.url }} "
+        )
+        rendered = self.render(template, {"myimage": self.image})
+        expected = f"""
+            width: 30, url: {get_test_image_filename(self.image, "width-30")}
+            width: 60, url: {get_test_image_filename(self.image, "width-60")}
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_srcset_image_assignment_render_as_is(self):
+        filename_35 = get_test_image_filename(self.image, "width-35")
+        filename_70 = get_test_image_filename(self.image, "width-70")
+
+        rendered = self.render(
+            "{% srcset_image myimage width-{35,70} as bg %}{{ bg }}",
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <img
+                src="{filename_35}"
+                srcset="{filename_35} 35w, {filename_70} 70w"
+                alt="Test image"
+                width="35"
+                height="26"
+            >
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_missing_srcset_image(self):
+        rendered = self.render(
+            "{% srcset_image myimage width-{200,400} sizes='100vw' %}",
+            {"myimage": self.bad_image},
+        )
+        expected = """
+            <img
+                sizes="100vw"
+                src="/media/not-found"
+                srcset="/media/not-found 0w, /media/not-found 0w"
+                alt="missing image"
+                width="0"
+                height="0"
+            >
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+
+class PictureTagTestCase(ImagesTestCase):
+    def test_picture_formats_multi_sizes(self):
+        filenames = [
+            get_test_image_filename(self.image, "width-200.format-jpeg"),
+            get_test_image_filename(self.image, "width-400.format-jpeg"),
+            get_test_image_filename(self.image, "width-200.format-webp"),
+            get_test_image_filename(self.image, "width-400.format-webp"),
+            get_test_image_filename(self.image, "width-200.format-gif"),
+            get_test_image_filename(self.image, "width-400.format-gif"),
+        ]
+
+        rendered = self.render(
+            '{% picture myimage width-{200,400} format-{jpeg,webp,gif} sizes="100vw" %}',
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <picture>
+            <source srcset="{filenames[2]} 200w, {filenames[3]} 400w" sizes="100vw" type="image/webp">
+            <source srcset="{filenames[0]} 200w, {filenames[1]} 400w" sizes="100vw" type="image/jpeg">
+            <img
+                sizes="100vw"
+                src="{filenames[4]}"
+                srcset="{filenames[4]} 200w, {filenames[5]} 400w"
+                alt="Test image"
+                width="200"
+                height="150"
+            >
+            </picture>
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_picture_formats_only(self):
+        filename_jpeg = get_test_image_filename(self.image, "format-jpeg")
+        filename_webp = get_test_image_filename(self.image, "format-webp")
+
+        rendered = self.render(
+            "{% picture myimage format-{jpeg,webp} %}",
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <picture>
+            <source srcset="{filename_webp}" type="image/webp">
+            <img
+                src="{filename_jpeg}"
+                alt="Test image"
+                width="640"
+                height="480"
+            >
+            </picture>
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_picture_sizes_only(self):
+        rendered = self.render(
+            '{% picture myimage width-{350,450} sizes="100vw" %}',
+            {"myimage": self.image},
+        )
+        expected = self.render(
+            '<picture>{% srcset_image myimage width-{350,450} sizes="100vw" %}</picture>',
+            {"myimage": self.image},
+        )
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_picture_single_format(self):
+        rendered = self.render(
+            "{% picture myimage format-jpeg %}",
+            {"myimage": self.image},
+        )
+        expected = self.render(
+            "<picture>{% image myimage format-jpeg %}</picture>",
+            {"myimage": self.image},
+        )
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_none(self):
+        rendered = self.render("{% picture myimage width-2 %}", {"myimage": None})
+        self.assertEqual(rendered, "")
+
+    def test_picture_assignment(self):
+        template = (
+            "{% picture myimage width-{550,600} format-{jpeg,webp} as bg %}"
+            "width: {{ bg.formats.jpeg.0.width }}, url: {{ bg.formats.jpeg.0.url }} "
+            "width: {{ bg.formats.jpeg.1.width }}, url: {{ bg.formats.jpeg.1.url }} "
+            "width: {{ bg.formats.webp.0.width }}, url: {{ bg.formats.webp.0.url }} "
+            "width: {{ bg.formats.webp.1.width }}, url: {{ bg.formats.webp.1.url }} "
+        )
+        rendered = self.render(template, {"myimage": self.image})
+        expected = f"""
+            width: 550, url: {get_test_image_filename(self.image, "width-550.format-jpeg")}
+            width: 600, url: {get_test_image_filename(self.image, "width-600.format-jpeg")}
+            width: 550, url: {get_test_image_filename(self.image, "width-550.format-webp")}
+            width: 600, url: {get_test_image_filename(self.image, "width-600.format-webp")}
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_picture_assignment_render_as_is(self):
+        rendered = self.render(
+            "{% picture myimage width-{2000,4000} format-{jpeg,webp} as bg %}{{ bg }}",
+            {"myimage": self.image},
+        )
+        expected = self.render(
+            "{% picture myimage width-{2000,4000} format-{jpeg,webp} %}",
+            {"myimage": self.image},
+        )
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_missing_picture(self):
+        rendered = self.render(
+            "{% picture myimage width-{200,400} %}",
+            {"myimage": self.bad_image},
+        )
+        expected = """
+            <picture>
+                <img
+                    src="/media/not-found"
+                    srcset="/media/not-found 0w, /media/not-found 0w"
+                    alt="missing image"
+                    width="0"
+                    height="0"
+                >
+            </picture>
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_invalid_character(self):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "filter specs in image tags may only"
+        ):
+            self.render(
+                '{% picture myimage fill-{20×20,40×40} sizes="100vw" %}',
+                {"myimage": self.image},
+            )
+
+    def test_chaining_filterspecs(self):
+        filename_jpeg = get_test_image_filename(
+            self.image, "format-jpeg.jpegquality-40.webpquality-40"
+        )
+        filename_webp = get_test_image_filename(
+            self.image, "format-webp.jpegquality-40.webpquality-40"
+        )
+        rendered = self.render(
+            "{% picture myimage format-{jpeg,webp} jpegquality-40 webpquality-40 %}",
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <picture>
+            <source srcset="{filename_webp}" type="image/webp">
+            <img
+                src="{filename_jpeg}"
+                alt="Test image"
+                width="640"
+                height="480"
+            >
+            </picture>
+        """
+        self.assertHTMLEqual(rendered, expected)
diff --git a/wagtail/images/tests/utils.py b/wagtail/images/tests/utils.py
index 072c36a261..298733028a 100644
--- a/wagtail/images/tests/utils.py
+++ b/wagtail/images/tests/utils.py
@@ -1,6 +1,9 @@
+import os
 from io import BytesIO
 
 import PIL.Image
+from django.conf import settings
+from django.core import serializers
 from django.core.files.images import ImageFile
 
 from wagtail.images import get_image_model
@@ -8,6 +11,19 @@ from wagtail.images import get_image_model
 Image = get_image_model()
 
 
+def get_test_image_filename(image, filterspec):
+    """
+    Get the generated filename for a resized image
+    """
+    name, ext = os.path.splitext(os.path.basename(image.file.name))
+    # Use the correct extension if the filterspec is a format operation.
+    if "format-" in filterspec:
+        ext = "." + filterspec.split("format-")[1].split("-")[0].split(".")[0].replace(
+            "jpeg", "jpg"
+        )
+    return f"{settings.MEDIA_URL}images/{name}.{filterspec}{ext}"
+
+
 def get_test_image_file(filename="test.png", colour="white", size=(640, 480)):
     f = BytesIO()
     image = PIL.Image.new("RGBA", size, colour)
@@ -54,3 +70,24 @@ def get_test_image_file_svg(
     """
     f = BytesIO(img.strip().encode("utf-8"))
     return ImageFile(f, filename)
+
+
+def get_test_bad_image():
+    # Create an image with a missing file, by deserializing fom a python object
+    # (which bypasses FileField's attempt to read the file)
+    return list(
+        serializers.deserialize(
+            "python",
+            [
+                {
+                    "fields": {
+                        "title": "missing image",
+                        "height": 100,
+                        "file": "original_images/missing-image.jpg",
+                        "width": 100,
+                    },
+                    "model": "wagtailimages.image",
+                }
+            ],
+        )
+    )[0].object