pillow-avif-plugin 1.4.2__tar.gz → 1.4.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pillow-avif-plugin might be problematic. Click here for more details.

Files changed (18) hide show
  1. {pillow-avif-plugin-1.4.2 → pillow-avif-plugin-1.4.4}/PKG-INFO +2 -5
  2. {pillow-avif-plugin-1.4.2 → pillow-avif-plugin-1.4.4}/setup.py +14 -1
  3. pillow-avif-plugin-1.4.4/src/pillow_avif/AvifImagePlugin.py +282 -0
  4. pillow-avif-plugin-1.4.2/src/pillow_avif/AvifImagePlugin.py → pillow-avif-plugin-1.4.4/src/pillow_avif/AvifImagePlugin.py.orig +8 -1
  5. {pillow-avif-plugin-1.4.2 → pillow-avif-plugin-1.4.4}/src/pillow_avif/__init__.py +1 -1
  6. {pillow-avif-plugin-1.4.2 → pillow-avif-plugin-1.4.4}/src/pillow_avif/_avif.c +28 -7
  7. {pillow-avif-plugin-1.4.2 → pillow-avif-plugin-1.4.4}/src/pillow_avif_plugin.egg-info/PKG-INFO +2 -5
  8. {pillow-avif-plugin-1.4.2 → pillow-avif-plugin-1.4.4}/src/pillow_avif_plugin.egg-info/SOURCES.txt +4 -2
  9. pillow-avif-plugin-1.4.4/tests/test_file_avif.py +783 -0
  10. {pillow-avif-plugin-1.4.2 → pillow-avif-plugin-1.4.4}/LICENSE +0 -0
  11. {pillow-avif-plugin-1.4.2 → pillow-avif-plugin-1.4.4}/MANIFEST.in +0 -0
  12. {pillow-avif-plugin-1.4.2 → pillow-avif-plugin-1.4.4}/README.md +0 -0
  13. {pillow-avif-plugin-1.4.2 → pillow-avif-plugin-1.4.4}/pyproject.toml +0 -0
  14. {pillow-avif-plugin-1.4.2 → pillow-avif-plugin-1.4.4}/setup.cfg +0 -0
  15. {pillow-avif-plugin-1.4.2 → pillow-avif-plugin-1.4.4}/src/pillow_avif_plugin.egg-info/dependency_links.txt +0 -0
  16. {pillow-avif-plugin-1.4.2 → pillow-avif-plugin-1.4.4}/src/pillow_avif_plugin.egg-info/top_level.txt +0 -0
  17. /pillow-avif-plugin-1.4.2/src/pillow_avif_plugin.egg-info/not-zip-safe → /pillow-avif-plugin-1.4.4/src/pillow_avif_plugin.egg-info/zip-safe +0 -0
  18. {pillow-avif-plugin-1.4.2 → pillow-avif-plugin-1.4.4}/tox.ini +0 -0
@@ -1,13 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pillow-avif-plugin
3
- Version: 1.4.2
3
+ Version: 1.4.4
4
4
  Summary: A pillow plugin that adds avif support via libavif
5
5
  Home-page: https://github.com/fdintino/pillow-avif-plugin/
6
+ Download-URL: https://github.com/fdintino/pillow-avif-plugin/releases
6
7
  Author: Frankie Dintino
7
8
  Author-email: fdintino@theatlantic.com
8
9
  License: MIT License
9
- Download-URL: https://github.com/fdintino/pillow-avif-plugin/releases
10
- Platform: UNKNOWN
11
10
  Classifier: Development Status :: 5 - Production/Stable
12
11
  Classifier: Environment :: Web Environment
13
12
  Classifier: Intended Audience :: Developers
@@ -35,5 +34,3 @@ License-File: LICENSE
35
34
  This is a plugin that adds support for AVIF files until official support has been added (see [this pull request](https://github.com/python-pillow/Pillow/pull/5201)).
36
35
 
37
36
  To register this plugin with pillow you will need to add `import pillow_avif` somewhere in your application.
38
-
39
-
@@ -28,6 +28,19 @@ def readme():
28
28
  IS_DEBUG = hasattr(sys, "gettotalrefcount")
29
29
  PLATFORM_MINGW = os.name == "nt" and "GCC" in sys.version
30
30
 
31
+ libraries = ["avif"]
32
+ if sys.platform == "win32":
33
+ libraries.extend(
34
+ [
35
+ "advapi32",
36
+ "bcrypt",
37
+ "ntdll",
38
+ "userenv",
39
+ "ws2_32",
40
+ "kernel32",
41
+ ]
42
+ )
43
+
31
44
  setup(
32
45
  name="pillow-avif-plugin",
33
46
  description="A pillow plugin that adds avif support via libavif",
@@ -39,7 +52,7 @@ setup(
39
52
  "pillow_avif._avif",
40
53
  ["src/pillow_avif/_avif.c"],
41
54
  depends=["avif/avif.h"],
42
- libraries=["avif"],
55
+ libraries=libraries,
43
56
  ),
44
57
  ],
45
58
  package_data={"": ["README.rst"]},
@@ -0,0 +1,282 @@
1
+ from __future__ import division
2
+
3
+ from io import BytesIO
4
+ import sys
5
+
6
+ from PIL import ExifTags, Image, ImageFile
7
+
8
+ try:
9
+ from pillow_avif import _avif
10
+
11
+ SUPPORTED = True
12
+ except ImportError:
13
+ SUPPORTED = False
14
+
15
+ # Decoder options as module globals, until there is a way to pass parameters
16
+ # to Image.open (see https://github.com/python-pillow/Pillow/issues/569)
17
+ DECODE_CODEC_CHOICE = "auto"
18
+ CHROMA_UPSAMPLING = "auto"
19
+ DEFAULT_MAX_THREADS = 0
20
+
21
+ _VALID_AVIF_MODES = {"RGB", "RGBA"}
22
+
23
+
24
+ if sys.version_info[0] == 2:
25
+ text_type = unicode # noqa
26
+ else:
27
+ text_type = str
28
+
29
+
30
+ def _accept(prefix):
31
+ if prefix[4:8] != b"ftyp":
32
+ return
33
+ coding_brands = (b"avif", b"avis")
34
+ container_brands = (b"mif1", b"msf1")
35
+ major_brand = prefix[8:12]
36
+ if major_brand in coding_brands:
37
+ if not SUPPORTED:
38
+ return (
39
+ "image file could not be identified because AVIF "
40
+ "support not installed"
41
+ )
42
+ return True
43
+ if major_brand in container_brands:
44
+ # We accept files with AVIF container brands; we can't yet know if
45
+ # the ftyp box has the correct compatible brands, but if it doesn't
46
+ # then the plugin will raise a SyntaxError which Pillow will catch
47
+ # before moving on to the next plugin that accepts the file.
48
+ #
49
+ # Also, because this file might not actually be an AVIF file, we
50
+ # don't raise an error if AVIF support isn't properly compiled.
51
+ return True
52
+
53
+
54
+ class AvifImageFile(ImageFile.ImageFile):
55
+ format = "AVIF"
56
+ format_description = "AVIF image"
57
+ __loaded = -1
58
+ __frame = 0
59
+
60
+ def load_seek(self, pos):
61
+ pass
62
+
63
+ def _open(self):
64
+ self._decoder = _avif.AvifDecoder(
65
+ self.fp.read(), DECODE_CODEC_CHOICE, CHROMA_UPSAMPLING, DEFAULT_MAX_THREADS
66
+ )
67
+
68
+ # Get info from decoder
69
+ width, height, n_frames, mode, icc, exif, xmp = self._decoder.get_info()
70
+ self._size = width, height
71
+ self.n_frames = n_frames
72
+ self.is_animated = self.n_frames > 1
73
+ try:
74
+ self.mode = self.rawmode = mode
75
+ except AttributeError:
76
+ self._mode = self.rawmode = mode
77
+ self.tile = []
78
+
79
+ if icc:
80
+ self.info["icc_profile"] = icc
81
+ if exif:
82
+ self.info["exif"] = exif
83
+ if xmp:
84
+ self.info["xmp"] = xmp
85
+
86
+ def seek(self, frame):
87
+ if not self._seek_check(frame):
88
+ return
89
+
90
+ self.__frame = frame
91
+
92
+ def load(self):
93
+ if self.__loaded != self.__frame:
94
+ # We need to load the image data for this frame
95
+ data, timescale, tsp_in_ts, dur_in_ts = self._decoder.get_frame(
96
+ self.__frame
97
+ )
98
+ timestamp = round(1000 * (tsp_in_ts / timescale))
99
+ duration = round(1000 * (dur_in_ts / timescale))
100
+ self.info["timestamp"] = timestamp
101
+ self.info["duration"] = duration
102
+ self.__loaded = self.__frame
103
+
104
+ # Set tile
105
+ if self.fp and self._exclusive_fp:
106
+ self.fp.close()
107
+ self.fp = BytesIO(data)
108
+ self.tile = [("raw", (0, 0) + self.size, 0, self.rawmode)]
109
+
110
+ return super(AvifImageFile, self).load()
111
+
112
+ def tell(self):
113
+ return self.__frame
114
+
115
+
116
+ def _save_all(im, fp, filename):
117
+ _save(im, fp, filename, save_all=True)
118
+
119
+
120
+ def _save(im, fp, filename, save_all=False):
121
+ info = im.encoderinfo.copy()
122
+ if save_all:
123
+ append_images = list(info.get("append_images", []))
124
+ else:
125
+ append_images = []
126
+
127
+ total = 0
128
+ for ims in [im] + append_images:
129
+ total += getattr(ims, "n_frames", 1)
130
+
131
+ is_single_frame = total == 1
132
+
133
+ qmin = info.get("qmin", -1)
134
+ qmax = info.get("qmax", -1)
135
+ quality = info.get("quality", 75)
136
+ if not isinstance(quality, int) or quality < 0 or quality > 100:
137
+ raise ValueError("Invalid quality setting")
138
+
139
+ duration = info.get("duration", 0)
140
+ subsampling = info.get("subsampling", "4:2:0")
141
+ speed = info.get("speed", 6)
142
+ max_threads = info.get("max_threads", DEFAULT_MAX_THREADS)
143
+ codec = info.get("codec", "auto")
144
+ range_ = info.get("range", "full")
145
+ tile_rows_log2 = info.get("tile_rows", 0)
146
+ tile_cols_log2 = info.get("tile_cols", 0)
147
+ alpha_premultiplied = bool(info.get("alpha_premultiplied", False))
148
+ autotiling = bool(info.get("autotiling", tile_rows_log2 == tile_cols_log2 == 0))
149
+
150
+ icc_profile = info.get("icc_profile", im.info.get("icc_profile"))
151
+ exif = info.get("exif", im.info.get("exif"))
152
+ if isinstance(exif, Image.Exif):
153
+ exif = exif.tobytes()
154
+
155
+ exif_orientation = 0
156
+ if exif:
157
+ exif_data = Image.Exif()
158
+ try:
159
+ exif_data.load(exif)
160
+ except SyntaxError:
161
+ pass
162
+ else:
163
+ orientation_tag = next(
164
+ k for k, v in ExifTags.TAGS.items() if v == "Orientation"
165
+ )
166
+ exif_orientation = exif_data.get(orientation_tag) or 0
167
+
168
+ xmp = info.get("xmp", im.info.get("xmp") or im.info.get("XML:com.adobe.xmp"))
169
+
170
+ if isinstance(xmp, text_type):
171
+ xmp = xmp.encode("utf-8")
172
+
173
+ advanced = info.get("advanced")
174
+ if isinstance(advanced, dict):
175
+ advanced = tuple([k, v] for (k, v) in advanced.items())
176
+ if advanced is not None:
177
+ try:
178
+ advanced = tuple(advanced)
179
+ except TypeError:
180
+ invalid = True
181
+ else:
182
+ invalid = all(isinstance(v, tuple) and len(v) == 2 for v in advanced)
183
+ if invalid:
184
+ raise ValueError(
185
+ "advanced codec options must be a dict of key-value string "
186
+ "pairs or a series of key-value two-tuples"
187
+ )
188
+ advanced = tuple(
189
+ [(str(k).encode("utf-8"), str(v).encode("utf-8")) for k, v in advanced]
190
+ )
191
+
192
+ # Setup the AVIF encoder
193
+ enc = _avif.AvifEncoder(
194
+ im.size[0],
195
+ im.size[1],
196
+ subsampling,
197
+ qmin,
198
+ qmax,
199
+ quality,
200
+ speed,
201
+ max_threads,
202
+ codec,
203
+ range_,
204
+ tile_rows_log2,
205
+ tile_cols_log2,
206
+ alpha_premultiplied,
207
+ autotiling,
208
+ icc_profile or b"",
209
+ exif or b"",
210
+ exif_orientation,
211
+ xmp or b"",
212
+ advanced,
213
+ )
214
+
215
+ # Add each frame
216
+ frame_idx = 0
217
+ frame_dur = 0
218
+ cur_idx = im.tell()
219
+ try:
220
+ for ims in [im] + append_images:
221
+ # Get # of frames in this image
222
+ nfr = getattr(ims, "n_frames", 1)
223
+
224
+ for idx in range(nfr):
225
+ ims.seek(idx)
226
+ ims.load()
227
+
228
+ # Make sure image mode is supported
229
+ frame = ims
230
+ rawmode = ims.mode
231
+ if ims.mode not in _VALID_AVIF_MODES:
232
+ alpha = (
233
+ "A" in ims.mode
234
+ or "a" in ims.mode
235
+ or (ims.mode == "P" and "A" in ims.im.getpalettemode())
236
+ or (
237
+ ims.mode == "P"
238
+ and ims.info.get("transparency", None) is not None
239
+ )
240
+ )
241
+ rawmode = "RGBA" if alpha else "RGB"
242
+ frame = ims.convert(rawmode)
243
+
244
+ # Update frame duration
245
+ if isinstance(duration, (list, tuple)):
246
+ frame_dur = duration[frame_idx]
247
+ else:
248
+ frame_dur = duration
249
+
250
+ # Append the frame to the animation encoder
251
+ enc.add(
252
+ frame.tobytes("raw", rawmode),
253
+ frame_dur,
254
+ frame.size[0],
255
+ frame.size[1],
256
+ rawmode,
257
+ is_single_frame,
258
+ )
259
+
260
+ # Update frame index
261
+ frame_idx += 1
262
+
263
+ if not save_all:
264
+ break
265
+
266
+ finally:
267
+ im.seek(cur_idx)
268
+
269
+ # Get the final output from the encoder
270
+ data = enc.finish()
271
+ if data is None:
272
+ raise OSError("cannot write file as AVIF (encoder returned None)")
273
+
274
+ fp.write(data)
275
+
276
+
277
+ Image.register_open(AvifImageFile.format, AvifImageFile, _accept)
278
+ if SUPPORTED:
279
+ Image.register_save(AvifImageFile.format, _save)
280
+ Image.register_save_all(AvifImageFile.format, _save_all)
281
+ Image.register_extensions(AvifImageFile.format, [".avif", ".avifs"])
282
+ Image.register_mime(AvifImageFile.format, "image/avif")
@@ -16,6 +16,11 @@ except ImportError:
16
16
  # to Image.open (see https://github.com/python-pillow/Pillow/issues/569)
17
17
  DECODE_CODEC_CHOICE = "auto"
18
18
  CHROMA_UPSAMPLING = "auto"
19
+ <<<<<<< HEAD
20
+ DEFAULT_MAX_THREADS = 0
21
+ =======
22
+ DECODE_MAX_THREADS = 0
23
+ >>>>>>> 1e2fa65 (Let users pass max_threads manually as an argument)
19
24
 
20
25
  _VALID_AVIF_MODES = {"RGB", "RGBA"}
21
26
 
@@ -61,7 +66,7 @@ class AvifImageFile(ImageFile.ImageFile):
61
66
 
62
67
  def _open(self):
63
68
  self._decoder = _avif.AvifDecoder(
64
- self.fp.read(), DECODE_CODEC_CHOICE, CHROMA_UPSAMPLING
69
+ self.fp.read(), DECODE_CODEC_CHOICE, CHROMA_UPSAMPLING, DECODE_MAX_THREADS
65
70
  )
66
71
 
67
72
  # Get info from decoder
@@ -138,6 +143,7 @@ def _save(im, fp, filename, save_all=False):
138
143
  duration = info.get("duration", 0)
139
144
  subsampling = info.get("subsampling", "4:2:0")
140
145
  speed = info.get("speed", 6)
146
+ max_threads = info.get("max_threads", DEFAULT_MAX_THREADS)
141
147
  codec = info.get("codec", "auto")
142
148
  range_ = info.get("range", "full")
143
149
  tile_rows_log2 = info.get("tile_rows", 0)
@@ -196,6 +202,7 @@ def _save(im, fp, filename, save_all=False):
196
202
  qmax,
197
203
  quality,
198
204
  speed,
205
+ max_threads,
199
206
  codec,
200
207
  range_,
201
208
  tile_rows_log2,
@@ -2,4 +2,4 @@ from . import AvifImagePlugin
2
2
 
3
3
 
4
4
  __all__ = ["AvifImagePlugin"]
5
- __version__ = "1.4.2"
5
+ __version__ = "1.4.4"
@@ -46,7 +46,7 @@ typedef struct {
46
46
 
47
47
  static PyTypeObject AvifDecoder_Type;
48
48
 
49
- static int max_threads = 0;
49
+ static int default_max_threads = 0;
50
50
 
51
51
  static void
52
52
  init_max_threads(void) {
@@ -85,7 +85,7 @@ init_max_threads(void) {
85
85
  goto error;
86
86
  }
87
87
 
88
- max_threads = (int)num_cpus;
88
+ default_max_threads = (int)num_cpus;
89
89
 
90
90
  done:
91
91
  Py_XDECREF(os);
@@ -321,6 +321,7 @@ AvifEncoderNew(PyObject *self_, PyObject *args) {
321
321
  int quality = 75;
322
322
  int speed = 8;
323
323
  int exif_orientation = 0;
324
+ int max_threads = default_max_threads;
324
325
  PyObject *icc_bytes;
325
326
  PyObject *exif_bytes;
326
327
  PyObject *xmp_bytes;
@@ -336,7 +337,7 @@ AvifEncoderNew(PyObject *self_, PyObject *args) {
336
337
 
337
338
  if (!PyArg_ParseTuple(
338
339
  args,
339
- "IIsiiiissiiOOSSiSO",
340
+ "IIsiiiiissiiOOSSiSO",
340
341
  &width,
341
342
  &height,
342
343
  &subsampling,
@@ -344,6 +345,7 @@ AvifEncoderNew(PyObject *self_, PyObject *args) {
344
345
  &qmax,
345
346
  &quality,
346
347
  &speed,
348
+ &max_threads,
347
349
  &codec,
348
350
  &range,
349
351
  &tile_rows_log2,
@@ -445,10 +447,17 @@ AvifEncoderNew(PyObject *self_, PyObject *args) {
445
447
  encoder = avifEncoderCreate();
446
448
 
447
449
  if (max_threads == 0) {
448
- init_max_threads();
450
+ if (default_max_threads == 0) {
451
+ init_max_threads();
452
+ }
453
+ max_threads = default_max_threads;
449
454
  }
450
455
 
451
- encoder->maxThreads = max_threads;
456
+ int is_aom_encode = strcmp(codec, "aom") == 0 ||
457
+ (strcmp(codec, "auto") == 0 &&
458
+ _codec_available("aom", AVIF_CODEC_FLAG_CAN_ENCODE));
459
+
460
+ encoder->maxThreads = is_aom_encode && max_threads > 64 ? 64 : max_threads;
452
461
  #if AVIF_VERSION >= 1000000
453
462
  if (enc_options.qmin != -1 && enc_options.qmax != -1) {
454
463
  encoder->minQuantizer = enc_options.qmin;
@@ -722,10 +731,11 @@ AvifDecoderNew(PyObject *self_, PyObject *args) {
722
731
  char *codec_str;
723
732
  avifCodecChoice codec;
724
733
  avifChromaUpsampling upsampling;
734
+ int max_threads = 0;
725
735
 
726
736
  avifResult result;
727
737
 
728
- if (!PyArg_ParseTuple(args, "Sss", &avif_bytes, &codec_str, &upsampling_str)) {
738
+ if (!PyArg_ParseTuple(args, "Sssi", &avif_bytes, &codec_str, &upsampling_str, &max_threads)) {
729
739
  return NULL;
730
740
  }
731
741
 
@@ -774,9 +784,20 @@ AvifDecoderNew(PyObject *self_, PyObject *args) {
774
784
  self->decoder = avifDecoderCreate();
775
785
  #if AVIF_VERSION >= 80400
776
786
  if (max_threads == 0) {
777
- init_max_threads();
787
+ if (default_max_threads == 0) {
788
+ init_max_threads();
789
+ }
790
+ max_threads = default_max_threads;
778
791
  }
779
792
  self->decoder->maxThreads = max_threads;
793
+ #endif
794
+ #if AVIF_VERSION >= 90200
795
+ // Turn off libavif's 'clap' (clean aperture) property validation.
796
+ self->decoder->strictFlags &= ~AVIF_STRICT_CLAP_VALID;
797
+ // Allow the PixelInformationProperty ('pixi') to be missing in AV1 image
798
+ // items. libheif v1.11.0 and older does not add the 'pixi' item property to
799
+ // AV1 image items.
800
+ self->decoder->strictFlags &= ~AVIF_STRICT_PIXI_REQUIRED;
780
801
  #endif
781
802
  self->decoder->codecChoice = codec;
782
803
 
@@ -1,13 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pillow-avif-plugin
3
- Version: 1.4.2
3
+ Version: 1.4.4
4
4
  Summary: A pillow plugin that adds avif support via libavif
5
5
  Home-page: https://github.com/fdintino/pillow-avif-plugin/
6
+ Download-URL: https://github.com/fdintino/pillow-avif-plugin/releases
6
7
  Author: Frankie Dintino
7
8
  Author-email: fdintino@theatlantic.com
8
9
  License: MIT License
9
- Download-URL: https://github.com/fdintino/pillow-avif-plugin/releases
10
- Platform: UNKNOWN
11
10
  Classifier: Development Status :: 5 - Production/Stable
12
11
  Classifier: Environment :: Web Environment
13
12
  Classifier: Intended Audience :: Developers
@@ -35,5 +34,3 @@ License-File: LICENSE
35
34
  This is a plugin that adds support for AVIF files until official support has been added (see [this pull request](https://github.com/python-pillow/Pillow/pull/5201)).
36
35
 
37
36
  To register this plugin with pillow you will need to add `import pillow_avif` somewhere in your application.
38
-
39
-
@@ -6,10 +6,12 @@ setup.cfg
6
6
  setup.py
7
7
  tox.ini
8
8
  src/pillow_avif/AvifImagePlugin.py
9
+ src/pillow_avif/AvifImagePlugin.py.orig
9
10
  src/pillow_avif/__init__.py
10
11
  src/pillow_avif/_avif.c
11
12
  src/pillow_avif_plugin.egg-info/PKG-INFO
12
13
  src/pillow_avif_plugin.egg-info/SOURCES.txt
13
14
  src/pillow_avif_plugin.egg-info/dependency_links.txt
14
- src/pillow_avif_plugin.egg-info/not-zip-safe
15
- src/pillow_avif_plugin.egg-info/top_level.txt
15
+ src/pillow_avif_plugin.egg-info/top_level.txt
16
+ src/pillow_avif_plugin.egg-info/zip-safe
17
+ tests/test_file_avif.py
@@ -0,0 +1,783 @@
1
+ import os
2
+ import xml.etree.ElementTree
3
+ from contextlib import contextmanager
4
+ from io import BytesIO
5
+ import warnings
6
+
7
+ try:
8
+ from os import cpu_count
9
+ except ImportError:
10
+ from multiprocessing import cpu_count
11
+
12
+ import pytest
13
+
14
+ from PIL import Image, ImageDraw
15
+ from pillow_avif import AvifImagePlugin
16
+
17
+ from .helper import (
18
+ PillowLeakTestCase,
19
+ assert_image,
20
+ assert_image_similar,
21
+ assert_image_similar_tofile,
22
+ hopper,
23
+ has_alpha_premultiplied,
24
+ )
25
+
26
+ from pillow_avif import _avif
27
+
28
+ try:
29
+ from PIL import UnidentifiedImageError
30
+ except ImportError:
31
+ UnidentifiedImageError = None
32
+
33
+
34
+ CURR_DIR = os.path.dirname(os.path.dirname(__file__))
35
+ TEST_AVIF_FILE = "%s/tests/images/hopper.avif" % CURR_DIR
36
+
37
+
38
+ def assert_xmp_orientation(xmp, expected):
39
+ assert isinstance(xmp, bytes)
40
+ root = xml.etree.ElementTree.fromstring(xmp)
41
+ orientation = None
42
+ for elem in root.iter():
43
+ if elem.tag.endswith("}Description"):
44
+ orientation = elem.attrib.get("{http://ns.adobe.com/tiff/1.0/}Orientation")
45
+ if orientation:
46
+ orientation = int(orientation)
47
+ break
48
+ assert orientation == expected
49
+
50
+
51
+ def roundtrip(im, **options):
52
+ out = BytesIO()
53
+ im.save(out, "AVIF", **options)
54
+ out.seek(0)
55
+ return Image.open(out)
56
+
57
+
58
+ def skip_unless_avif_decoder(codec_name):
59
+ reason = "%s decode not available" % codec_name
60
+ return pytest.mark.skipif(
61
+ not _avif or not _avif.decoder_codec_available(codec_name), reason=reason
62
+ )
63
+
64
+
65
+ def skip_unless_avif_encoder(codec_name):
66
+ reason = "%s encode not available" % codec_name
67
+ return pytest.mark.skipif(
68
+ not _avif or not _avif.encoder_codec_available(codec_name), reason=reason
69
+ )
70
+
71
+
72
+ def is_docker_qemu():
73
+ try:
74
+ init_proc_exe = os.readlink("/proc/1/exe")
75
+ except: # noqa: E722
76
+ return False
77
+ else:
78
+ return "qemu" in init_proc_exe
79
+
80
+
81
+ def skip_unless_avif_version_gte(version):
82
+ if not _avif:
83
+ reason = "AVIF unavailable"
84
+ should_skip = True
85
+ else:
86
+ version_str = ".".join([str(v) for v in version])
87
+ reason = "%s < %s" % (_avif.libavif_version, version_str)
88
+ should_skip = _avif.VERSION < version
89
+ return pytest.mark.skipif(should_skip, reason=reason)
90
+
91
+
92
+ def skip_unless_avif_version_lt(version):
93
+ if not _avif:
94
+ reason = "AVIF unavailable"
95
+ should_skip = True
96
+ else:
97
+ version_str = ".".join([str(v) for v in version])
98
+ reason = "%s > %s" % (_avif.libavif_version, version_str)
99
+ should_skip = _avif.VERSION >= version
100
+ return pytest.mark.skipif(should_skip, reason=reason)
101
+
102
+
103
+ class TestUnsupportedAvif:
104
+ def test_unsupported(self):
105
+ AvifImagePlugin.SUPPORTED = False
106
+
107
+ try:
108
+ file_path = "%s/tests/images/hopper.avif" % CURR_DIR
109
+ if UnidentifiedImageError:
110
+ pytest.warns(
111
+ UserWarning,
112
+ lambda: pytest.raises(
113
+ UnidentifiedImageError, Image.open, file_path
114
+ ),
115
+ )
116
+ else:
117
+ with pytest.raises(IOError):
118
+ Image.open(file_path)
119
+ finally:
120
+ AvifImagePlugin.SUPPORTED = True
121
+
122
+
123
+ class TestFileAvif:
124
+ def test_version(self):
125
+ _avif.AvifCodecVersions()
126
+
127
+ def test_read(self):
128
+ """
129
+ Can we read an AVIF file without error?
130
+ Does it have the bits we expect?
131
+ """
132
+
133
+ with Image.open("%s/tests/images/hopper.avif" % CURR_DIR) as image:
134
+ assert image.mode == "RGB"
135
+ assert image.size == (128, 128)
136
+ assert image.format == "AVIF"
137
+ assert image.get_format_mimetype() == "image/avif"
138
+ image.load()
139
+ image.getdata()
140
+
141
+ # generated with:
142
+ # avifdec hopper.avif hopper_avif_write.png
143
+ assert_image_similar_tofile(
144
+ image, "%s/tests/images/hopper_avif_write.png" % CURR_DIR, 12.0
145
+ )
146
+
147
+ def _roundtrip(self, tmp_path, mode, epsilon, args={}):
148
+ temp_file = str(tmp_path / "temp.avif")
149
+
150
+ hopper(mode).save(temp_file, **args)
151
+ with Image.open(temp_file) as image:
152
+ assert image.mode == "RGB"
153
+ assert image.size == (128, 128)
154
+ assert image.format == "AVIF"
155
+ image.load()
156
+ image.getdata()
157
+
158
+ if mode == "RGB":
159
+ # avifdec hopper.avif avif/hopper_avif_write.png
160
+ assert_image_similar_tofile(
161
+ image, "%s/tests/images/hopper_avif_write.png" % CURR_DIR, 12.0
162
+ )
163
+
164
+ # This test asserts that the images are similar. If the average pixel
165
+ # difference between the two images is less than the epsilon value,
166
+ # then we're going to accept that it's a reasonable lossy version of
167
+ # the image.
168
+ target = hopper(mode)
169
+ if mode != "RGB":
170
+ target = target.convert("RGB")
171
+ assert_image_similar(image, target, epsilon)
172
+
173
+ def test_write_rgb(self, tmp_path):
174
+ """
175
+ Can we write a RGB mode file to avif without error?
176
+ Does it have the bits we expect?
177
+ """
178
+
179
+ self._roundtrip(tmp_path, "RGB", 12.5)
180
+
181
+ def test_AvifEncoder_with_invalid_args(self):
182
+ """
183
+ Calling encoder functions with no arguments should result in an error.
184
+ """
185
+ with pytest.raises(TypeError):
186
+ _avif.AvifEncoder()
187
+
188
+ def test_AvifDecoder_with_invalid_args(self):
189
+ """
190
+ Calling decoder functions with no arguments should result in an error.
191
+ """
192
+ with pytest.raises(TypeError):
193
+ _avif.AvifDecoder()
194
+
195
+ @pytest.mark.parametrize("major_brand", [b"avif", b"avis", b"mif1", b"msf1"])
196
+ def test_accept_ftyp_brands(self, major_brand):
197
+ data = b"\x00\x00\x00\x1cftyp%s\x00\x00\x00\x00" % major_brand
198
+ assert AvifImagePlugin._accept(data) is True
199
+
200
+ def test_no_resource_warning(self, tmp_path):
201
+ with Image.open(TEST_AVIF_FILE) as image:
202
+ temp_file = str(tmp_path / "temp.avif")
203
+ with warnings.catch_warnings():
204
+ warnings.simplefilter("error")
205
+ image.save(temp_file)
206
+
207
+ def test_file_pointer_could_be_reused(self):
208
+ with open(TEST_AVIF_FILE, "rb") as blob:
209
+ Image.open(blob).load()
210
+ Image.open(blob).load()
211
+
212
+ def test_background_from_gif(self, tmp_path):
213
+ with Image.open("%s/tests/images/chi.gif" % CURR_DIR) as im:
214
+ original_value = im.convert("RGB").getpixel((1, 1))
215
+
216
+ # Save as AVIF
217
+ out_avif = str(tmp_path / "temp.avif")
218
+ im.save(out_avif, save_all=True)
219
+
220
+ # Save as GIF
221
+ out_gif = str(tmp_path / "temp.gif")
222
+ Image.open(out_avif).save(out_gif)
223
+
224
+ with Image.open(out_gif) as reread:
225
+ reread_value = reread.convert("RGB").getpixel((1, 1))
226
+ difference = sum(
227
+ [abs(original_value[i] - reread_value[i]) for i in range(0, 3)]
228
+ )
229
+ assert difference < 5
230
+
231
+ def test_save_single_frame(self, tmp_path):
232
+ temp_file = str(tmp_path / "temp.avif")
233
+ with Image.open("%s/tests/images/chi.gif" % CURR_DIR) as im:
234
+ # Save as AVIF
235
+ im.save(temp_file)
236
+ with Image.open(temp_file) as im:
237
+ assert im.n_frames == 1
238
+
239
+ def test_invalid_file(self):
240
+ invalid_file = "tests/images/flower.jpg"
241
+
242
+ with pytest.raises(SyntaxError):
243
+ AvifImagePlugin.AvifImageFile(invalid_file)
244
+
245
+ def test_load_transparent_rgb(self):
246
+ test_file = "tests/images/transparency.avif"
247
+ with Image.open(test_file) as im:
248
+ assert_image(im, "RGBA", (64, 64))
249
+
250
+ # image has 876 transparent pixels
251
+ assert im.getchannel("A").getcolors()[0][0] == 876
252
+
253
+ def test_save_transparent(self, tmp_path):
254
+ im = Image.new("RGBA", (10, 10), (0, 0, 0, 0))
255
+ assert im.getcolors() == [(100, (0, 0, 0, 0))]
256
+
257
+ test_file = str(tmp_path / "temp.avif")
258
+ im.save(test_file)
259
+
260
+ # check if saved image contains same transparency
261
+ with Image.open(test_file) as im:
262
+ assert_image(im, "RGBA", (10, 10))
263
+ assert im.getcolors() == [(100, (0, 0, 0, 0))]
264
+
265
+ def test_save_icc_profile(self):
266
+ with Image.open("tests/images/icc_profile_none.avif") as im:
267
+ assert im.info.get("icc_profile") is None
268
+
269
+ with Image.open("tests/images/icc_profile.avif") as with_icc:
270
+ expected_icc = with_icc.info.get("icc_profile")
271
+ assert expected_icc is not None
272
+
273
+ im = roundtrip(im, icc_profile=expected_icc)
274
+ assert im.info["icc_profile"] == expected_icc
275
+
276
+ def test_discard_icc_profile(self):
277
+ with Image.open("tests/images/icc_profile.avif") as im:
278
+ im = roundtrip(im, icc_profile=None)
279
+ assert "icc_profile" not in im.info
280
+
281
+ def test_roundtrip_icc_profile(self):
282
+ with Image.open("tests/images/icc_profile.avif") as im:
283
+ expected_icc = im.info["icc_profile"]
284
+
285
+ im = roundtrip(im)
286
+ assert im.info["icc_profile"] == expected_icc
287
+
288
+ def test_roundtrip_no_icc_profile(self):
289
+ with Image.open("tests/images/icc_profile_none.avif") as im:
290
+ assert im.info.get("icc_profile") is None
291
+
292
+ im = roundtrip(im)
293
+ assert "icc_profile" not in im.info
294
+
295
+ def test_exif(self):
296
+ # With an EXIF chunk
297
+ with Image.open("tests/images/exif.avif") as im:
298
+ exif = im.getexif()
299
+ assert exif[274] == 1
300
+
301
+ def test_exif_save(self, tmp_path):
302
+ with Image.open("tests/images/exif.avif") as im:
303
+ test_file = str(tmp_path / "temp.avif")
304
+ im.save(test_file)
305
+
306
+ with Image.open(test_file) as reloaded:
307
+ exif = reloaded.getexif()
308
+ assert exif[274] == 1
309
+
310
+ def test_exif_obj_argument(self, tmp_path):
311
+ exif = Image.Exif()
312
+ exif[274] = 1
313
+ exif_data = exif.tobytes()
314
+ with Image.open(TEST_AVIF_FILE) as im:
315
+ test_file = str(tmp_path / "temp.avif")
316
+ im.save(test_file, exif=exif)
317
+
318
+ with Image.open(test_file) as reloaded:
319
+ assert reloaded.info["exif"] == exif_data
320
+
321
+ def test_exif_bytes_argument(self, tmp_path):
322
+ exif = Image.Exif()
323
+ exif[274] = 1
324
+ exif_data = exif.tobytes()
325
+ with Image.open(TEST_AVIF_FILE) as im:
326
+ test_file = str(tmp_path / "temp.avif")
327
+ im.save(test_file, exif=exif_data)
328
+
329
+ with Image.open(test_file) as reloaded:
330
+ assert reloaded.info["exif"] == exif_data
331
+
332
+ def test_exif_invalid(self, tmp_path):
333
+ with Image.open(TEST_AVIF_FILE) as im:
334
+ test_file = str(tmp_path / "temp.avif")
335
+ with pytest.raises(ValueError):
336
+ im.save(test_file, exif=b"invalid")
337
+
338
+ def test_xmp(self):
339
+ with Image.open("tests/images/xmp_tags_orientation.avif") as im:
340
+ xmp = im.info.get("xmp")
341
+ assert_xmp_orientation(xmp, 3)
342
+
343
+ def test_xmp_save(self, tmp_path):
344
+ with Image.open("tests/images/xmp_tags_orientation.avif") as im:
345
+ test_file = str(tmp_path / "temp.avif")
346
+ im.save(test_file)
347
+
348
+ with Image.open(test_file) as reloaded:
349
+ xmp = reloaded.info.get("xmp")
350
+ assert_xmp_orientation(xmp, 3)
351
+
352
+ def test_xmp_save_from_png(self, tmp_path):
353
+ with Image.open("tests/images/xmp_tags_orientation.png") as im:
354
+ test_file = str(tmp_path / "temp.avif")
355
+ im.save(test_file)
356
+
357
+ with Image.open(test_file) as reloaded:
358
+ xmp = reloaded.info.get("xmp")
359
+ assert_xmp_orientation(xmp, 3)
360
+
361
+ def test_xmp_save_argument(self, tmp_path):
362
+ xmp_arg = "\n".join(
363
+ [
364
+ '<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>',
365
+ '<x:xmpmeta xmlns:x="adobe:ns:meta/">',
366
+ ' <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">',
367
+ ' <rdf:Description rdf:about=""',
368
+ ' xmlns:tiff="http://ns.adobe.com/tiff/1.0/"',
369
+ ' tiff:Orientation="1"/>',
370
+ " </rdf:RDF>",
371
+ "</x:xmpmeta>",
372
+ '<?xpacket end="r"?>',
373
+ ]
374
+ )
375
+ with Image.open("tests/images/hopper.avif") as im:
376
+ test_file = str(tmp_path / "temp.avif")
377
+ im.save(test_file, xmp=xmp_arg)
378
+
379
+ with Image.open(test_file) as reloaded:
380
+ xmp = reloaded.info.get("xmp")
381
+ assert_xmp_orientation(xmp, 1)
382
+
383
+ def test_tell(self):
384
+ with Image.open(TEST_AVIF_FILE) as im:
385
+ assert im.tell() == 0
386
+
387
+ def test_seek(self):
388
+ with Image.open(TEST_AVIF_FILE) as im:
389
+ im.seek(0)
390
+
391
+ with pytest.raises(EOFError):
392
+ im.seek(1)
393
+
394
+ @pytest.mark.parametrize("subsampling", ["4:4:4", "4:2:2", "4:0:0"])
395
+ def test_encoder_subsampling(self, tmp_path, subsampling):
396
+ with Image.open(TEST_AVIF_FILE) as im:
397
+ test_file = str(tmp_path / "temp.avif")
398
+ im.save(test_file, subsampling=subsampling)
399
+
400
+ def test_encoder_subsampling_invalid(self, tmp_path):
401
+ with Image.open(TEST_AVIF_FILE) as im:
402
+ test_file = str(tmp_path / "temp.avif")
403
+ with pytest.raises(ValueError):
404
+ im.save(test_file, subsampling="foo")
405
+
406
+ def test_encoder_range(self, tmp_path):
407
+ with Image.open(TEST_AVIF_FILE) as im:
408
+ test_file = str(tmp_path / "temp.avif")
409
+ im.save(test_file, range="limited")
410
+
411
+ def test_encoder_range_invalid(self, tmp_path):
412
+ with Image.open(TEST_AVIF_FILE) as im:
413
+ test_file = str(tmp_path / "temp.avif")
414
+ with pytest.raises(ValueError):
415
+ im.save(test_file, range="foo")
416
+
417
+ @skip_unless_avif_encoder("aom")
418
+ def test_encoder_codec_param(self, tmp_path):
419
+ with Image.open(TEST_AVIF_FILE) as im:
420
+ test_file = str(tmp_path / "temp.avif")
421
+ im.save(test_file, codec="aom")
422
+
423
+ def test_encoder_codec_invalid(self, tmp_path):
424
+ with Image.open(TEST_AVIF_FILE) as im:
425
+ test_file = str(tmp_path / "temp.avif")
426
+ with pytest.raises(ValueError):
427
+ im.save(test_file, codec="foo")
428
+
429
+ @skip_unless_avif_decoder("dav1d")
430
+ def test_encoder_codec_cannot_encode(self, tmp_path):
431
+ with Image.open(TEST_AVIF_FILE) as im:
432
+ test_file = str(tmp_path / "temp.avif")
433
+ with pytest.raises(ValueError):
434
+ im.save(test_file, codec="dav1d")
435
+
436
+ @skip_unless_avif_encoder("aom")
437
+ @skip_unless_avif_version_gte((0, 8, 2))
438
+ def test_encoder_advanced_codec_options(self):
439
+ with Image.open(TEST_AVIF_FILE) as im:
440
+ ctrl_buf = BytesIO()
441
+ im.save(ctrl_buf, "AVIF", codec="aom")
442
+ test_buf = BytesIO()
443
+ im.save(
444
+ test_buf,
445
+ "AVIF",
446
+ codec="aom",
447
+ advanced={
448
+ "aq-mode": "1",
449
+ "enable-chroma-deltaq": "1",
450
+ },
451
+ )
452
+ assert ctrl_buf.getvalue() != test_buf.getvalue()
453
+
454
+ @skip_unless_avif_encoder("aom")
455
+ @skip_unless_avif_version_gte((0, 8, 2))
456
+ @pytest.mark.parametrize("val", [{"foo": "bar"}, 1234])
457
+ def test_encoder_advanced_codec_options_invalid(self, tmp_path, val):
458
+ with Image.open(TEST_AVIF_FILE) as im:
459
+ test_file = str(tmp_path / "temp.avif")
460
+ with pytest.raises(ValueError):
461
+ im.save(test_file, codec="aom", advanced=val)
462
+
463
+ @skip_unless_avif_decoder("aom")
464
+ def test_decoder_codec_param(self):
465
+ AvifImagePlugin.DECODE_CODEC_CHOICE = "aom"
466
+ try:
467
+ with Image.open(TEST_AVIF_FILE) as im:
468
+ assert im.size == (128, 128)
469
+ finally:
470
+ AvifImagePlugin.DECODE_CODEC_CHOICE = "auto"
471
+
472
+ @skip_unless_avif_encoder("rav1e")
473
+ def test_decoder_codec_cannot_decode(self, tmp_path):
474
+ AvifImagePlugin.DECODE_CODEC_CHOICE = "rav1e"
475
+ try:
476
+ with pytest.raises(ValueError):
477
+ with Image.open(TEST_AVIF_FILE):
478
+ pass
479
+ finally:
480
+ AvifImagePlugin.DECODE_CODEC_CHOICE = "auto"
481
+
482
+ def test_decoder_codec_invalid(self):
483
+ AvifImagePlugin.DECODE_CODEC_CHOICE = "foo"
484
+ try:
485
+ with pytest.raises(ValueError):
486
+ with Image.open(TEST_AVIF_FILE):
487
+ pass
488
+ finally:
489
+ AvifImagePlugin.DECODE_CODEC_CHOICE = "auto"
490
+
491
+ @skip_unless_avif_encoder("aom")
492
+ def test_encoder_codec_available(self):
493
+ assert _avif.encoder_codec_available("aom") is True
494
+
495
+ def test_encoder_codec_available_bad_params(self):
496
+ with pytest.raises(TypeError):
497
+ _avif.encoder_codec_available()
498
+
499
+ @skip_unless_avif_encoder("dav1d")
500
+ def test_encoder_codec_available_cannot_decode(self):
501
+ assert _avif.encoder_codec_available("dav1d") is False
502
+
503
+ def test_encoder_codec_available_invalid(self):
504
+ assert _avif.encoder_codec_available("foo") is False
505
+
506
+ @skip_unless_avif_version_lt((1, 0, 0))
507
+ @pytest.mark.parametrize(
508
+ "quality,expected_qminmax",
509
+ [
510
+ [0, (63, 63)],
511
+ [100, (0, 0)],
512
+ [90, (0, 10)],
513
+ [None, (0, 25)], # default
514
+ [50, (14, 50)],
515
+ ],
516
+ )
517
+ def test_encoder_quality_qmin_qmax_map(self, tmp_path, quality, expected_qminmax):
518
+ qmin, qmax = expected_qminmax
519
+ with Image.open("tests/images/hopper.avif") as im:
520
+ out_quality = BytesIO()
521
+ out_qminmax = BytesIO()
522
+ im.save(out_qminmax, "AVIF", qmin=qmin, qmax=qmax)
523
+ if quality is None:
524
+ im.save(out_quality, "AVIF")
525
+ else:
526
+ im.save(out_quality, "AVIF", quality=quality)
527
+ assert len(out_quality.getvalue()) == len(out_qminmax.getvalue())
528
+
529
+ def test_encoder_quality_valueerror(self, tmp_path):
530
+ with Image.open("tests/images/hopper.avif") as im:
531
+ test_file = str(tmp_path / "temp.avif")
532
+ with pytest.raises(ValueError):
533
+ im.save(test_file, quality="invalid")
534
+
535
+ @skip_unless_avif_decoder("aom")
536
+ def test_decoder_codec_available(self):
537
+ assert _avif.decoder_codec_available("aom") is True
538
+
539
+ def test_decoder_codec_available_bad_params(self):
540
+ with pytest.raises(TypeError):
541
+ _avif.decoder_codec_available()
542
+
543
+ @skip_unless_avif_encoder("rav1e")
544
+ def test_decoder_codec_available_cannot_decode(self):
545
+ assert _avif.decoder_codec_available("rav1e") is False
546
+
547
+ def test_decoder_codec_available_invalid(self):
548
+ assert _avif.decoder_codec_available("foo") is False
549
+
550
+ @pytest.mark.parametrize("upsampling", ["fastest", "best", "nearest", "bilinear"])
551
+ def test_decoder_upsampling(self, upsampling):
552
+ AvifImagePlugin.CHROMA_UPSAMPLING = upsampling
553
+ try:
554
+ with Image.open(TEST_AVIF_FILE):
555
+ pass
556
+ finally:
557
+ AvifImagePlugin.CHROMA_UPSAMPLING = "auto"
558
+
559
+ def test_decoder_upsampling_invalid(self):
560
+ AvifImagePlugin.CHROMA_UPSAMPLING = "foo"
561
+ try:
562
+ with pytest.raises(ValueError):
563
+ with Image.open(TEST_AVIF_FILE):
564
+ pass
565
+ finally:
566
+ AvifImagePlugin.CHROMA_UPSAMPLING = "auto"
567
+
568
+ def test_p_mode_transparency(self):
569
+ im = Image.new("P", size=(64, 64))
570
+ draw = ImageDraw.Draw(im)
571
+ draw.rectangle(xy=[(0, 0), (32, 32)], fill=255)
572
+ draw.rectangle(xy=[(32, 32), (64, 64)], fill=255)
573
+
574
+ buf_png = BytesIO()
575
+ im.save(buf_png, format="PNG", transparency=0)
576
+ im_png = Image.open(buf_png)
577
+ buf_out = BytesIO()
578
+ im_png.save(buf_out, format="AVIF", quality=100)
579
+
580
+ assert_image_similar(im_png.convert("RGBA"), Image.open(buf_out), 1)
581
+
582
+ def test_decoder_strict_flags(self):
583
+ # This would fail if full avif strictFlags were enabled
584
+ with Image.open("%s/tests/images/chimera-missing-pixi.avif" % CURR_DIR) as im:
585
+ assert im.size == (480, 270)
586
+
587
+
588
+ class TestAvifAnimation:
589
+ @contextmanager
590
+ def star_frames(self):
591
+ with Image.open("%s/tests/images/star.png" % CURR_DIR) as f1:
592
+ with Image.open("%s/tests/images/star90.png" % CURR_DIR) as f2:
593
+ with Image.open("%s/tests/images/star180.png" % CURR_DIR) as f3:
594
+ with Image.open("%s/tests/images/star270.png" % CURR_DIR) as f4:
595
+ yield [f1, f2, f3, f4]
596
+
597
+ def test_n_frames(self):
598
+ """
599
+ Ensure that AVIF format sets n_frames and is_animated attributes
600
+ correctly.
601
+ """
602
+
603
+ with Image.open("tests/images/hopper.avif") as im:
604
+ assert im.n_frames == 1
605
+ assert not im.is_animated
606
+
607
+ with Image.open("tests/images/star.avifs") as im:
608
+ assert im.n_frames == 5
609
+ assert im.is_animated
610
+
611
+ def test_write_animation_L(self, tmp_path):
612
+ """
613
+ Convert an animated GIF to animated AVIF, then compare the frame
614
+ count, and first and last frames to ensure they're visually similar.
615
+ """
616
+
617
+ with Image.open("tests/images/star.gif") as orig:
618
+ assert orig.n_frames > 1
619
+
620
+ temp_file = str(tmp_path / "temp.avif")
621
+ orig.save(temp_file, save_all=True)
622
+ with Image.open(temp_file) as im:
623
+ assert im.n_frames == orig.n_frames
624
+
625
+ # Compare first and second-to-last frames to the original animated GIF
626
+ orig.load()
627
+ im.load()
628
+ assert_image_similar(im.convert("RGB"), orig.convert("RGB"), 25.0)
629
+ orig.seek(orig.n_frames - 2)
630
+ im.seek(im.n_frames - 2)
631
+ orig.load()
632
+ im.load()
633
+ assert_image_similar(im.convert("RGB"), orig.convert("RGB"), 25.0)
634
+
635
+ def test_write_animation_RGB(self, tmp_path):
636
+ """
637
+ Write an animated AVIF from RGB frames, and ensure the frames
638
+ are visually similar to the originals.
639
+ """
640
+
641
+ def check(temp_file):
642
+ with Image.open(temp_file) as im:
643
+ assert im.n_frames == 4
644
+
645
+ # Compare first frame to original
646
+ im.load()
647
+ assert_image_similar(im, frame1.convert("RGBA"), 25.0)
648
+
649
+ # Compare second frame to original
650
+ im.seek(1)
651
+ im.load()
652
+ assert_image_similar(im, frame2.convert("RGBA"), 25.0)
653
+
654
+ with self.star_frames() as frames:
655
+ frame1 = frames[0]
656
+ frame2 = frames[1]
657
+ temp_file1 = str(tmp_path / "temp.avif")
658
+ frames[0].copy().save(temp_file1, save_all=True, append_images=frames[1:])
659
+ check(temp_file1)
660
+
661
+ # Tests appending using a generator
662
+ def imGenerator(ims):
663
+ for im in ims:
664
+ yield im
665
+
666
+ temp_file2 = str(tmp_path / "temp_generator.avif")
667
+ frames[0].copy().save(
668
+ temp_file2,
669
+ save_all=True,
670
+ append_images=imGenerator(frames[1:]),
671
+ )
672
+ check(temp_file2)
673
+
674
+ def test_sequence_dimension_mismatch_check(self, tmp_path):
675
+ temp_file = str(tmp_path / "temp.avif")
676
+ frame1 = Image.new("RGB", (100, 100))
677
+ frame2 = Image.new("RGB", (150, 150))
678
+ with pytest.raises(ValueError):
679
+ frame1.save(temp_file, save_all=True, append_images=[frame2], duration=100)
680
+
681
+ def test_heif_raises_unidentified_image_error(self):
682
+ with pytest.raises(UnidentifiedImageError or IOError):
683
+ with Image.open("tests/images/rgba10.heif"):
684
+ pass
685
+
686
+ @skip_unless_avif_version_gte((0, 9, 0))
687
+ @pytest.mark.parametrize("alpha_premultipled", [False, True])
688
+ def test_alpha_premultiplied_true(self, alpha_premultipled):
689
+ im = Image.new("RGBA", (10, 10), (0, 0, 0, 0))
690
+ im_buf = BytesIO()
691
+ im.save(im_buf, "AVIF", alpha_premultiplied=alpha_premultipled)
692
+ im_bytes = im_buf.getvalue()
693
+ assert has_alpha_premultiplied(im_bytes) is alpha_premultipled
694
+
695
+ def test_timestamp_and_duration(self, tmp_path):
696
+ """
697
+ Try passing a list of durations, and make sure the encoded
698
+ timestamps and durations are correct.
699
+ """
700
+
701
+ durations = [1, 10, 20, 30, 40]
702
+ temp_file = str(tmp_path / "temp.avif")
703
+ with self.star_frames() as frames:
704
+ frames[0].save(
705
+ temp_file,
706
+ save_all=True,
707
+ append_images=(frames[1:] + [frames[0]]),
708
+ duration=durations,
709
+ )
710
+
711
+ with Image.open(temp_file) as im:
712
+ assert im.n_frames == 5
713
+ assert im.is_animated
714
+
715
+ # Check that timestamps and durations match original values specified
716
+ ts = 0
717
+ for frame in range(im.n_frames):
718
+ im.seek(frame)
719
+ im.load()
720
+ assert im.info["duration"] == durations[frame]
721
+ assert im.info["timestamp"] == ts
722
+ ts += durations[frame]
723
+
724
+ def test_seeking(self, tmp_path):
725
+ """
726
+ Create an animated AVIF file, and then try seeking through frames in
727
+ reverse-order, verifying the timestamps and durations are correct.
728
+ """
729
+
730
+ dur = 33
731
+ temp_file = str(tmp_path / "temp.avif")
732
+ with self.star_frames() as frames:
733
+ frames[0].save(
734
+ temp_file,
735
+ save_all=True,
736
+ append_images=(frames[1:] + [frames[0]]),
737
+ duration=dur,
738
+ )
739
+
740
+ with Image.open(temp_file) as im:
741
+ assert im.n_frames == 5
742
+ assert im.is_animated
743
+
744
+ # Traverse frames in reverse, checking timestamps and durations
745
+ ts = dur * (im.n_frames - 1)
746
+ for frame in reversed(range(im.n_frames)):
747
+ im.seek(frame)
748
+ im.load()
749
+ assert im.info["duration"] == dur
750
+ assert im.info["timestamp"] == ts
751
+ ts -= dur
752
+
753
+ def test_seek_errors(self):
754
+ with Image.open("tests/images/star.avifs") as im:
755
+ with pytest.raises(EOFError):
756
+ im.seek(-1)
757
+
758
+ with pytest.raises(EOFError):
759
+ im.seek(42)
760
+
761
+
762
+ if hasattr(os, "sched_getaffinity"):
763
+ MAX_THREADS = len(os.sched_getaffinity(0))
764
+ else:
765
+ MAX_THREADS = cpu_count()
766
+
767
+
768
+ class TestAvifLeaks(PillowLeakTestCase):
769
+ mem_limit = MAX_THREADS * 3 * 1024
770
+ iterations = 100
771
+
772
+ @pytest.mark.skipif(
773
+ is_docker_qemu(), reason="Skipping on cross-architecture containers"
774
+ )
775
+ def test_leak_load(self):
776
+ with open(TEST_AVIF_FILE, "rb") as f:
777
+ im_data = f.read()
778
+
779
+ def core():
780
+ with Image.open(BytesIO(im_data)) as im:
781
+ im.load()
782
+
783
+ self._test_leak(core)