models.py 49 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388
  1. from __future__ import annotations
  2. import concurrent.futures
  3. import hashlib
  4. import itertools
  5. import logging
  6. import os.path
  7. import re
  8. import time
  9. from collections import OrderedDict, defaultdict, namedtuple
  10. from collections.abc import Iterable
  11. from contextlib import contextmanager
  12. from io import BytesIO
  13. from tempfile import SpooledTemporaryFile
  14. from typing import Any
  15. import willow
  16. from django.apps import apps
  17. from django.conf import settings
  18. from django.core import checks
  19. from django.core.cache import DEFAULT_CACHE_ALIAS, InvalidCacheBackendError, caches
  20. from django.core.cache.backends.base import BaseCache
  21. from django.core.exceptions import ImproperlyConfigured
  22. from django.core.files import File
  23. from django.core.files.storage import InvalidStorageError, default_storage, storages
  24. from django.db import models
  25. from django.db.models import Q
  26. from django.forms.utils import flatatt
  27. from django.urls import reverse
  28. from django.utils.functional import cached_property, classproperty
  29. from django.utils.module_loading import import_string
  30. from django.utils.safestring import mark_safe
  31. from django.utils.translation import gettext_lazy as _
  32. from taggit.managers import TaggableManager
  33. from wagtail import hooks
  34. from wagtail.coreutils import string_to_ascii
  35. from wagtail.images.exceptions import (
  36. InvalidFilterSpecError,
  37. UnknownOutputImageFormatError,
  38. )
  39. from wagtail.images.image_operations import (
  40. FilterOperation,
  41. FormatOperation,
  42. ImageTransform,
  43. TransformOperation,
  44. )
  45. from wagtail.images.rect import Rect
  46. from wagtail.models import CollectionMember, ReferenceIndex
  47. from wagtail.search import index
  48. from wagtail.search.queryset import SearchableQuerySetMixin
  49. from wagtail.utils.file import hash_filelike
  50. logger = logging.getLogger("wagtail.images")
  51. IMAGE_FORMAT_EXTENSIONS = {
  52. "avif": ".avif",
  53. "jpeg": ".jpg",
  54. "png": ".png",
  55. "gif": ".gif",
  56. "webp": ".webp",
  57. "svg": ".svg",
  58. "ico": ".ico",
  59. "heic": ".heic",
  60. }
  61. class SourceImageIOError(IOError):
  62. """
  63. Custom exception to distinguish IOErrors that were thrown while opening the source image
  64. """
  65. pass
  66. class ImageQuerySet(SearchableQuerySetMixin, models.QuerySet):
  67. def prefetch_renditions(self, *filters):
  68. """
  69. Prefetches generated renditions for the given filters.
  70. Returns all renditions when no filters are provided.
  71. """
  72. rendition_model = self.model.get_rendition_model()
  73. queryset = rendition_model.objects.all()
  74. if filters:
  75. # Get a list of filter spec strings. The given value could contain Filter objects
  76. filter_specs = [
  77. filter.spec if isinstance(filter, Filter) else filter
  78. for filter in filters
  79. ]
  80. queryset = queryset.filter(filter_spec__in=filter_specs)
  81. return self.prefetch_related(
  82. models.Prefetch(
  83. "renditions",
  84. queryset=queryset,
  85. to_attr="prefetched_renditions",
  86. )
  87. )
  88. def get_upload_to(instance, filename):
  89. """
  90. Obtain a valid upload path for an image file.
  91. This needs to be a module-level function so that it can be referenced within migrations,
  92. but simply delegates to the `get_upload_to` method of the instance, so that AbstractImage
  93. subclasses can override it.
  94. """
  95. return instance.get_upload_to(filename)
  96. def get_rendition_upload_to(instance, filename):
  97. """
  98. Obtain a valid upload path for an image rendition file.
  99. This needs to be a module-level function so that it can be referenced within migrations,
  100. but simply delegates to the `get_upload_to` method of the instance, so that AbstractRendition
  101. subclasses can override it.
  102. """
  103. return instance.get_upload_to(filename)
  104. def get_rendition_storage():
  105. """
  106. Obtain the storage object for an image rendition file.
  107. Returns custom storage (if defined), or the default storage.
  108. This needs to be a module-level function, because we do not yet
  109. have an instance when Django loads the models.
  110. """
  111. storage = getattr(settings, "WAGTAILIMAGES_RENDITION_STORAGE", default_storage)
  112. if isinstance(storage, str):
  113. try:
  114. # First see if the string is a storage alias
  115. storage = storages[storage]
  116. except InvalidStorageError:
  117. # Otherwise treat the string as a dotted path
  118. try:
  119. module = import_string(storage)
  120. storage = module()
  121. except ImportError:
  122. raise ImproperlyConfigured(
  123. "WAGTAILIMAGES_RENDITION_STORAGE must be either a valid storage alias or dotted module path."
  124. )
  125. return storage
  126. class ImageFileMixin:
  127. def is_stored_locally(self):
  128. """
  129. Returns True if the image is hosted on the local filesystem
  130. """
  131. try:
  132. self.file.path
  133. return True
  134. except NotImplementedError:
  135. return False
  136. def get_file_size(self):
  137. if self.file_size is None:
  138. try:
  139. self.file_size = self.file.size
  140. except Exception as e: # noqa: BLE001
  141. # File not found
  142. #
  143. # Have to catch everything, because the exception
  144. # depends on the file subclass, and therefore the
  145. # storage being used.
  146. raise SourceImageIOError(str(e))
  147. self.save(update_fields=["file_size"])
  148. return self.file_size
  149. @contextmanager
  150. def open_file(self):
  151. # Open file if it is closed
  152. close_file = False
  153. try:
  154. image_file = self.file
  155. if self.file.closed:
  156. # Reopen the file
  157. if self.is_stored_locally():
  158. self.file.open("rb")
  159. else:
  160. # Some external storage backends don't allow reopening
  161. # the file. Get a fresh file instance. #1397
  162. storage = self._meta.get_field("file").storage
  163. image_file = storage.open(self.file.name, "rb")
  164. close_file = True
  165. except OSError as e:
  166. # re-throw this as a SourceImageIOError so that calling code can distinguish
  167. # these from IOErrors elsewhere in the process
  168. raise SourceImageIOError(str(e))
  169. # Seek to beginning
  170. image_file.seek(0)
  171. try:
  172. yield image_file
  173. finally:
  174. if close_file:
  175. image_file.close()
  176. @contextmanager
  177. def get_willow_image(self):
  178. with self.open_file() as image_file:
  179. yield willow.Image.open(image_file)
  180. class WagtailImageFieldFile(models.fields.files.ImageFieldFile):
  181. """
  182. Override the ImageFieldFile in order to use Willow instead
  183. of Pillow.
  184. """
  185. def _get_image_dimensions(self):
  186. """
  187. override _get_image_dimensions to call our own get_image_dimensions.
  188. """
  189. if not hasattr(self, "_dimensions_cache"):
  190. self._dimensions_cache = self.get_image_dimensions()
  191. return self._dimensions_cache
  192. def get_image_dimensions(self):
  193. """
  194. The upstream ImageFieldFile calls a local function get_image_dimensions. In this implementation we've made get_image_dimensions
  195. a method to make it easier to override for Wagtail developers in the future.
  196. """
  197. close = self.closed
  198. try:
  199. self.open()
  200. image = willow.Image.open(self)
  201. return image.get_size()
  202. finally:
  203. if close:
  204. self.close()
  205. else:
  206. self.seek(0)
  207. class WagtailImageField(models.ImageField):
  208. """
  209. Override the attr_class on the Django ImageField Model to inject our ImageFieldFile
  210. with Willow support.
  211. """
  212. attr_class = WagtailImageFieldFile
  213. class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Model):
  214. title = models.CharField(max_length=255, verbose_name=_("title"))
  215. """ Use local ImageField with Willow support. """
  216. file = WagtailImageField(
  217. verbose_name=_("file"),
  218. upload_to=get_upload_to,
  219. width_field="width",
  220. height_field="height",
  221. )
  222. description = models.CharField(
  223. blank=True,
  224. max_length=255,
  225. verbose_name=_("description"),
  226. default="",
  227. )
  228. width = models.IntegerField(verbose_name=_("width"), editable=False)
  229. height = models.IntegerField(verbose_name=_("height"), editable=False)
  230. created_at = models.DateTimeField(
  231. verbose_name=_("created at"), auto_now_add=True, db_index=True
  232. )
  233. uploaded_by_user = models.ForeignKey(
  234. settings.AUTH_USER_MODEL,
  235. verbose_name=_("uploaded by user"),
  236. null=True,
  237. blank=True,
  238. editable=False,
  239. on_delete=models.SET_NULL,
  240. )
  241. uploaded_by_user.wagtail_reference_index_ignore = True
  242. tags = TaggableManager(help_text=None, blank=True, verbose_name=_("tags"))
  243. focal_point_x = models.PositiveIntegerField(null=True, blank=True)
  244. focal_point_y = models.PositiveIntegerField(null=True, blank=True)
  245. focal_point_width = models.PositiveIntegerField(null=True, blank=True)
  246. focal_point_height = models.PositiveIntegerField(null=True, blank=True)
  247. file_size = models.PositiveIntegerField(null=True, editable=False)
  248. # A SHA-1 hash of the file contents
  249. file_hash = models.CharField(
  250. max_length=40, blank=True, editable=False, db_index=True
  251. )
  252. objects = ImageQuerySet.as_manager()
  253. def _set_file_hash(self):
  254. with self.open_file() as f:
  255. self.file_hash = hash_filelike(f)
  256. def get_file_hash(self):
  257. if self.file_hash == "":
  258. self._set_file_hash()
  259. self.save(update_fields=["file_hash"])
  260. return self.file_hash
  261. def _set_image_file_metadata(self):
  262. self.file.open()
  263. # Set new image file size
  264. self.file_size = self.file.size
  265. # Set new image file hash
  266. self._set_file_hash()
  267. self.file.seek(0)
  268. def get_upload_to(self, filename):
  269. """
  270. Generates a file path in the "original_images" folder.
  271. Ensuring ASCII characters and limiting length to prevent filesystem issues during uploads.
  272. """
  273. folder_name = "original_images"
  274. filename = self.file.field.storage.get_valid_name(filename)
  275. # convert the filename to simple ascii characters and then
  276. # replace non-ascii characters in filename with _ , to sidestep issues with filesystem encoding
  277. filename = "".join(
  278. (i if ord(i) < 128 else "_") for i in string_to_ascii(filename)
  279. )
  280. # Truncate filename so it fits in the 100 character limit
  281. # https://code.djangoproject.com/ticket/9893
  282. full_path = os.path.join(folder_name, filename)
  283. if len(full_path) >= 95:
  284. chars_to_trim = len(full_path) - 94
  285. prefix, extension = os.path.splitext(filename)
  286. filename = prefix[:-chars_to_trim] + extension
  287. full_path = os.path.join(folder_name, filename)
  288. return full_path
  289. def get_usage(self):
  290. return ReferenceIndex.get_grouped_references_to(self)
  291. @property
  292. def usage_url(self):
  293. return reverse("wagtailimages:image_usage", args=(self.id,))
  294. search_fields = CollectionMember.search_fields + [
  295. index.SearchField("title", boost=10),
  296. index.AutocompleteField("title"),
  297. index.FilterField("title"),
  298. index.RelatedFields(
  299. "tags",
  300. [
  301. index.SearchField("name", boost=10),
  302. index.AutocompleteField("name"),
  303. ],
  304. ),
  305. index.FilterField("uploaded_by_user"),
  306. index.FilterField("created_at"),
  307. index.FilterField("id"),
  308. ]
  309. def __str__(self):
  310. return self.title
  311. def get_rect(self):
  312. return Rect(0, 0, self.width, self.height)
  313. def get_focal_point(self):
  314. if (
  315. self.focal_point_x is not None
  316. and self.focal_point_y is not None
  317. and self.focal_point_width is not None
  318. and self.focal_point_height is not None
  319. ):
  320. return Rect.from_point(
  321. self.focal_point_x,
  322. self.focal_point_y,
  323. self.focal_point_width,
  324. self.focal_point_height,
  325. )
  326. def has_focal_point(self):
  327. return self.get_focal_point() is not None
  328. def set_focal_point(self, rect):
  329. if rect is not None:
  330. self.focal_point_x = rect.centroid_x
  331. self.focal_point_y = rect.centroid_y
  332. self.focal_point_width = rect.width
  333. self.focal_point_height = rect.height
  334. else:
  335. self.focal_point_x = None
  336. self.focal_point_y = None
  337. self.focal_point_width = None
  338. self.focal_point_height = None
  339. def get_suggested_focal_point(self):
  340. if self.is_svg():
  341. # We can't run feature detection on SVGs, and don't provide a
  342. # pathway from SVG -> raster formats, so don't try it.
  343. return None
  344. with self.get_willow_image() as willow:
  345. faces = willow.detect_faces()
  346. if faces:
  347. # Create a bounding box around all faces
  348. left = min(face[0] for face in faces)
  349. top = min(face[1] for face in faces)
  350. right = max(face[2] for face in faces)
  351. bottom = max(face[3] for face in faces)
  352. focal_point = Rect(left, top, right, bottom)
  353. else:
  354. features = willow.detect_features()
  355. if features:
  356. # Create a bounding box around all features
  357. left = min(feature[0] for feature in features)
  358. top = min(feature[1] for feature in features)
  359. right = max(feature[0] for feature in features)
  360. bottom = max(feature[1] for feature in features)
  361. focal_point = Rect(left, top, right, bottom)
  362. else:
  363. return None
  364. # Add 20% to width and height and give it a minimum size
  365. x, y = focal_point.centroid
  366. width, height = focal_point.size
  367. width *= 1.20
  368. height *= 1.20
  369. width = max(width, 100)
  370. height = max(height, 100)
  371. return Rect.from_point(x, y, width, height)
  372. @classmethod
  373. def get_rendition_model(cls):
  374. """Get the Rendition model for this Image model"""
  375. return cls.renditions.rel.related_model
  376. def _get_prefetched_renditions(self) -> Iterable[AbstractRendition] | None:
  377. if "renditions" in getattr(self, "_prefetched_objects_cache", {}):
  378. return self.renditions.all()
  379. return getattr(self, "prefetched_renditions", None)
  380. def _add_to_prefetched_renditions(self, rendition: AbstractRendition) -> None:
  381. # Reuse this rendition if requested again from this object
  382. try:
  383. self._prefetched_objects_cache["renditions"]._result_cache.append(rendition)
  384. except (AttributeError, KeyError):
  385. pass
  386. try:
  387. self.prefetched_renditions.append(rendition)
  388. except AttributeError:
  389. pass
  390. def get_rendition(self, filter: Filter | str) -> AbstractRendition:
  391. """
  392. Returns a ``Rendition`` instance with a ``file`` field value (an
  393. image) reflecting the supplied ``filter`` value and focal point values
  394. from this object.
  395. Note: If using custom image models, an instance of the custom rendition
  396. model will be returned.
  397. """
  398. Rendition = self.get_rendition_model()
  399. if isinstance(filter, str):
  400. filter = Filter(spec=filter)
  401. try:
  402. rendition = self.find_existing_rendition(filter)
  403. except Rendition.DoesNotExist:
  404. rendition = self.create_rendition(filter)
  405. # Reuse this rendition if requested again from this object
  406. self._add_to_prefetched_renditions(rendition)
  407. cache_key = Rendition.construct_cache_key(
  408. self, filter.get_cache_key(self), filter.spec
  409. )
  410. Rendition.cache_backend.set(cache_key, rendition)
  411. return rendition
  412. def find_existing_rendition(self, filter: Filter) -> AbstractRendition:
  413. """
  414. Returns an existing ``Rendition`` instance with a ``file`` field value
  415. (an image) reflecting the supplied ``filter`` value and focal point
  416. values from this object.
  417. If no such rendition exists, a ``DoesNotExist`` error is raised for the
  418. relevant model.
  419. Note: If using custom image models, an instance of the custom rendition
  420. model will be returned.
  421. """
  422. Rendition = self.get_rendition_model()
  423. try:
  424. return self.find_existing_renditions(filter)[filter]
  425. except KeyError:
  426. raise Rendition.DoesNotExist
  427. def create_rendition(self, filter: Filter) -> AbstractRendition:
  428. """
  429. Creates and returns a ``Rendition`` instance with a ``file`` field
  430. value (an image) reflecting the supplied ``filter`` value and focal
  431. point values from this object.
  432. This method is usually called by ``Image.get_rendition()``, after first
  433. checking that a suitable rendition does not already exist.
  434. Note: If using custom image models, an instance of the custom rendition
  435. model will be returned.
  436. """
  437. # Because of unique constraints applied to the model, we use
  438. # get_or_create() to guard against race conditions
  439. rendition, created = self.renditions.get_or_create(
  440. filter_spec=filter.spec,
  441. focal_point_key=filter.get_cache_key(self),
  442. defaults={"file": self.generate_rendition_file(filter)},
  443. )
  444. return rendition
  445. def get_renditions(self, *filters: Filter | str) -> dict[str, AbstractRendition]:
  446. """
  447. Returns a ``dict`` of ``Rendition`` instances with image files reflecting
  448. the supplied ``filters``, keyed by filter spec patterns.
  449. Note: If using custom image models, instances of the custom rendition
  450. model will be returned.
  451. """
  452. Rendition = self.get_rendition_model()
  453. # We don’t support providing mixed Filter and string arguments in the same call.
  454. if isinstance(filters[0], str):
  455. filters = [Filter(spec) for spec in dict.fromkeys(filters).keys()]
  456. # Find existing renditions where possible
  457. renditions = self.find_existing_renditions(*filters)
  458. # Create any renditions not found in prefetched values, cache or database
  459. not_found = [f for f in filters if f not in renditions]
  460. for filter, rendition in self.create_renditions(*not_found).items():
  461. self._add_to_prefetched_renditions(rendition)
  462. renditions[filter] = rendition
  463. # Update the cache
  464. cache_additions = {
  465. Rendition.construct_cache_key(
  466. self, filter.get_cache_key(self), filter.spec
  467. ): rendition
  468. for filter, rendition in renditions.items()
  469. # prevent writing of cached data back to the cache
  470. if not getattr(rendition, "_from_cache", False)
  471. }
  472. if cache_additions:
  473. Rendition.cache_backend.set_many(cache_additions)
  474. # Make sure key insertion order matches the input order.
  475. return {filter.spec: renditions[filter] for filter in filters}
  476. def find_existing_renditions(
  477. self, *filters: Filter
  478. ) -> dict[Filter, AbstractRendition]:
  479. """
  480. Returns a dictionary of existing ``Rendition`` instances with ``file``
  481. values (images) reflecting the supplied ``filters`` and the focal point
  482. values from this object.
  483. Filters for which an existing rendition cannot be found are omitted
  484. from the return value. If none of the requested renditions have been
  485. created before, the return value will be an empty dict.
  486. """
  487. Rendition = self.get_rendition_model()
  488. filters_by_spec: dict[str, Filter] = {f.spec: f for f in filters}
  489. found: dict[Filter, AbstractRendition] = {}
  490. # Interrogate prefetched values first (where available)
  491. prefetched_renditions = self._get_prefetched_renditions()
  492. if prefetched_renditions is not None:
  493. # NOTE: When renditions are prefetched, it's assumed that if the
  494. # requested renditions exist, they will be present in the
  495. # prefetched value, and further cache/database lookups are avoided.
  496. # group renditions by the filters of interest
  497. potential_matches: dict[Filter, list[AbstractRendition]] = defaultdict(list)
  498. for rendition in prefetched_renditions:
  499. try:
  500. filter = filters_by_spec[rendition.filter_spec]
  501. except KeyError:
  502. continue # this rendition can be ignored
  503. else:
  504. potential_matches[filter].append(rendition)
  505. # For each filter we have renditions for, look for one with a
  506. # 'focal_point_key' value matching filter.get_cache_key()
  507. for filter, renditions in potential_matches.items():
  508. focal_point_key = filter.get_cache_key(self)
  509. for rendition in renditions:
  510. if rendition.focal_point_key == focal_point_key:
  511. # to prevent writing of cached data back to the cache
  512. rendition._from_cache = True
  513. # use this rendition
  514. found[filter] = rendition
  515. # skip to the next filter
  516. break
  517. else:
  518. # Renditions are not prefetched, so attempt to find suitable
  519. # items in the cache or database
  520. # Query the cache first
  521. cache_keys = [
  522. Rendition.construct_cache_key(self, filter.get_cache_key(self), spec)
  523. for spec, filter in filters_by_spec.items()
  524. ]
  525. for rendition in Rendition.cache_backend.get_many(cache_keys).values():
  526. filter = filters_by_spec[rendition.filter_spec]
  527. found[filter] = rendition
  528. # For items not found in the cache, look in the database
  529. not_found = [f for f in filters if f not in found]
  530. if not_found:
  531. lookup_q = Q()
  532. for filter in not_found:
  533. lookup_q |= Q(
  534. filter_spec=filter.spec,
  535. focal_point_key=filter.get_cache_key(self),
  536. )
  537. for rendition in self.renditions.filter(lookup_q):
  538. filter = filters_by_spec[rendition.filter_spec]
  539. found[filter] = rendition
  540. return found
  541. def create_renditions(self, *filters: Filter) -> dict[Filter, AbstractRendition]:
  542. """
  543. Creates multiple ``Rendition`` instances with image files reflecting the supplied
  544. ``filters``, and returns them as a ``dict`` keyed by the relevant ``Filter`` instance.
  545. Where suitable renditions already exist in the database, they will be returned instead,
  546. so as not to create duplicates.
  547. This method is usually called by ``Image.get_renditions()``, after first
  548. checking that a suitable rendition does not already exist.
  549. Note: If using custom image models, an instance of the custom rendition
  550. model will be returned.
  551. """
  552. Rendition = self.get_rendition_model()
  553. if not filters:
  554. return {}
  555. if len(filters) == 1:
  556. # create_rendition() is better for single renditions, as it can
  557. # utilize QuerySet.get_or_create(), which has better handling of
  558. # race conditions
  559. filter = filters[0]
  560. return {filter: self.create_rendition(filter)}
  561. return_value: dict[Filter, AbstractRendition] = {}
  562. filter_map: dict[str, Filter] = {f.spec: f for f in filters}
  563. # Read file contents into memory
  564. with self.open_file() as file:
  565. original_image_bytes = file.read()
  566. to_create = []
  567. with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
  568. for future in concurrent.futures.as_completed(
  569. executor.submit(
  570. self.generate_rendition_instance,
  571. filter,
  572. BytesIO(original_image_bytes),
  573. )
  574. for filter in filters
  575. ):
  576. to_create.append(future.result())
  577. # Rendition generation can take a while. So, if other processes have created
  578. # identical renditions in the meantime, we should find them to avoid clashes.
  579. # NB: Clashes can still occur, because there is no get_or_create() equivalent
  580. # for multiple objects. However, this will reduce that risk considerably.
  581. files_for_deletion: list[File] = []
  582. # Assemble Q() to identify potential clashes
  583. lookup_q = Q()
  584. for rendition in to_create:
  585. lookup_q |= Q(
  586. filter_spec=rendition.filter_spec,
  587. focal_point_key=rendition.focal_point_key,
  588. )
  589. for existing in self.renditions.filter(lookup_q):
  590. # Include the existing rendition in the return value
  591. filter = filter_map[existing.filter_spec]
  592. return_value[filter] = existing
  593. for new in list(to_create):
  594. if (
  595. new.filter_spec == existing.filter_spec
  596. and new.focal_point_key == existing.focal_point_key
  597. ):
  598. # Avoid creating the new version
  599. to_create.remove(new)
  600. # Mark for deletion later, so as not to hold up creation
  601. files_for_deletion.append(new.file)
  602. for new in Rendition.objects.bulk_create(to_create, ignore_conflicts=True):
  603. filter = filter_map[new.filter_spec]
  604. return_value[filter] = new
  605. # Delete redundant rendition image files
  606. for file in files_for_deletion:
  607. file.delete(save=False)
  608. return return_value
  609. def generate_rendition_instance(
  610. self, filter: Filter, source: BytesIO
  611. ) -> AbstractRendition:
  612. """
  613. Use the supplied ``source`` image to create and return an
  614. **unsaved** ``Rendition`` instance, with a ``file`` value reflecting
  615. the supplied ``filter`` value and focal point values from this object.
  616. """
  617. return self.get_rendition_model()(
  618. image=self,
  619. filter_spec=filter.spec,
  620. focal_point_key=filter.get_cache_key(self),
  621. file=self.generate_rendition_file(
  622. filter, source=File(source, name=self.file.name)
  623. ),
  624. )
  625. def generate_rendition_file(self, filter: Filter, *, source: File = None) -> File:
  626. """
  627. Generates an in-memory image matching the supplied ``filter`` value
  628. and focal point value from this object, wraps it in a ``File`` object
  629. with a suitable filename, and returns it. The return value is used
  630. as the ``file`` field value for rendition objects saved by
  631. ``AbstractImage.create_rendition()``.
  632. If the contents of ``self.file`` has already been read into memory, the
  633. ``source`` keyword can be used to provide a reference to the in-memory
  634. ``File``, bypassing the need to reload the image contents from storage.
  635. NOTE: The responsibility of generating the new image from the original
  636. falls to the supplied ``filter`` object. If you want to do anything
  637. custom with rendition images (for example, to preserve metadata from
  638. the original image), you might want to consider swapping out ``filter``
  639. for an instance of a custom ``Filter`` subclass of your design.
  640. """
  641. cache_key = filter.get_cache_key(self)
  642. logger.debug(
  643. "Generating '%s' rendition for image %d",
  644. filter.spec,
  645. self.pk,
  646. )
  647. start_time = time.time()
  648. try:
  649. generated_image = filter.run(
  650. self,
  651. SpooledTemporaryFile(max_size=settings.FILE_UPLOAD_MAX_MEMORY_SIZE),
  652. source=source,
  653. )
  654. logger.debug(
  655. "Generated '%s' rendition for image %d in %.1fms",
  656. filter.spec,
  657. self.pk,
  658. (time.time() - start_time) * 1000,
  659. )
  660. except: # noqa:B901,E722
  661. logger.debug(
  662. "Failed to generate '%s' rendition for image %d",
  663. filter.spec,
  664. self.pk,
  665. )
  666. raise
  667. # Generate filename
  668. input_filename = os.path.basename(self.file.name)
  669. input_filename_without_extension, input_extension = os.path.splitext(
  670. input_filename
  671. )
  672. output_extension = (
  673. filter.spec.replace("|", ".")
  674. + IMAGE_FORMAT_EXTENSIONS[generated_image.format_name]
  675. )
  676. if cache_key:
  677. output_extension = cache_key + "." + output_extension
  678. # Truncate filename to prevent it going over 60 chars
  679. output_filename_without_extension = input_filename_without_extension[
  680. : (59 - len(output_extension))
  681. ]
  682. output_filename = output_filename_without_extension + "." + output_extension
  683. return File(generated_image.f, name=output_filename)
  684. def is_portrait(self):
  685. return self.width < self.height
  686. def is_landscape(self):
  687. return self.height < self.width
  688. def is_svg(self):
  689. _, ext = os.path.splitext(self.file.name)
  690. return ext.lower() == ".svg"
  691. @property
  692. def filename(self):
  693. return os.path.basename(self.file.name)
  694. @property
  695. def default_alt_text(self):
  696. # by default the alt text field (used in rich text insertion) is populated
  697. # from the description. In the absence of that, it is populated from the title.
  698. # Subclasses might provide a separate alt field, and override this
  699. return getattr(self, "description", None) or self.title
  700. def is_editable_by_user(self, user):
  701. from wagtail.images.permissions import permission_policy
  702. return permission_policy.user_has_permission_for_instance(user, "change", self)
  703. class Meta:
  704. abstract = True
  705. class Image(AbstractImage):
  706. admin_form_fields = (
  707. "title",
  708. "file",
  709. "description",
  710. "collection",
  711. "tags",
  712. "focal_point_x",
  713. "focal_point_y",
  714. "focal_point_width",
  715. "focal_point_height",
  716. )
  717. class Meta(AbstractImage.Meta):
  718. verbose_name = _("image")
  719. verbose_name_plural = _("images")
  720. permissions = [
  721. ("choose_image", "Can choose image"),
  722. ]
  723. class Filter:
  724. """
  725. Represents one or more operations that can be applied to an Image to produce a rendition
  726. appropriate for final display on the website. Usually this would be a resize operation,
  727. but could potentially involve colour processing, etc.
  728. """
  729. spec_pattern = re.compile(r"^[A-Za-z0-9_\-\.]+$")
  730. pipe_spec_pattern = re.compile(r"^[A-Za-z0-9_\-\.\|]+$")
  731. expanding_spec_pattern = re.compile(r"^[A-Za-z0-9_\-\.{},]+$")
  732. pipe_expanding_spec_pattern = re.compile(r"^[A-Za-z0-9_\-\.{},\|]+$")
  733. def __init__(self, spec=None):
  734. # The spec pattern is operation1-var1-var2|operation2-var1
  735. self.spec = spec
  736. @classmethod
  737. def expand_spec(self, spec: str | Iterable[str]) -> list[str]:
  738. """
  739. Converts a spec pattern with brace-expansions, into a list of spec patterns.
  740. For example, "width-{100,200}" becomes ["width-100", "width-200"].
  741. Supports providing filter specs already split, or pipe or space-separated.
  742. """
  743. if isinstance(spec, str):
  744. separator = "|" if "|" in spec else " "
  745. spec = spec.split(separator)
  746. expanded_segments = []
  747. for segment in spec:
  748. # Check if segment has braces to expand
  749. if "{" in segment and "}" in segment:
  750. prefix, options_suffixed = segment.split("{")
  751. options_pattern, suffix = options_suffixed.split("}")
  752. options = options_pattern.split(",")
  753. expanded_segments.append(
  754. [prefix + option + suffix for option in options]
  755. )
  756. else:
  757. expanded_segments.append([segment])
  758. # Cartesian product of all expanded segments (equivalent to nested for loops).
  759. combinations = itertools.product(*expanded_segments)
  760. return ["|".join(combination) for combination in combinations]
  761. @cached_property
  762. def operations(self):
  763. # Search for operations
  764. registered_operations = {}
  765. for fn in hooks.get_hooks("register_image_operations"):
  766. registered_operations.update(dict(fn()))
  767. # Build list of operation objects
  768. operations = []
  769. for op_spec in self.spec.split("|"):
  770. op_spec_parts = op_spec.split("-")
  771. if op_spec_parts[0] not in registered_operations:
  772. raise InvalidFilterSpecError(
  773. "Unrecognised operation: %s" % op_spec_parts[0]
  774. )
  775. op_class = registered_operations[op_spec_parts[0]]
  776. operations.append(op_class(*op_spec_parts))
  777. return operations
  778. @property
  779. def transform_operations(self):
  780. return [
  781. operation
  782. for operation in self.operations
  783. if isinstance(operation, TransformOperation)
  784. ]
  785. @property
  786. def filter_operations(self):
  787. return [
  788. operation
  789. for operation in self.operations
  790. if isinstance(operation, FilterOperation)
  791. ]
  792. def get_transform(self, image, size=None):
  793. """
  794. Returns an ImageTransform with all the transforms in this filter applied.
  795. The ImageTransform is an object with two attributes:
  796. - .size - The size of the final image
  797. - .matrix - An affine transformation matrix that combines any
  798. transform/scale/rotation operations that need to be applied to the image
  799. """
  800. if not size:
  801. size = (image.width, image.height)
  802. transform = ImageTransform(size, image_is_svg=image.is_svg())
  803. for operation in self.transform_operations:
  804. transform = operation.run(transform, image)
  805. return transform
  806. @contextmanager
  807. def get_willow_image(self, image: AbstractImage, source: File = None):
  808. if source is not None:
  809. yield willow.Image.open(source)
  810. else:
  811. with image.get_willow_image() as willow_image:
  812. yield willow_image
  813. def run(self, image: AbstractImage, output: BytesIO, source: File = None):
  814. with self.get_willow_image(image, source) as willow:
  815. original_format = willow.format_name
  816. # Fix orientation of image
  817. willow = willow.auto_orient()
  818. # Transform the image
  819. transform = self.get_transform(
  820. image, (willow.image.width, willow.image.height)
  821. )
  822. willow = willow.crop(transform.get_rect().round())
  823. willow = willow.resize(transform.size)
  824. # Apply filters
  825. env = {
  826. "original-format": original_format,
  827. }
  828. for operation in self.filter_operations:
  829. willow = operation.run(willow, image, env) or willow
  830. # Find the output format to use
  831. if "output-format" in env:
  832. # Developer specified an output format
  833. output_format = env["output-format"]
  834. else:
  835. # Convert avif, bmp and webp to png, and heic to jpg, by default
  836. default_conversions = {
  837. "avif": "png",
  838. "bmp": "png",
  839. "webp": "png",
  840. "heic": "jpeg",
  841. }
  842. # Convert unanimated GIFs to PNG as well
  843. if not willow.has_animation():
  844. default_conversions["gif"] = "png"
  845. # Allow the user to override the conversions
  846. conversion = getattr(settings, "WAGTAILIMAGES_FORMAT_CONVERSIONS", {})
  847. default_conversions.update(conversion)
  848. # Get the converted output format falling back to the original
  849. output_format = default_conversions.get(
  850. original_format, original_format
  851. )
  852. if output_format == "jpeg":
  853. # Allow changing of JPEG compression quality
  854. if "jpeg-quality" in env:
  855. quality = env["jpeg-quality"]
  856. else:
  857. quality = getattr(settings, "WAGTAILIMAGES_JPEG_QUALITY", 85)
  858. # If the image has an alpha channel, give it a white background
  859. if willow.has_alpha():
  860. willow = willow.set_background_color_rgb((255, 255, 255))
  861. return willow.save_as_jpeg(
  862. output, quality=quality, progressive=True, optimize=True
  863. )
  864. elif output_format == "png":
  865. return willow.save_as_png(output, optimize=True)
  866. elif output_format == "gif":
  867. return willow.save_as_gif(output)
  868. elif output_format == "webp":
  869. # Allow changing of WebP compression quality
  870. if (
  871. "output-format-options" in env
  872. and "lossless" in env["output-format-options"]
  873. ):
  874. return willow.save_as_webp(output, lossless=True)
  875. elif "webp-quality" in env:
  876. quality = env["webp-quality"]
  877. else:
  878. quality = getattr(settings, "WAGTAILIMAGES_WEBP_QUALITY", 80)
  879. return willow.save_as_webp(output, quality=quality)
  880. elif output_format == "avif":
  881. # Allow changing of AVIF compression quality
  882. if (
  883. "output-format-options" in env
  884. and "lossless" in env["output-format-options"]
  885. ):
  886. return willow.save_as_avif(output, lossless=True)
  887. elif "avif-quality" in env:
  888. quality = env["avif-quality"]
  889. else:
  890. quality = getattr(settings, "WAGTAILIMAGES_AVIF_QUALITY", 80)
  891. return willow.save_as_avif(output, quality=quality)
  892. elif output_format == "heic":
  893. # Allow changing of HEIC compression quality. Safari is the only browser that supports HEIC,
  894. # so there is little value in outputting it - for that reason, we make it work if someone
  895. # explicitly requests it, but these settings are not documented.
  896. if (
  897. "output-format-options" in env
  898. and "lossless" in env["output-format-options"]
  899. ):
  900. return willow.save_as_heic(output, lossless=True)
  901. elif "heic-quality" in env:
  902. quality = env["heic-quality"]
  903. else:
  904. quality = getattr(settings, "WAGTAILIMAGES_HEIC_QUALITY", 80)
  905. return willow.save_as_heic(output, quality=quality)
  906. elif output_format == "svg":
  907. return willow.save_as_svg(output)
  908. elif output_format == "ico":
  909. return willow.save_as_ico(output)
  910. raise UnknownOutputImageFormatError(
  911. f"Unknown output image format '{output_format}'"
  912. )
  913. def get_cache_key(self, image):
  914. vary_parts = []
  915. for operation in self.operations:
  916. for field in getattr(operation, "vary_fields", []):
  917. value = getattr(image, field, "")
  918. vary_parts.append(str(value))
  919. vary_string = "-".join(vary_parts)
  920. # Return blank string if there are no vary fields
  921. if not vary_string:
  922. return ""
  923. return hashlib.sha1(vary_string.encode("utf-8")).hexdigest()[:8]
  924. class ResponsiveImage:
  925. """
  926. A custom object used to represent a collection of renditions.
  927. Provides a 'renditions' property to access the renditions,
  928. and renders to the front-end HTML.
  929. """
  930. def __init__(
  931. self,
  932. renditions: dict[str, AbstractRendition],
  933. attrs: dict[str, Any] | None = None,
  934. ):
  935. self.renditions = list(renditions.values())
  936. self.attrs = attrs
  937. @classmethod
  938. def get_width_srcset(cls, renditions_list: list[AbstractRendition]):
  939. if len(renditions_list) == 1:
  940. # No point in using width descriptors if there is a single image.
  941. return renditions_list[0].url
  942. return ", ".join([f"{r.url} {r.width}w" for r in renditions_list])
  943. def __html__(self):
  944. attrs = self.attrs or {}
  945. # No point in adding a srcset if there is a single image.
  946. if len(self.renditions) > 1:
  947. attrs["srcset"] = self.get_width_srcset(self.renditions)
  948. # The first rendition is the "base" / "fallback" image.
  949. return self.renditions[0].img_tag(attrs)
  950. def __str__(self):
  951. return mark_safe(self.__html__())
  952. def __bool__(self):
  953. return bool(self.renditions)
  954. def __eq__(self, other: ResponsiveImage):
  955. if isinstance(other, ResponsiveImage):
  956. return self.renditions == other.renditions and self.attrs == other.attrs
  957. return False
  958. FileFormat = namedtuple("FileFormat", ["name", "mime_type"])
  959. class Picture(ResponsiveImage):
  960. # Keep this separate from FormatOperation.supported_formats,
  961. # as the order our formats are defined in is essential for the picture tag.
  962. # Defines the order of <source> elements in the tag when format operations
  963. # are in use, and the priority order to identify the "fallback" format.
  964. # The browser will pick the first supported format in this list.
  965. source_format_order = [
  966. FileFormat("avif", "image/avif"),
  967. FileFormat("webp", "image/webp"),
  968. FileFormat("jpeg", "image/jpeg"),
  969. FileFormat("png", "image/png"),
  970. FileFormat("gif", "image/gif"),
  971. ]
  972. def __init__(
  973. self,
  974. renditions: dict[str, AbstractRendition],
  975. attrs: dict[str, Any] | None = None,
  976. ):
  977. super().__init__(renditions, attrs)
  978. # Store renditions grouped by format separately for access from templates.
  979. self.formats = self.get_formats(renditions)
  980. def get_formats(
  981. self, renditions: dict[str, AbstractRendition]
  982. ) -> dict[str, list[AbstractRendition]]:
  983. """
  984. Group renditions by the format they are for, if any.
  985. If there is only one format, no grouping is required.
  986. """
  987. formats = defaultdict(list)
  988. for spec, rendition in renditions.items():
  989. for fmt in FormatOperation.supported_formats:
  990. # Identify the spec’s format (if any).
  991. if f"format-{fmt}" in spec:
  992. formats[fmt].append(rendition)
  993. break
  994. # Avoid the split by format if there is only one.
  995. if len(formats.keys()) < 2:
  996. return {}
  997. return formats
  998. def get_fallback_format(self):
  999. for fmt in reversed(self.source_format_order):
  1000. if fmt.name in self.formats:
  1001. return fmt.name
  1002. def __html__(self):
  1003. # If there aren’t multiple formats, render a vanilla img tag with srcset.
  1004. if not self.formats:
  1005. return mark_safe(f"<picture>{super().__html__()}</picture>")
  1006. attrs = self.attrs or {}
  1007. sizes = f'sizes="{attrs["sizes"]}" ' if "sizes" in attrs else ""
  1008. fallback_format = self.get_fallback_format()
  1009. fallback_renditions = self.formats[fallback_format]
  1010. sources = []
  1011. for fmt in self.source_format_order:
  1012. if fmt.name != fallback_format and fmt.name in self.formats:
  1013. srcset = self.get_width_srcset(self.formats[fmt.name])
  1014. mime = fmt.mime_type
  1015. sources.append(f'<source srcset="{srcset}" {sizes}type="{mime}">')
  1016. if len(fallback_renditions) > 1:
  1017. attrs["srcset"] = self.get_width_srcset(fallback_renditions)
  1018. # The first rendition is the "base" / "fallback" image.
  1019. fallback = fallback_renditions[0].img_tag(attrs)
  1020. return mark_safe(f"<picture>{''.join(sources)}{fallback}</picture>")
  1021. class AbstractRendition(ImageFileMixin, models.Model):
  1022. filter_spec = models.CharField(max_length=255, db_index=True)
  1023. """ Use local ImageField with Willow support. """
  1024. file = WagtailImageField(
  1025. upload_to=get_rendition_upload_to,
  1026. storage=get_rendition_storage,
  1027. width_field="width",
  1028. height_field="height",
  1029. )
  1030. width = models.IntegerField(editable=False)
  1031. height = models.IntegerField(editable=False)
  1032. focal_point_key = models.CharField(
  1033. max_length=16, blank=True, default="", editable=False
  1034. )
  1035. wagtail_reference_index_ignore = True
  1036. @property
  1037. def url(self):
  1038. return self.file.url
  1039. @property
  1040. def alt(self):
  1041. return self.image.default_alt_text
  1042. @property
  1043. def attrs(self):
  1044. """
  1045. The src, width, height, and alt attributes for an <img> tag, as a HTML
  1046. string
  1047. """
  1048. return flatatt(self.attrs_dict)
  1049. @property
  1050. def attrs_dict(self):
  1051. """
  1052. A dict of the src, width, height, and alt attributes for an <img> tag.
  1053. """
  1054. return OrderedDict(
  1055. [
  1056. ("src", self.url),
  1057. ("width", self.width),
  1058. ("height", self.height),
  1059. ("alt", self.alt),
  1060. ]
  1061. )
  1062. @property
  1063. def full_url(self):
  1064. url = self.url
  1065. if hasattr(settings, "WAGTAILADMIN_BASE_URL") and url.startswith("/"):
  1066. url = settings.WAGTAILADMIN_BASE_URL + url
  1067. return url
  1068. @property
  1069. def filter(self):
  1070. return Filter(self.filter_spec)
  1071. @cached_property
  1072. def focal_point(self):
  1073. image_focal_point = self.image.get_focal_point()
  1074. if image_focal_point:
  1075. transform = self.filter.get_transform(self.image)
  1076. return image_focal_point.transform(transform)
  1077. @property
  1078. def background_position_style(self):
  1079. """
  1080. Returns a `background-position` rule to be put in the inline style of an element which uses the rendition for its background.
  1081. This positions the rendition according to the value of the focal point. This is helpful for when the element does not have
  1082. the same aspect ratio as the rendition.
  1083. For example:
  1084. {% image page.image fill-1920x600 as image %}
  1085. <div style="background-image: url('{{ image.url }}'); {{ image.background_position_style }}">
  1086. </div>
  1087. """
  1088. focal_point = self.focal_point
  1089. if focal_point:
  1090. horz = int((focal_point.x * 100) // self.width)
  1091. vert = int((focal_point.y * 100) // self.height)
  1092. return f"background-position: {horz}% {vert}%;"
  1093. else:
  1094. return "background-position: 50% 50%;"
  1095. def img_tag(self, extra_attributes={}):
  1096. attrs = self.attrs_dict.copy()
  1097. attrs.update(apps.get_app_config("wagtailimages").default_attrs)
  1098. attrs.update(extra_attributes)
  1099. return mark_safe(f"<img{flatatt(attrs)}>")
  1100. def __html__(self):
  1101. return self.img_tag()
  1102. def get_upload_to(self, filename):
  1103. """
  1104. Generates a file path within the "images" folder by combining the folder name and the validated filename.
  1105. """
  1106. folder_name = "images"
  1107. filename = self.file.field.storage.get_valid_name(filename)
  1108. return os.path.join(folder_name, filename)
  1109. @classmethod
  1110. def check(cls, **kwargs):
  1111. errors = super().check(**kwargs)
  1112. if not cls._meta.abstract:
  1113. if not any(
  1114. set(constraint) == {"image", "filter_spec", "focal_point_key"}
  1115. for constraint in cls._meta.unique_together
  1116. ):
  1117. errors.append(
  1118. checks.Error(
  1119. "Custom rendition model %r has an invalid unique_together setting"
  1120. % cls,
  1121. hint="Custom rendition models must include the constraint "
  1122. "('image', 'filter_spec', 'focal_point_key') in their unique_together definition.",
  1123. obj=cls,
  1124. id="wagtailimages.E001",
  1125. )
  1126. )
  1127. return errors
  1128. @staticmethod
  1129. def construct_cache_key(image, filter_cache_key, filter_spec):
  1130. return "wagtail-rendition-" + "-".join(
  1131. [str(image.id), image.file_hash, filter_cache_key, filter_spec]
  1132. )
  1133. @classproperty
  1134. def cache_backend(cls) -> BaseCache:
  1135. try:
  1136. return caches["renditions"]
  1137. except InvalidCacheBackendError:
  1138. return caches[DEFAULT_CACHE_ALIAS]
  1139. def get_cache_key(self):
  1140. return self.construct_cache_key(
  1141. self.image, self.focal_point_key, self.filter_spec
  1142. )
  1143. def purge_from_cache(self):
  1144. self.cache_backend.delete(self.get_cache_key())
  1145. class Meta:
  1146. abstract = True
  1147. class Rendition(AbstractRendition):
  1148. image = models.ForeignKey(
  1149. Image, related_name="renditions", on_delete=models.CASCADE
  1150. )
  1151. class Meta:
  1152. unique_together = (("image", "filter_spec", "focal_point_key"),)