diff --git a/PIL/WebPImagePlugin.py b/PIL/WebPImagePlugin.py index b93e0d3e7..36579dddd 100644 --- a/PIL/WebPImagePlugin.py +++ b/PIL/WebPImagePlugin.py @@ -3,6 +3,11 @@ from io import BytesIO _VALID_WEBP_MODES = { + "RGBX": True, + "RGBA": True, + } + +_VALID_WEBP_LEGACY_MODES = { "RGB": True, "RGBA": True, } @@ -28,32 +33,263 @@ class WebPImageFile(ImageFile.ImageFile): format_description = "WebP image" def _open(self): - data, width, height, self.mode, icc_profile, exif = \ - _webp.WebPDecode(self.fp.read()) + if not _webp.HAVE_WEBPANIM: + # Legacy mode + data, width, height, self.mode, icc_profile, exif = \ + _webp.WebPDecode(self.fp.read()) + if icc_profile: + self.info["icc_profile"] = icc_profile + if exif: + self.info["exif"] = exif + self.size = width, height + self.fp = BytesIO(data) + self.tile = [("raw", (0, 0) + self.size, 0, self.mode)] + self._n_frames = 1 + return + # Use the newer AnimDecoder API to parse the (possibly) animated file, + # and access muxed chunks like ICC/EXIF/XMP. + self._decoder = _webp.WebPAnimDecoder(self.fp.read()) + + # Get info from decoder + width, height, loop_count, bgcolor, frame_count, mode = \ + self._decoder.get_info() + self.size = width, height + self.info["loop"] = loop_count + bg_a, bg_r, bg_g, bg_b = \ + (bgcolor >> 24) & 0xFF, \ + (bgcolor >> 16) & 0xFF, \ + (bgcolor >> 8) & 0xFF, \ + bgcolor & 0xFF + self.info["background"] = (bg_r, bg_g, bg_b, bg_a) + self._n_frames = frame_count + self.mode = mode + self.tile = [] + + # Attempt to read ICC / EXIF / XMP chunks from file + icc_profile = self._decoder.get_chunk("ICCP") + exif = self._decoder.get_chunk("EXIF") + xmp = self._decoder.get_chunk("XMP ") if icc_profile: self.info["icc_profile"] = icc_profile if exif: self.info["exif"] = exif + if xmp: + self.info["xmp"] = xmp - self.size = width, height - self.fp = BytesIO(data) - self.tile = [("raw", (0, 0) + self.size, 0, self.mode)] + # Initialize seek state + self._reset(reset=False) + self.seek(0) def _getexif(self): from .JpegImagePlugin import _getexif return _getexif(self) + @property + def n_frames(self): + return self._n_frames + + @property + def is_animated(self): + return self._n_frames > 1 + + def seek(self, frame): + if not _webp.HAVE_WEBPANIM: + return super(WebPImageFile, self).seek(frame) + + # Perform some simple checks first + if frame >= self._n_frames: + raise EOFError("attempted to seek beyond end of sequence") + if frame < 0: + raise EOFError("negative frame index is not valid") + + # Set logical frame to requested position + self.__logical_frame = frame + + def _reset(self, reset=True): + if reset: + self._decoder.reset() + self.__physical_frame = 0 + self.__loaded = -1 + self.__timestamp = 0 + + def _get_next(self): + # Get next frame + ret = self._decoder.get_next() + self.__physical_frame += 1 + + # Check if an error occurred + if ret is None: + self._reset() # Reset just to be safe + self.seek(0) + raise EOFError("failed to decode next frame in WebP file") + + # Compute duration + data, timestamp = ret + duration = timestamp - self.__timestamp + self.__timestamp = timestamp + + # libwebp gives frame end, adjust to start of frame + timestamp -= duration + return data, timestamp, duration + + def _seek(self, frame): + if self.__physical_frame == frame: + return # Nothing to do + if frame < self.__physical_frame: + self._reset() # Rewind to beginning + while self.__physical_frame < frame: + self._get_next() # Advance to the requested frame + + def load(self): + if _webp.HAVE_WEBPANIM: + if self.__loaded != self.__logical_frame: + self._seek(self.__logical_frame) + + # We need to load the image data for this frame + data, timestamp, duration = self._get_next() + self.info["timestamp"] = timestamp + self.info["duration"] = duration + self.__loaded = self.__logical_frame + + # Set tile + self.fp = BytesIO(data) + self.tile = [("raw", (0, 0) + self.size, 0, self.mode)] + + return super(WebPImageFile, self).load() + + def tell(self): + if not _webp.HAVE_WEBPANIM: + return super(WebPImageFile, self).tell() + + return self.__logical_frame + + +def _save_all(im, fp, filename): + encoderinfo = im.encoderinfo.copy() + append_images = encoderinfo.get("append_images", []) + + # If total frame count is 1, then save using the legacy API, which + # will preserve non-alpha modes + total = 0 + for ims in [im]+append_images: + total += 1 if not hasattr(ims, "n_frames") else ims.n_frames + if total == 1: + _save(im, fp, filename) + return + + background = encoderinfo.get("background", (0, 0, 0, 0)) + duration = im.encoderinfo.get("duration", 0) + loop = im.encoderinfo.get("loop", 0) + minimize_size = im.encoderinfo.get("minimize_size", False) + kmin = im.encoderinfo.get("kmin", None) + kmax = im.encoderinfo.get("kmax", None) + allow_mixed = im.encoderinfo.get("allow_mixed", False) + verbose = False + lossless = im.encoderinfo.get("lossless", False) + quality = im.encoderinfo.get("quality", 80) + method = im.encoderinfo.get("method", 0) + icc_profile = im.encoderinfo.get("icc_profile", "") + exif = im.encoderinfo.get("exif", "") + xmp = im.encoderinfo.get("xmp", "") + if allow_mixed: + lossless = False + + # Sensible keyframe defaults are from gif2webp.c script + if kmin is None: + kmin = 9 if lossless else 3 + if kmax is None: + kmax = 17 if lossless else 5 + + # Validate background color + if (not isinstance(background, (list, tuple)) or len(background) != 4 or + not all(v >= 0 and v < 256 for v in background)): + raise IOError("Background color is not an RGBA tuple clamped " + "to (0-255): %s" % str(background)) + + # Convert to packed uint + bg_r, bg_g, bg_b, bg_a = background + background = (bg_a << 24) | (bg_r << 16) | (bg_g << 8) | (bg_b << 0) + + # Setup the WebP animation encoder + enc = _webp.WebPAnimEncoder( + im.size[0], im.size[1], + background, + loop, + minimize_size, + kmin, kmax, + allow_mixed, + verbose + ) + + # Add each frame + frame_idx = 0 + timestamp = 0 + cur_idx = im.tell() + try: + for ims in [im]+append_images: + # Get # of frames in this image + if not hasattr(ims, "n_frames"): + nfr = 1 + else: + nfr = ims.n_frames + + for idx in range(nfr): + ims.seek(idx) + ims.load() + + # Make sure image mode is supported + frame = ims + if ims.mode not in _VALID_WEBP_MODES: + alpha = ims.mode == 'P' and 'A' in ims.im.getpalettemode() + frame = ims.convert('RGBA' if alpha else 'RGBX') + + # Append the frame to the animation encoder + enc.add( + frame.tobytes(), + timestamp, + frame.size[0], frame.size[1], + frame.mode, + lossless, + quality, + method + ) + + # Update timestamp and frame index + if isinstance(duration, (list, tuple)): + timestamp += duration[frame_idx] + else: + timestamp += duration + frame_idx += 1 + + finally: + im.seek(cur_idx) + + # Force encoder to flush frames + enc.add( + None, + timestamp, + 0, 0, "", lossless, quality, 0 + ) + + # Get the final output from the encoder + data = enc.assemble(icc_profile, exif, xmp) + if data is None: + raise IOError("cannot write file as WebP (encoder returned None)") + + fp.write(data) + def _save(im, fp, filename): - image_mode = im.mode - if im.mode not in _VALID_WEBP_MODES: - raise IOError("cannot write mode %s as WEBP" % image_mode) - lossless = im.encoderinfo.get("lossless", False) quality = im.encoderinfo.get("quality", 80) icc_profile = im.encoderinfo.get("icc_profile", "") exif = im.encoderinfo.get("exif", "") + xmp = im.encoderinfo.get("xmp", "") + + if im.mode not in _VALID_WEBP_LEGACY_MODES: + alpha = im.mode == 'P' and 'A' in im.im.getpalettemode() + im = im.convert('RGBA' if alpha else 'RGB') data = _webp.WebPEncode( im.tobytes(), @@ -63,16 +299,18 @@ def _save(im, fp, filename): float(quality), im.mode, icc_profile, - exif + exif, + xmp ) if data is None: - raise IOError("cannot write file as WEBP (encoder returned None)") + raise IOError("cannot write file as WebP (encoder returned None)") fp.write(data) Image.register_open(WebPImageFile.format, WebPImageFile, _accept) Image.register_save(WebPImageFile.format, _save) - +if _webp.HAVE_WEBPANIM: + Image.register_save_all(WebPImageFile.format, _save_all) Image.register_extension(WebPImageFile.format, ".webp") Image.register_mime(WebPImageFile.format, "image/webp") diff --git a/PIL/features.py b/PIL/features.py index 60f4c10ca..9cbd523c9 100644 --- a/PIL/features.py +++ b/PIL/features.py @@ -43,6 +43,7 @@ def get_supported_codecs(): return [f for f in codecs if check_codec(f)] features = { + "webp_anim": ("PIL._webp", 'HAVE_WEBPANIM'), "webp_mux": ("PIL._webp", 'HAVE_WEBPMUX'), "transp_webp": ("PIL._webp", "HAVE_TRANSPARENCY"), "raqm": ("PIL._imagingft", "HAVE_RAQM") @@ -53,7 +54,7 @@ def check_feature(feature): raise ValueError("Unknown feature %s" % feature) module, flag = features[feature] - + try: imported_module = __import__(module, fromlist=['PIL']) return getattr(imported_module, flag) @@ -75,4 +76,4 @@ def get_supported(): ret.extend(get_supported_features()) ret.extend(get_supported_codecs()) return ret - + diff --git a/Tests/images/anim_frame1.webp b/Tests/images/anim_frame1.webp new file mode 100644 index 000000000..74e1592bd Binary files /dev/null and b/Tests/images/anim_frame1.webp differ diff --git a/Tests/images/anim_frame2.webp b/Tests/images/anim_frame2.webp new file mode 100644 index 000000000..88c240af3 Binary files /dev/null and b/Tests/images/anim_frame2.webp differ diff --git a/Tests/images/iss634.webp b/Tests/images/iss634.webp new file mode 100644 index 000000000..5181da736 Binary files /dev/null and b/Tests/images/iss634.webp differ diff --git a/Tests/images/transparent.gif b/Tests/images/transparent.gif new file mode 100644 index 000000000..911e4ee34 Binary files /dev/null and b/Tests/images/transparent.gif differ diff --git a/Tests/test_features.py b/Tests/test_features.py index cdabcad5e..54d668d2f 100644 --- a/Tests/test_features.py +++ b/Tests/test_features.py @@ -35,6 +35,11 @@ class TestFeatures(PillowTestCase): self.assertEqual(features.check('webp_mux'), _webp.HAVE_WEBPMUX) + @unittest.skipUnless(HAVE_WEBP, True) + def check_webp_anim(self): + self.assertEqual(features.check('webp_anim'), + _webp.HAVE_WEBPANIM) + def test_check_modules(self): for feature in features.modules: self.assertIn(features.check_module(feature), [True, False]) diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index 6c16882fd..06e274d0a 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -4,29 +4,35 @@ from PIL import Image try: from PIL import _webp + HAVE_WEBP = True except ImportError: - # Skip in setUp() - pass + HAVE_WEBP = False class TestFileWebp(PillowTestCase): def setUp(self): - try: - from PIL import _webp - except ImportError: + if not HAVE_WEBP: self.skipTest('WebP support not installed') + return + + # WebPAnimDecoder only returns RGBA or RGBX, never RGB + self.rgb_mode = "RGBX" if _webp.HAVE_WEBPANIM else "RGB" def test_version(self): _webp.WebPDecoderVersion() _webp.WebPDecoderBuggyAlpha() def test_read_rgb(self): + """ + Can we read a RGB mode WebP file without error? + Does it have the bits we expect? + """ file_path = "Tests/images/hopper.webp" image = Image.open(file_path) - self.assertEqual(image.mode, "RGB") + self.assertEqual(image.mode, self.rgb_mode) self.assertEqual(image.size, (128, 128)) self.assertEqual(image.format, "WEBP") image.load() @@ -35,6 +41,7 @@ class TestFileWebp(PillowTestCase): # generated with: # dwebp -ppm ../../Tests/images/hopper.webp -o hopper_webp_bits.ppm target = Image.open('Tests/images/hopper_webp_bits.ppm') + target = target.convert(self.rgb_mode) self.assert_image_similar(image, target, 20.0) def test_write_rgb(self): @@ -45,12 +52,10 @@ class TestFileWebp(PillowTestCase): temp_file = self.tempfile("temp.webp") - hopper("RGB").save(temp_file) - + hopper(self.rgb_mode).save(temp_file) image = Image.open(temp_file) - image.load() - self.assertEqual(image.mode, "RGB") + self.assertEqual(image.mode, self.rgb_mode) self.assertEqual(image.size, (128, 128)) self.assertEqual(image.format, "WEBP") image.load() @@ -69,20 +74,67 @@ class TestFileWebp(PillowTestCase): # then we're going to accept that it's a reasonable lossy version of # the image. The old lena images for WebP are showing ~16 on # Ubuntu, the jpegs are showing ~18. - target = hopper("RGB") - self.assert_image_similar(image, target, 12) + target = hopper(self.rgb_mode) + self.assert_image_similar(image, target, 12.0) + + def test_write_unsupported_mode_L(self): + """ + Saving a black-and-white file to WebP format should work, and be + similar to the original file. + """ - def test_write_unsupported_mode(self): temp_file = self.tempfile("temp.webp") + hopper("L").save(temp_file) + image = Image.open(temp_file) - im = hopper("L") - self.assertRaises(IOError, im.save, temp_file) + self.assertEqual(image.mode, self.rgb_mode) + self.assertEqual(image.size, (128, 128)) + self.assertEqual(image.format, "WEBP") + + image.load() + image.getdata() + target = hopper("L").convert(self.rgb_mode) + + self.assert_image_similar(image, target, 10.0) + + def test_write_unsupported_mode_P(self): + """ + Saving a palette-based file to WebP format should work, and be + similar to the original file. + """ + + temp_file = self.tempfile("temp.webp") + hopper("P").save(temp_file) + image = Image.open(temp_file) + + self.assertEqual(image.mode, self.rgb_mode) + self.assertEqual(image.size, (128, 128)) + self.assertEqual(image.format, "WEBP") + + image.load() + image.getdata() + target = hopper("P").convert(self.rgb_mode) + + self.assert_image_similar(image, target, 50.0) def test_WebPEncode_with_invalid_args(self): + """ + Calling encoder functions with no arguments should result in an error. + """ + + if _webp.HAVE_WEBPANIM: + self.assertRaises(TypeError, _webp.WebPAnimEncoder) self.assertRaises(TypeError, _webp.WebPEncode) def test_WebPDecode_with_invalid_args(self): + """ + Calling decoder functions with no arguments should result in an error. + """ + + if _webp.HAVE_WEBPANIM: + self.assertRaises(TypeError, _webp.WebPAnimDecoder) self.assertRaises(TypeError, _webp.WebPDecode) + if __name__ == '__main__': unittest.main() diff --git a/Tests/test_file_webp_alpha.py b/Tests/test_file_webp_alpha.py index 70a4d7354..ef9d74f3a 100644 --- a/Tests/test_file_webp_alpha.py +++ b/Tests/test_file_webp_alpha.py @@ -22,6 +22,11 @@ class TestFileWebpAlpha(PillowTestCase): "not testing transparency") def test_read_rgba(self): + """ + Can we read an RGBA mode file without error? + Does it have the bits we expect? + """ + # Generated with `cwebp transparent.png -o transparent.webp` file_path = "Tests/images/transparent.webp" image = Image.open(file_path) @@ -38,6 +43,11 @@ class TestFileWebpAlpha(PillowTestCase): self.assert_image_similar(image, target, 20.0) def test_write_lossless_rgb(self): + """ + Can we write an RGBA mode file with lossless compression without + error? Does it have the bits we expect? + """ + temp_file = self.tempfile("temp.webp") # temp_file = "temp.webp" @@ -90,6 +100,27 @@ class TestFileWebpAlpha(PillowTestCase): else: self.assert_image_similar(image, pil_image, 1.0) + def test_write_unsupported_mode_PA(self): + """ + Saving a palette-based file with transparency to WebP format + should work, and be similar to the original file. + """ + + temp_file = self.tempfile("temp.webp") + file_path = "Tests/images/transparent.gif" + Image.open(file_path).save(temp_file) + image = Image.open(temp_file) + + self.assertEqual(image.mode, "RGBA") + self.assertEqual(image.size, (200, 150)) + self.assertEqual(image.format, "WEBP") + + image.load() + image.getdata() + target = Image.open(file_path).convert("RGBA") + + self.assert_image_similar(image, target, 10.0) + if __name__ == '__main__': unittest.main() diff --git a/Tests/test_file_webp_animated.py b/Tests/test_file_webp_animated.py new file mode 100644 index 000000000..ba5d4b3af --- /dev/null +++ b/Tests/test_file_webp_animated.py @@ -0,0 +1,145 @@ +from helper import unittest, PillowTestCase + +from PIL import Image + +try: + from PIL import _webp + HAVE_WEBP = True +except ImportError: + HAVE_WEBP = False + + +class TestFileWebpAnimation(PillowTestCase): + + def setUp(self): + if not HAVE_WEBP: + self.skipTest('WebP support not installed') + return + + if not _webp.HAVE_WEBPANIM: + self.skipTest("WebP library does not contain animation support, " + "not testing animation") + + def test_n_frames(self): + """ + Ensure that WebP format sets n_frames and is_animated + attributes correctly. + """ + + im = Image.open("Tests/images/hopper.webp") + self.assertEqual(im.n_frames, 1) + self.assertFalse(im.is_animated) + + im = Image.open("Tests/images/iss634.webp") + self.assertEqual(im.n_frames, 42) + self.assertTrue(im.is_animated) + + def test_write_animation_L(self): + """ + Convert an animated GIF to animated WebP, then compare the + frame count, and first and last frames to ensure they're + visually similar. + """ + + orig = Image.open("Tests/images/iss634.gif") + self.assertGreater(orig.n_frames, 1) + + temp_file = self.tempfile("temp.webp") + orig.save(temp_file, save_all=True) + im = Image.open(temp_file) + self.assertEqual(im.n_frames, orig.n_frames) + + # Compare first and last frames to the original animated GIF + orig.load() + im.load() + self.assert_image_similar(im, orig.convert("RGBA"), 25.0) + orig.seek(orig.n_frames-1) + im.seek(im.n_frames-1) + orig.load() + im.load() + self.assert_image_similar(im, orig.convert("RGBA"), 25.0) + + def test_write_animation_RGB(self): + """ + Write an animated WebP from RGB frames, and ensure the frames + are visually similar to the originals. + """ + + temp_file = self.tempfile("temp.webp") + temp_file2 = self.tempfile("temp.png") + frame1 = Image.open('Tests/images/anim_frame1.webp') + frame2 = Image.open('Tests/images/anim_frame2.webp') + frame1.save(temp_file, + save_all=True, append_images=[frame2], lossless=True) + + im = Image.open(temp_file) + self.assertEqual(im.n_frames, 2) + + # Compare first frame to original + im.load() + im.save(temp_file2) + self.assert_image_equal(im, frame1.convert("RGBA")) + + # Compare second frame to original + im.seek(1) + im.load() + self.assert_image_equal(im, frame2.convert("RGBA")) + + def test_timestamp_and_duration(self): + """ + Try passing a list of durations, and make sure the encoded + timestamps and durations are correct. + """ + + durations = [0, 10, 20, 30, 40] + temp_file = self.tempfile("temp.webp") + frame1 = Image.open('Tests/images/anim_frame1.webp') + frame2 = Image.open('Tests/images/anim_frame2.webp') + frame1.save(temp_file, save_all=True, + append_images=[frame2, frame1, frame2, frame1], + duration=durations) + + im = Image.open(temp_file) + self.assertEqual(im.n_frames, 5) + self.assertTrue(im.is_animated) + + # Check that timestamps and durations match original values specified + ts = 0 + for frame in range(im.n_frames): + im.seek(frame) + im.load() + self.assertEqual(im.info["duration"], durations[frame]) + self.assertEqual(im.info["timestamp"], ts) + ts += durations[frame] + + def test_seeking(self): + """ + Create an animated WebP file, and then try seeking through + frames in reverse-order, verifying the timestamps and durations + are correct. + """ + + dur = 33 + temp_file = self.tempfile("temp.webp") + frame1 = Image.open('Tests/images/anim_frame1.webp') + frame2 = Image.open('Tests/images/anim_frame2.webp') + frame1.save(temp_file, save_all=True, + append_images=[frame2, frame1, frame2, frame1], + duration=dur) + + im = Image.open(temp_file) + self.assertEqual(im.n_frames, 5) + self.assertTrue(im.is_animated) + + # Traverse frames in reverse, checking timestamps and durations + ts = dur * (im.n_frames-1) + for frame in reversed(range(im.n_frames)): + im.seek(frame) + im.load() + self.assertEqual(im.info["duration"], dur) + self.assertEqual(im.info["timestamp"], ts) + ts -= dur + + +if __name__ == '__main__': + unittest.main() diff --git a/Tests/test_file_webp_lossless.py b/Tests/test_file_webp_lossless.py index f653eb8b4..10354c55f 100644 --- a/Tests/test_file_webp_lossless.py +++ b/Tests/test_file_webp_lossless.py @@ -4,37 +4,39 @@ from PIL import Image try: from PIL import _webp + HAVE_WEBP = True except ImportError: - pass - # Skip in setUp() + HAVE_WEBP = False class TestFileWebpLossless(PillowTestCase): def setUp(self): - try: - from PIL import _webp - except: + if not HAVE_WEBP: self.skipTest('WebP support not installed') + return if (_webp.WebPDecoderVersion() < 0x0200): self.skipTest('lossless not included') + # WebPAnimDecoder only returns RGBA or RGBX, never RGB + self.rgb_mode = "RGBX" if _webp.HAVE_WEBPANIM else "RGB" + def test_write_lossless_rgb(self): temp_file = self.tempfile("temp.webp") - hopper("RGB").save(temp_file, lossless=True) + hopper(self.rgb_mode).save(temp_file, lossless=True) image = Image.open(temp_file) image.load() - self.assertEqual(image.mode, "RGB") + self.assertEqual(image.mode, self.rgb_mode) self.assertEqual(image.size, (128, 128)) self.assertEqual(image.format, "WEBP") image.load() image.getdata() - self.assert_image_equal(image, hopper("RGB")) + self.assert_image_equal(image, hopper(self.rgb_mode)) if __name__ == '__main__': diff --git a/Tests/test_file_webp_metadata.py b/Tests/test_file_webp_metadata.py index 88e2b3b88..c04443f46 100644 --- a/Tests/test_file_webp_metadata.py +++ b/Tests/test_file_webp_metadata.py @@ -2,13 +2,17 @@ from helper import unittest, PillowTestCase from PIL import Image +try: + from PIL import _webp + HAVE_WEBP = True +except ImportError: + HAVE_WEBP = False + class TestFileWebpMetadata(PillowTestCase): def setUp(self): - try: - from PIL import _webp - except ImportError: + if not HAVE_WEBP: self.skipTest('WebP support not installed') return @@ -107,6 +111,29 @@ class TestFileWebpMetadata(PillowTestCase): self.assertFalse(webp_image._getexif()) + def test_write_animated_metadata(self): + if not _webp.HAVE_WEBPANIM: + self.skipTest('WebP animation support not available') + + iccp_data = ''.encode('utf-8') + exif_data = ''.encode('utf-8') + xmp_data = ''.encode('utf-8') + + temp_file = self.tempfile("temp.webp") + frame1 = Image.open('Tests/images/anim_frame1.webp') + frame2 = Image.open('Tests/images/anim_frame2.webp') + frame1.save(temp_file, save_all=True, + append_images=[frame2, frame1, frame2], + icc_profile=iccp_data, exif=exif_data, xmp=xmp_data) + + image = Image.open(temp_file) + self.assertIn('icc_profile', image.info) + self.assertIn('exif', image.info) + self.assertIn('xmp', image.info) + self.assertEqual(iccp_data, image.info.get('icc_profile', None)) + self.assertEqual(exif_data, image.info.get('exif', None)) + self.assertEqual(xmp_data, image.info.get('xmp', None)) + if __name__ == '__main__': unittest.main() diff --git a/_webp.c b/_webp.c index 260f2182b..67a9e6b66 100644 --- a/_webp.c +++ b/_webp.c @@ -8,27 +8,551 @@ #ifdef HAVE_WEBPMUX #include +#include + +/* + * Check the versions from mux.h and demux.h, to ensure the WebPAnimEncoder and + * WebPAnimDecoder APIs are present (initial support was added in 0.5.0). The + * very early versions added had some significant differences, so we require + * later versions, before enabling animation support. + */ +#if WEBP_MUX_ABI_VERSION >= 0x0104 && WEBP_DEMUX_ABI_VERSION >= 0x0105 +#define HAVE_WEBPANIM #endif +#endif + +/* -------------------------------------------------------------------- */ +/* WebP Muxer Error Handling */ +/* -------------------------------------------------------------------- */ + +#ifdef HAVE_WEBPMUX + +static const char* const kErrorMessages[-WEBP_MUX_NOT_ENOUGH_DATA + 1] = { + "WEBP_MUX_NOT_FOUND", "WEBP_MUX_INVALID_ARGUMENT", "WEBP_MUX_BAD_DATA", + "WEBP_MUX_MEMORY_ERROR", "WEBP_MUX_NOT_ENOUGH_DATA" +}; + +PyObject* HandleMuxError(WebPMuxError err, char* chunk) { + char message[100]; + int message_len; + assert(err <= WEBP_MUX_NOT_FOUND && err >= WEBP_MUX_NOT_ENOUGH_DATA); + + // Check for a memory error first + if (err == WEBP_MUX_MEMORY_ERROR) { + return PyErr_NoMemory(); + } + + // Create the error message + if (chunk == NULL) { + message_len = sprintf(message, "could not assemble chunks: %s", kErrorMessages[-err]); + } else { + message_len = sprintf(message, "could not set %.4s chunk: %s", chunk, kErrorMessages[-err]); + } + if (message_len < 0) { + PyErr_SetString(PyExc_RuntimeError, "failed to construct error message"); + return NULL; + } + + // Set the proper error type + switch (err) { + case WEBP_MUX_NOT_FOUND: + case WEBP_MUX_INVALID_ARGUMENT: + PyErr_SetString(PyExc_ValueError, message); + break; + + case WEBP_MUX_BAD_DATA: + case WEBP_MUX_NOT_ENOUGH_DATA: + PyErr_SetString(PyExc_IOError, message); + break; + + default: + PyErr_SetString(PyExc_RuntimeError, message); + break; + } + return NULL; +} + +#endif + +/* -------------------------------------------------------------------- */ +/* WebP Animation Support */ +/* -------------------------------------------------------------------- */ + +#ifdef HAVE_WEBPANIM + +// Encoder type +typedef struct { + PyObject_HEAD + WebPAnimEncoder* enc; + WebPPicture frame; +} WebPAnimEncoderObject; + +static PyTypeObject WebPAnimEncoder_Type; + +// Decoder type +typedef struct { + PyObject_HEAD + WebPAnimDecoder* dec; + WebPAnimInfo info; + WebPData data; + char* mode; +} WebPAnimDecoderObject; + +static PyTypeObject WebPAnimDecoder_Type; + +// Encoder functions +PyObject* _anim_encoder_new(PyObject* self, PyObject* args) +{ + int width, height; + uint32_t bgcolor; + int loop_count; + int minimize_size; + int kmin, kmax; + int allow_mixed; + int verbose; + WebPAnimEncoderOptions enc_options; + WebPAnimEncoderObject* encp = NULL; + WebPAnimEncoder* enc = NULL; + + if (!PyArg_ParseTuple(args, "iiIiiiiii", + &width, &height, &bgcolor, &loop_count, &minimize_size, + &kmin, &kmax, &allow_mixed, &verbose)) { + return NULL; + } + + // Setup and configure the encoder's options (these are animation-specific) + if (!WebPAnimEncoderOptionsInit(&enc_options)) { + PyErr_SetString(PyExc_RuntimeError, "failed to initialize encoder options"); + return NULL; + } + enc_options.anim_params.bgcolor = bgcolor; + enc_options.anim_params.loop_count = loop_count; + enc_options.minimize_size = minimize_size; + enc_options.kmin = kmin; + enc_options.kmax = kmax; + enc_options.allow_mixed = allow_mixed; + enc_options.verbose = verbose; + + // Validate canvas dimensions + if (width <= 0 || height <= 0) { + PyErr_SetString(PyExc_ValueError, "invalid canvas dimensions"); + return NULL; + } + + // Create a new animation encoder and picture frame + encp = PyObject_New(WebPAnimEncoderObject, &WebPAnimEncoder_Type); + if (encp) { + if (WebPPictureInit(&(encp->frame))) { + enc = WebPAnimEncoderNew(width, height, &enc_options); + if (enc) { + encp->enc = enc; + return (PyObject*) encp; + } + WebPPictureFree(&(encp->frame)); + } + PyObject_Del(encp); + } + PyErr_SetString(PyExc_RuntimeError, "could not create encoder object"); + return NULL; +} + +PyObject* _anim_encoder_dealloc(PyObject* self) +{ + WebPAnimEncoderObject* encp = (WebPAnimEncoderObject*)self; + WebPPictureFree(&(encp->frame)); + WebPAnimEncoderDelete(encp->enc); + Py_RETURN_NONE; +} + +PyObject* _anim_encoder_add(PyObject* self, PyObject* args) +{ + uint8_t* rgb; + Py_ssize_t size; + int timestamp; + int width; + int height; + char* mode; + int lossless; + float quality_factor; + int method; + WebPConfig config; + WebPAnimEncoderObject* encp = (WebPAnimEncoderObject*)self; + WebPAnimEncoder* enc = encp->enc; + WebPPicture* frame = &(encp->frame); + + if (!PyArg_ParseTuple(args, "z#iiisifi", + (char**)&rgb, &size, ×tamp, &width, &height, &mode, + &lossless, &quality_factor, &method)) { + return NULL; + } + + // Check for NULL frame, which sets duration of final frame + if (!rgb) { + WebPAnimEncoderAdd(enc, NULL, timestamp, NULL); + Py_RETURN_NONE; + } + + // Setup config for this frame + if (!WebPConfigInit(&config)) { + PyErr_SetString(PyExc_RuntimeError, "failed to initialize config!"); + return NULL; + } + config.lossless = lossless; + config.quality = quality_factor; + config.method = method; + + // Validate the config + if (!WebPValidateConfig(&config)) { + PyErr_SetString(PyExc_ValueError, "invalid configuration"); + return NULL; + } + + // Populate the frame with raw bytes passed to us + frame->width = width; + frame->height = height; + frame->use_argb = 1; // Don't convert RGB pixels to YUV + if (strcmp(mode, "RGBA")==0) { + WebPPictureImportRGBA(frame, rgb, 4 * width); + } else if (strcmp(mode, "RGBX")==0) { + WebPPictureImportRGBX(frame, rgb, 4 * width); + } + + // Add the frame to the encoder + if (!WebPAnimEncoderAdd(enc, frame, timestamp, &config)) { + PyErr_SetString(PyExc_RuntimeError, WebPAnimEncoderGetError(enc)); + return NULL; + } + + Py_RETURN_NONE; +} + +PyObject* _anim_encoder_assemble(PyObject* self, PyObject* args) +{ + uint8_t* icc_bytes; + uint8_t* exif_bytes; + uint8_t* xmp_bytes; + Py_ssize_t icc_size; + Py_ssize_t exif_size; + Py_ssize_t xmp_size; + WebPData webp_data; + WebPAnimEncoderObject* encp = (WebPAnimEncoderObject*)self; + WebPAnimEncoder* enc = encp->enc; + WebPMux* mux = NULL; + PyObject* ret = NULL; + + if (!PyArg_ParseTuple(args, "s#s#s#", + &icc_bytes, &icc_size, &exif_bytes, &exif_size, &xmp_bytes, &xmp_size)) { + return NULL; + } + + // Init the output buffer + WebPDataInit(&webp_data); + + // Assemble everything into the output buffer + if (!WebPAnimEncoderAssemble(enc, &webp_data)) { + PyErr_SetString(PyExc_RuntimeError, WebPAnimEncoderGetError(enc)); + return NULL; + } + + // Re-mux to add metadata as needed + if (icc_size > 0 || exif_size > 0 || xmp_size > 0) { + WebPMuxError err = WEBP_MUX_OK; + int i_icc_size = (int)icc_size; + int i_exif_size = (int)exif_size; + int i_xmp_size = (int)xmp_size; + WebPData icc_profile = { icc_bytes, i_icc_size }; + WebPData exif = { exif_bytes, i_exif_size }; + WebPData xmp = { xmp_bytes, i_xmp_size }; + + mux = WebPMuxCreate(&webp_data, 1); + if (mux == NULL) { + PyErr_SetString(PyExc_RuntimeError, "could not re-mux to add metadata"); + return NULL; + } + WebPDataClear(&webp_data); + + // Add ICCP chunk + if (i_icc_size > 0) { + err = WebPMuxSetChunk(mux, "ICCP", &icc_profile, 1); + if (err != WEBP_MUX_OK) { + return HandleMuxError(err, "ICCP"); + } + } + + // Add EXIF chunk + if (i_exif_size > 0) { + err = WebPMuxSetChunk(mux, "EXIF", &exif, 1); + if (err != WEBP_MUX_OK) { + return HandleMuxError(err, "EXIF"); + } + } + + // Add XMP chunk + if (i_xmp_size > 0) { + err = WebPMuxSetChunk(mux, "XMP ", &xmp, 1); + if (err != WEBP_MUX_OK) { + return HandleMuxError(err, "XMP"); + } + } + + err = WebPMuxAssemble(mux, &webp_data); + if (err != WEBP_MUX_OK) { + return HandleMuxError(err, NULL); + } + } + + // Convert to Python bytes + ret = PyBytes_FromStringAndSize((char*)webp_data.bytes, webp_data.size); + WebPDataClear(&webp_data); + + // If we had to re-mux, we should free it now that we're done with it + if (mux != NULL) { + WebPMuxDelete(mux); + } + + return ret; +} + +// Decoder functions +PyObject* _anim_decoder_new(PyObject* self, PyObject* args) +{ + PyBytesObject *webp_string; + const uint8_t *webp; + Py_ssize_t size; + WebPData webp_src; + char* mode; + WebPDecoderConfig config; + WebPAnimDecoderObject* decp = NULL; + WebPAnimDecoder* dec = NULL; + + if (!PyArg_ParseTuple(args, "S", &webp_string)) { + return NULL; + } + PyBytes_AsStringAndSize((PyObject *)webp_string, (char**)&webp, &size); + webp_src.bytes = webp; + webp_src.size = size; + + // Sniff the mode, since the decoder API doesn't tell us + mode = "RGBA"; + if (WebPGetFeatures(webp, size, &config.input) == VP8_STATUS_OK) { + if (!config.input.has_alpha) { + mode = "RGBX"; + } + } + + // Create the decoder (default mode is RGBA, if no options passed) + decp = PyObject_New(WebPAnimDecoderObject, &WebPAnimDecoder_Type); + if (decp) { + decp->mode = mode; + if (WebPDataCopy(&webp_src, &(decp->data))) { + dec = WebPAnimDecoderNew(&(decp->data), NULL); + if (dec) { + if (WebPAnimDecoderGetInfo(dec, &(decp->info))) { + decp->dec = dec; + return (PyObject*)decp; + } + } + } + PyObject_Del(decp); + } + PyErr_SetString(PyExc_RuntimeError, "could not create decoder object"); + return NULL; +} + +PyObject* _anim_decoder_dealloc(PyObject* self) +{ + WebPAnimDecoderObject* decp = (WebPAnimDecoderObject *)self; + WebPDataClear(&(decp->data)); + WebPAnimDecoderDelete(decp->dec); + Py_RETURN_NONE; +} + +PyObject* _anim_decoder_get_info(PyObject* self, PyObject* args) +{ + WebPAnimDecoderObject* decp = (WebPAnimDecoderObject *)self; + WebPAnimInfo* info = &(decp->info); + + return Py_BuildValue("IIIIIs", + info->canvas_width, info->canvas_height, + info->loop_count, + info->bgcolor, + info->frame_count, + decp->mode + ); +} + +PyObject* _anim_decoder_get_chunk(PyObject* self, PyObject* args) +{ + char* mode; + WebPAnimDecoderObject* decp = (WebPAnimDecoderObject *)self; + const WebPDemuxer* demux; + WebPChunkIterator iter; + PyObject *ret; + + if (!PyArg_ParseTuple(args, "s", &mode)) { + return NULL; + } + + demux = WebPAnimDecoderGetDemuxer(decp->dec); + if (!WebPDemuxGetChunk(demux, mode, 1, &iter)) { + Py_RETURN_NONE; + } + + ret = PyBytes_FromStringAndSize((const char*)iter.chunk.bytes, iter.chunk.size); + WebPDemuxReleaseChunkIterator(&iter); + + return ret; +} + +PyObject* _anim_decoder_get_next(PyObject* self, PyObject* args) +{ + uint8_t* buf; + int timestamp; + PyObject* bytes; + WebPAnimDecoderObject* decp = (WebPAnimDecoderObject*)self; + + if (!WebPAnimDecoderGetNext(decp->dec, &buf, ×tamp)) { + PyErr_SetString(PyExc_IOError, "failed to read next frame"); + return NULL; + } + + bytes = PyBytes_FromStringAndSize((char *)buf, + decp->info.canvas_width * 4 * decp->info.canvas_height); + return Py_BuildValue("Si", bytes, timestamp); +} + +PyObject* _anim_decoder_has_more_frames(PyObject* self, PyObject* args) +{ + WebPAnimDecoderObject* decp = (WebPAnimDecoderObject*)self; + return Py_BuildValue("i", WebPAnimDecoderHasMoreFrames(decp->dec)); +} + +PyObject* _anim_decoder_reset(PyObject* self, PyObject* args) +{ + WebPAnimDecoderObject* decp = (WebPAnimDecoderObject *)self; + WebPAnimDecoderReset(decp->dec); + Py_RETURN_NONE; +} + +/* -------------------------------------------------------------------- */ +/* Type Definitions */ +/* -------------------------------------------------------------------- */ + +// WebPAnimEncoder methods +static struct PyMethodDef _anim_encoder_methods[] = { + {"add", (PyCFunction)_anim_encoder_add, METH_VARARGS, "add"}, + {"assemble", (PyCFunction)_anim_encoder_assemble, METH_VARARGS, "assemble"}, + {NULL, NULL} /* sentinel */ +}; + +// WebPAnimDecoder type definition +static PyTypeObject WebPAnimEncoder_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + "WebPAnimEncoder", /*tp_name */ + sizeof(WebPAnimEncoderObject), /*tp_size */ + 0, /*tp_itemsize */ + /* methods */ + (destructor)_anim_encoder_dealloc, /*tp_dealloc*/ + 0, /*tp_print*/ + 0, /*tp_getattr*/ + 0, /*tp_setattr*/ + 0, /*tp_compare*/ + 0, /*tp_repr*/ + 0, /*tp_as_number */ + 0, /*tp_as_sequence */ + 0, /*tp_as_mapping */ + 0, /*tp_hash*/ + 0, /*tp_call*/ + 0, /*tp_str*/ + 0, /*tp_getattro*/ + 0, /*tp_setattro*/ + 0, /*tp_as_buffer*/ + Py_TPFLAGS_DEFAULT, /*tp_flags*/ + 0, /*tp_doc*/ + 0, /*tp_traverse*/ + 0, /*tp_clear*/ + 0, /*tp_richcompare*/ + 0, /*tp_weaklistoffset*/ + 0, /*tp_iter*/ + 0, /*tp_iternext*/ + _anim_encoder_methods, /*tp_methods*/ + 0, /*tp_members*/ + 0, /*tp_getset*/ +}; + +// WebPAnimDecoder methods +static struct PyMethodDef _anim_decoder_methods[] = { + {"get_info", (PyCFunction)_anim_decoder_get_info, METH_VARARGS, "get_info"}, + {"get_chunk", (PyCFunction)_anim_decoder_get_chunk, METH_VARARGS, "get_chunk"}, + {"get_next", (PyCFunction)_anim_decoder_get_next, METH_VARARGS, "get_next"}, + {"has_more_frames", (PyCFunction)_anim_decoder_has_more_frames, METH_VARARGS, "has_more_frames"}, + {"reset", (PyCFunction)_anim_decoder_reset, METH_VARARGS, "reset"}, + {NULL, NULL} /* sentinel */ +}; + +// WebPAnimDecoder type definition +static PyTypeObject WebPAnimDecoder_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + "WebPAnimDecoder", /*tp_name */ + sizeof(WebPAnimDecoderObject), /*tp_size */ + 0, /*tp_itemsize */ + /* methods */ + (destructor)_anim_decoder_dealloc, /*tp_dealloc*/ + 0, /*tp_print*/ + 0, /*tp_getattr*/ + 0, /*tp_setattr*/ + 0, /*tp_compare*/ + 0, /*tp_repr*/ + 0, /*tp_as_number */ + 0, /*tp_as_sequence */ + 0, /*tp_as_mapping */ + 0, /*tp_hash*/ + 0, /*tp_call*/ + 0, /*tp_str*/ + 0, /*tp_getattro*/ + 0, /*tp_setattro*/ + 0, /*tp_as_buffer*/ + Py_TPFLAGS_DEFAULT, /*tp_flags*/ + 0, /*tp_doc*/ + 0, /*tp_traverse*/ + 0, /*tp_clear*/ + 0, /*tp_richcompare*/ + 0, /*tp_weaklistoffset*/ + 0, /*tp_iter*/ + 0, /*tp_iternext*/ + _anim_decoder_methods, /*tp_methods*/ + 0, /*tp_members*/ + 0, /*tp_getset*/ +}; + +#endif + +/* -------------------------------------------------------------------- */ +/* Legacy WebP Support */ +/* -------------------------------------------------------------------- */ + PyObject* WebPEncode_wrapper(PyObject* self, PyObject* args) { int width; int height; int lossless; float quality_factor; - uint8_t *rgb; - uint8_t *icc_bytes; - uint8_t *exif_bytes; - uint8_t *output; - char *mode; + uint8_t* rgb; + uint8_t* icc_bytes; + uint8_t* exif_bytes; + uint8_t* xmp_bytes; + uint8_t* output; + char* mode; Py_ssize_t size; Py_ssize_t icc_size; - Py_ssize_t exif_size; + Py_ssize_t exif_size; + Py_ssize_t xmp_size; size_t ret_size; - if (!PyArg_ParseTuple(args, PY_ARG_BYTES_LENGTH"iiifss#s#", + if (!PyArg_ParseTuple(args, PY_ARG_BYTES_LENGTH"iiifss#s#s#", (char**)&rgb, &size, &width, &height, &lossless, &quality_factor, &mode, - &icc_bytes, &icc_size, &exif_bytes, &exif_size)) { + &icc_bytes, &icc_size, &exif_bytes, &exif_size, &xmp_bytes, &xmp_size)) { return NULL; } if (strcmp(mode, "RGBA")==0){ @@ -37,11 +561,11 @@ PyObject* WebPEncode_wrapper(PyObject* self, PyObject* args) } #if WEBP_ENCODER_ABI_VERSION >= 0x0100 if (lossless) { - ret_size = WebPEncodeLosslessRGBA(rgb, width, height, 4* width, &output); + ret_size = WebPEncodeLosslessRGBA(rgb, width, height, 4 * width, &output); } else #endif { - ret_size = WebPEncodeRGBA(rgb, width, height, 4* width, quality_factor, &output); + ret_size = WebPEncodeRGBA(rgb, width, height, 4 * width, quality_factor, &output); } } else if (strcmp(mode, "RGB")==0){ if (size < width * height * 3){ @@ -49,11 +573,11 @@ PyObject* WebPEncode_wrapper(PyObject* self, PyObject* args) } #if WEBP_ENCODER_ABI_VERSION >= 0x0100 if (lossless) { - ret_size = WebPEncodeLosslessRGB(rgb, width, height, 3* width, &output); + ret_size = WebPEncodeLosslessRGB(rgb, width, height, 3 * width, &output); } else #endif { - ret_size = WebPEncodeRGB(rgb, width, height, 3* width, quality_factor, &output); + ret_size = WebPEncodeRGB(rgb, width, height, 3 * width, quality_factor, &output); } } else { Py_RETURN_NONE; @@ -66,17 +590,19 @@ PyObject* WebPEncode_wrapper(PyObject* self, PyObject* args) return ret; } #else - { - /* I want to truncate the *_size items that get passed into webp + { + /* I want to truncate the *_size items that get passed into WebP data. Pypy2.1.0 had some issues where the Py_ssize_t items had data in the upper byte. (Not sure why, it shouldn't have been there) */ int i_icc_size = (int)icc_size; int i_exif_size = (int)exif_size; + int i_xmp_size = (int)xmp_size; WebPData output_data = {0}; WebPData image = { output, ret_size }; WebPData icc_profile = { icc_bytes, i_icc_size }; WebPData exif = { exif_bytes, i_exif_size }; + WebPData xmp = { xmp_bytes, i_xmp_size }; WebPMuxError err; int dbg = 0; @@ -93,13 +619,11 @@ PyObject* WebPEncode_wrapper(PyObject* self, PyObject* args) if (i_icc_size > 0) { if (dbg) { - fprintf (stderr, "Adding ICC Profile\n"); + fprintf(stderr, "Adding ICC Profile\n"); } err = WebPMuxSetChunk(mux, "ICCP", &icc_profile, copy_data); - if (dbg && err == WEBP_MUX_INVALID_ARGUMENT) { - fprintf(stderr, "Invalid ICC Argument\n"); - } else if (dbg && err == WEBP_MUX_MEMORY_ERROR) { - fprintf(stderr, "ICC Memory Error\n"); + if (err != WEBP_MUX_OK) { + return HandleMuxError(err, "ICCP"); } } @@ -107,14 +631,25 @@ PyObject* WebPEncode_wrapper(PyObject* self, PyObject* args) fprintf(stderr, "exif size %d \n", i_exif_size); } if (i_exif_size > 0) { - if (dbg){ - fprintf (stderr, "Adding Exif Data\n"); + if (dbg) { + fprintf(stderr, "Adding Exif Data\n"); } err = WebPMuxSetChunk(mux, "EXIF", &exif, copy_data); - if (dbg && err == WEBP_MUX_INVALID_ARGUMENT) { - fprintf(stderr, "Invalid Exif Argument\n"); - } else if (dbg && err == WEBP_MUX_MEMORY_ERROR) { - fprintf(stderr, "Exif Memory Error\n"); + if (err != WEBP_MUX_OK) { + return HandleMuxError(err, "EXIF"); + } + } + + if (dbg) { + fprintf(stderr, "xmp size %d \n", i_xmp_size); + } + if (i_xmp_size > 0) { + if (dbg){ + fprintf(stderr, "Adding XMP Data\n"); + } + err = WebPMuxSetChunk(mux, "XMP ", &xmp, copy_data); + if (err != WEBP_MUX_OK) { + return HandleMuxError(err, "XMP "); } } @@ -133,11 +668,10 @@ PyObject* WebPEncode_wrapper(PyObject* self, PyObject* args) Py_RETURN_NONE; } - PyObject* WebPDecode_wrapper(PyObject* self, PyObject* args) { - PyBytesObject *webp_string; - const uint8_t *webp; + PyBytesObject* webp_string; + const uint8_t* webp; Py_ssize_t size; PyObject *ret = Py_None, *bytes = NULL, *pymode = NULL, *icc_profile = NULL, *exif = NULL; WebPDecoderConfig config; @@ -152,7 +686,7 @@ PyObject* WebPDecode_wrapper(PyObject* self, PyObject* args) Py_RETURN_NONE; } - PyBytes_AsStringAndSize((PyObject *) webp_string, (char**)&webp, &size); + PyBytes_AsStringAndSize((PyObject*) webp_string, (char**)&webp, &size); vp8_status_code = WebPGetFeatures(webp, size, &config.input); if (vp8_status_code == VP8_STATUS_OK) { @@ -204,12 +738,12 @@ PyObject* WebPDecode_wrapper(PyObject* self, PyObject* args) goto end; if (config.output.colorspace < MODE_YUV) { - bytes = PyBytes_FromStringAndSize((char *)config.output.u.RGBA.rgba, + bytes = PyBytes_FromStringAndSize((char*)config.output.u.RGBA.rgba, config.output.u.RGBA.size); } else { // Skipping YUV for now. Need Test Images. // UNDONE -- unclear if we'll ever get here if we set mode_rgb* - bytes = PyBytes_FromStringAndSize((char *)config.output.u.YUVA.y, + bytes = PyBytes_FromStringAndSize((char*)config.output.u.YUVA.y, config.output.u.YUVA.y_size); } @@ -255,8 +789,16 @@ PyObject* WebPDecoderBuggyAlpha_wrapper(PyObject* self, PyObject* args){ return Py_BuildValue("i", WebPDecoderBuggyAlpha()); } +/* -------------------------------------------------------------------- */ +/* Module Setup */ +/* -------------------------------------------------------------------- */ + static PyMethodDef webpMethods[] = { +#ifdef HAVE_WEBPANIM + {"WebPAnimDecoder", _anim_decoder_new, METH_VARARGS, "WebPAnimDecoder"}, + {"WebPAnimEncoder", _anim_encoder_new, METH_VARARGS, "WebPAnimEncoder"}, +#endif {"WebPEncode", WebPEncode_wrapper, METH_VARARGS, "WebPEncode"}, {"WebPDecode", WebPDecode_wrapper, METH_VARARGS, "WebPDecode"}, {"WebPDecoderVersion", WebPDecoderVersion_wrapper, METH_VARARGS, "WebPVersion"}, @@ -272,11 +814,32 @@ void addMuxFlagToModule(PyObject* m) { #endif } +void addAnimFlagToModule(PyObject* m) { +#ifdef HAVE_WEBPANIM + PyModule_AddObject(m, "HAVE_WEBPANIM", Py_True); +#else + PyModule_AddObject(m, "HAVE_WEBPANIM", Py_False); +#endif +} + void addTransparencyFlagToModule(PyObject* m) { PyModule_AddObject(m, "HAVE_TRANSPARENCY", PyBool_FromLong(!WebPDecoderBuggyAlpha())); } +static int setup_module(PyObject* m) { + addMuxFlagToModule(m); + addAnimFlagToModule(m); + addTransparencyFlagToModule(m); + +#ifdef HAVE_WEBPANIM + /* Ready object types */ + if (PyType_Ready(&WebPAnimDecoder_Type) < 0 || + PyType_Ready(&WebPAnimEncoder_Type) < 0) + return -1; +#endif + return 0; +} #if PY_VERSION_HEX >= 0x03000000 PyMODINIT_FUNC @@ -292,8 +855,9 @@ PyInit__webp(void) { }; m = PyModule_Create(&module_def); - addMuxFlagToModule(m); - addTransparencyFlagToModule(m); + if (setup_module(m) < 0) + return NULL; + return m; } #else @@ -301,7 +865,6 @@ PyMODINIT_FUNC init_webp(void) { PyObject* m = Py_InitModule("_webp", webpMethods); - addMuxFlagToModule(m); - addTransparencyFlagToModule(m); + setup_module(m); } #endif diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 7975c6900..130d15908 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -110,7 +110,7 @@ are available:: **append_images** A list of images to append as additional frames. Each of the images in the list can be single or multiframe images. - This is currently only supported for GIF, PDF and TIFF. + This is currently only supported for GIF, PDF, TIFF, and WebP. **duration** The display duration of each frame of the multiframe gif, in @@ -658,19 +658,69 @@ format are currently undocumented. The :py:meth:`~PIL.Image.Image.save` method supports the following options: **lossless** - If present and true, instructs the WEBP writer to use lossless compression. + If present and true, instructs the WebP writer to use lossless compression. **quality** - Integer, 1-100, Defaults to 80. Sets the quality level for - lossy compression. + Integer, 1-100, Defaults to 80. For lossy, 0 gives the smallest + size and 100 the largest. For lossless, this parameter is the amount + of effort put into the compression: 0 is the fastest, but gives larger + files compared to the slowest, but best, 100. + +**method** + Quality/speed trade-off (0=fast, 6=slower-better). Defaults to 0. **icc_procfile** The ICC Profile to include in the saved file. Only supported if - the system webp library was built with webpmux support. + the system WebP library was built with webpmux support. **exif** The exif data to include in the saved file. Only supported if - the system webp library was built with webpmux support. + the system WebP library was built with webpmux support. + +Saving sequences +~~~~~~~~~~~~~~~~~ + +.. note:: + + Support for animated WebP files will only be enabled if the system WebP + library is v0.5.0 or later. You can check webp animation support at + runtime by calling `features.check("webp_anim")`. + +When calling :py:meth:`~PIL.Image.Image.save`, the following options +are available when the `save_all` argument is present and true. + +**append_images** + A list of images to append as additional frames. Each of the + images in the list can be single or multiframe images. + +**duration** + The display duration of each frame, in milliseconds. Pass a single + integer for a constant duration, or a list or tuple to set the + duration for each frame separately. + +**loop** + Number of times to repeat the animation. Defaults to [0 = infinite]. + +**background** + Background color of the canvas, as an RGBA tuple with values in + the range of (0-255). + +**minimize_size** + If true, minimize the output size (slow). Implicitly disables + key-frame insertion. + +**kmin, kmax** + Minimum and maximum distance between consecutive key frames in + the output. The library may insert some key frames as needed + to satisfy this criteria. Note that these conditions should + hold: kmax > kmin and kmin >= kmax / 2 + 1. Also, if kmax <= 0, + then key-frame insertion is disabled; and if kmax == 1, then all + frames will be key-frames (kmin value does not matter for these + special cases). + +**allow_mixed** + If true, use mixed compression mode; the encoder heuristically + chooses between lossy and lossless for each frame. XBM ^^^ diff --git a/selftest.py b/selftest.py index 108e57fd2..324d23b45 100755 --- a/selftest.py +++ b/selftest.py @@ -178,8 +178,9 @@ if __name__ == "__main__": ("freetype2", "FREETYPE2"), ("littlecms2", "LITTLECMS2"), ("webp", "WEBP"), - ("transp_webp", "Transparent WEBP"), + ("transp_webp", "WEBP Transparency"), ("webp_mux", "WEBPMUX"), + ("webp_anim", "WEBP Animation"), ("jpg", "JPEG"), ("jpg_2000", "OPENJPEG (JPEG2000)"), ("zlib", "ZLIB (PNG/ZIP)"),