From 6e4766155d2678f3152340be9c2025753774a791 Mon Sep 17 00:00:00 2001 From: Jason Douglas Date: Mon, 25 Sep 2017 18:53:31 -0700 Subject: [PATCH] Add support for writing animated webp files --- PIL/WebPImagePlugin.py | 99 ++++++++++- _webp.c | 247 ++++++++++++++++++++++++++- docs/handbook/image-file-formats.rst | 56 +++++- 3 files changed, 393 insertions(+), 9 deletions(-) diff --git a/PIL/WebPImagePlugin.py b/PIL/WebPImagePlugin.py index b93e0d3e7..4057448e0 100644 --- a/PIL/WebPImagePlugin.py +++ b/PIL/WebPImagePlugin.py @@ -45,10 +45,105 @@ class WebPImageFile(ImageFile.ImageFile): return _getexif(self) +def _save_all(im, fp, filename): + encoderinfo = im.encoderinfo.copy() + append_images = encoderinfo.get("append_images", []) + 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", "") + 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)) + bg_r, bg_g, bg_b, bg_a = background + background = (bg_a << 24) | (bg_r << 16) | (bg_g << 8) | (bg_b << 0) # Convert to packed uint + + # 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 in _VALID_WEBP_MODES else ims.convert("RGBA") + + # 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 + timestamp += duration[frame_idx] if isinstance(duration, (list, tuple)) else 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) + 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) + im = im.convert("RGBA") lossless = im.encoderinfo.get("lossless", False) quality = im.encoderinfo.get("quality", 80) @@ -73,6 +168,6 @@ def _save(im, fp, filename): Image.register_open(WebPImageFile.format, WebPImageFile, _accept) Image.register_save(WebPImageFile.format, _save) - +Image.register_save_all(WebPImageFile.format, _save_all) Image.register_extension(WebPImageFile.format, ".webp") Image.register_mime(WebPImageFile.format, "image/webp") diff --git a/_webp.c b/_webp.c index 260f2182b..4df63c883 100644 --- a/_webp.c +++ b/_webp.c @@ -10,6 +10,179 @@ #include #endif +/* -------------------------------------------------------------------- */ +/* WebP Animation Support */ +/* -------------------------------------------------------------------- */ +#ifdef HAVE_WEBPMUX + +typedef struct { + PyObject_HEAD + WebPAnimEncoder* enc; + WebPPicture frame; +} WebPAnimEncoderObject; + +static PyTypeObject WebPAnimEncoder_Type; + +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; + + 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) + WebPAnimEncoderOptions enc_options; + if (!WebPAnimEncoderOptionsInit(&enc_options)) { + fprintf(stderr, "Error! Failed to initialize encoder options\n"); + Py_RETURN_NONE; + } + 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) { + fprintf(stderr, "Error! Invalid canvas dimensions: width=%d, height=%d\n", width, height); + Py_RETURN_NONE; + } + + // Create a new animation encoder and picture frame + WebPAnimEncoderObject* encp; + encp = PyObject_New(WebPAnimEncoderObject, &WebPAnimEncoder_Type); + if (encp) { + if (WebPPictureInit(&(encp->frame))) { + WebPAnimEncoder* enc = WebPAnimEncoderNew(width, height, &enc_options); + if (enc) { + encp->enc = enc; + return (PyObject*) encp; + } + } + PyObject_Del(encp); + } + fprintf(stderr, "Error! Could not create encoder object.\n"); + Py_RETURN_NONE; +} + +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; + + if (!PyArg_ParseTuple(args, "z#iiisifi", + (char**)&rgb, &size, ×tamp, &width, &height, &mode, + &lossless, &quality_factor, &method)) { + return NULL; + } + + WebPAnimEncoderObject* encp = (WebPAnimEncoderObject *)self; + WebPAnimEncoder* enc = encp->enc; + WebPPicture* frame = &(encp->frame); + + // 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 + WebPConfig config; + if (!WebPConfigInit(&config)) { + fprintf(stderr, "Error! Failed to initialize config!\n"); + Py_RETURN_NONE; + } + config.lossless = lossless; + config.quality = quality_factor; + config.method = method; + + // Validate the config + if (!WebPValidateConfig(&config)) { + fprintf(stderr, "Error! Invalid configuration\n"); + Py_RETURN_NONE; + } + + // 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, "RGB")==0) { + WebPPictureImportRGB(frame, rgb, 3 * width); + } + + // Add the frame to the encoder + if (!WebPAnimEncoderAdd(enc, frame, timestamp, &config)) { + fprintf(stderr, "Error! Could not add frame: %s\n", WebPAnimEncoderGetError(enc)); + Py_RETURN_NONE; + } + + Py_RETURN_NONE; +} + +PyObject* _anim_encoder_assemble(PyObject* self, PyObject* args) +{ + uint8_t *icc_bytes; + uint8_t *exif_bytes; + Py_ssize_t icc_size; + Py_ssize_t exif_size; + + if (!PyArg_ParseTuple(args, "s#s#", + &icc_bytes, &icc_size, &exif_bytes, &exif_size)) { + return NULL; + } + + // Init the output buffer + WebPData webp_data; + WebPDataInit(&webp_data); + + // Assemble everything into the output buffer + WebPAnimEncoderObject* encp = (WebPAnimEncoderObject *)self; + WebPAnimEncoder* enc = encp->enc; + if (!WebPAnimEncoderAssemble(enc, &webp_data)) { + fprintf(stderr, "%s\n", WebPAnimEncoderGetError(enc)); + Py_RETURN_NONE; + } + + // Convert to Python bytes and return + PyObject *ret = PyBytes_FromStringAndSize((char*)webp_data.bytes, webp_data.size); + WebPDataClear(&webp_data); + return ret; +} + +#endif + +/* -------------------------------------------------------------------- */ +/* WebP Single-Frame Support */ +/* -------------------------------------------------------------------- */ + PyObject* WebPEncode_wrapper(PyObject* self, PyObject* args) { int width; @@ -255,8 +428,63 @@ PyObject* WebPDecoderBuggyAlpha_wrapper(PyObject* self, PyObject* args){ return Py_BuildValue("i", WebPDecoderBuggyAlpha()); } +/* -------------------------------------------------------------------- */ +/* WebPAnimEncoder Type */ +/* -------------------------------------------------------------------- */ +#ifdef HAVE_WEBPMUX + +static struct PyMethodDef _anim_encoder_methods[] = { + {"add", (PyCFunction)_anim_encoder_add, 1}, + {"assemble", (PyCFunction)_anim_encoder_assemble, 1}, + {NULL, NULL} /* sentinel */ +}; + +/* type description */ +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*/ +}; + +#endif + +/* -------------------------------------------------------------------- */ +/* Module Setup */ +/* -------------------------------------------------------------------- */ + static PyMethodDef webpMethods[] = { +#ifdef HAVE_WEBPMUX + {"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"}, @@ -277,6 +505,17 @@ void addTransparencyFlagToModule(PyObject* m) { PyBool_FromLong(!WebPDecoderBuggyAlpha())); } +static int setup_module(PyObject* m) { + addMuxFlagToModule(m); + addTransparencyFlagToModule(m); + +#ifdef HAVE_WEBPMUX + /* Ready object types */ + if (PyType_Ready(&WebPAnimEncoder_Type) < 0) + return -1; +#endif + return 0; +} #if PY_VERSION_HEX >= 0x03000000 PyMODINIT_FUNC @@ -292,8 +531,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 +541,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..8528b24e1 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 @@ -661,8 +661,13 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options: 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 @@ -672,6 +677,51 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options: The exif data to include in the saved file. Only supported if 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 was built with webpmux support. You can check webpmux support + at runtime by inspecting the `_webp.HAVE_WEBPMUX` module flag. + +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 ^^^