Repository: python-pillow/Pillow

Issue:
Install gifmaker as library
Right now `gifmaker` is only usable as an executable script.
Otherwise people have to copy and paste: https://github.com/Jimdo/pilbox/commit/f56abc7d5ed7af5c889b841de0c92536ef713f90#diff-a4ab3a3327863e8d763c59ca95bb0152R20

I suggest making the `makedelta` callable once pillow is installed.



Discussion:
Issue #733 is a request for the ability to save multipage images. I'd be keen to implement this as a first step towards that, but as you can see from my [comment](https://github.com/python-pillow/Pillow/issues/733#issuecomment-113745019) over there, I'm not quite sure how to proceed.

In the meantime, I can see that you had the idea of importing ImageSequence, rather than what the gifmaker script currently does, which is to rewrite the whole class. I think that's a good idea for a simple PR. Would you mind if I went ahead and created that change? Or you can if you want.

Sure, go ahead and given that change a shot.

Sweet, thanks. PR #1294

PR #1294 has been merged, what's next on this one?

Next is my question, [originally asked in #733](https://github.com/python-pillow/Pillow/issues/733#issuecomment-113745019) - if we're adding the ability to save multiframe gifs into the core library, what should happen when you trying to roundtrip a multiframe gif? At the moment, only the first frame is saved. It would make more sense to me that when opening a multiframe gif, by default, it saves again with all the frames, but that would be a change in behaviour.


Resolving patch:
diff --git a/PIL/GifImagePlugin.py b/PIL/GifImagePlugin.py
--- a/PIL/GifImagePlugin.py
+++ b/PIL/GifImagePlugin.py
@@ -24,7 +24,7 @@
 # See the README file for information on usage and redistribution.
 #
 
-from PIL import Image, ImageFile, ImagePalette, _binary
+from PIL import Image, ImageFile, ImagePalette, ImageChops, ImageSequence, _binary
 
 __version__ = "0.9"
 
@@ -284,8 +284,10 @@ def load_end(self):
     "P": "P",
 }
 
+def _save_all(im, fp, filename):
+    _save(im, fp, filename, save_all=True)
 
-def _save(im, fp, filename):
+def _save(im, fp, filename, save_all=False):
 
     if _imaging_gif:
         # call external driver
@@ -315,23 +317,47 @@ def _save(im, fp, filename):
         palette = None
         im.encoderinfo["optimize"] = im.encoderinfo.get("optimize", True)
 
-    header, used_palette_colors = getheader(im_out, palette, im.encoderinfo)
-    for s in header:
-        fp.write(s)
+    if save_all:
+        previous = None
 
-    flags = 0
+        for im_frame in ImageSequence.Iterator(im_out):
+            # To specify duration, add the time in milliseconds to getdata(),
+            # e.g. getdata(im_frame, duration=1000)
+            if not previous:
+                # global header
+                for s in getheader(im_frame, palette, im.encoderinfo)[0] + getdata(im_frame):
+                    fp.write(s)
+            else:
+                # delta frame
+                delta = ImageChops.subtract_modulo(im_frame, previous)
+                bbox = delta.getbbox()
+
+                if bbox:
+                    # compress difference
+                    for s in getdata(im_frame.crop(bbox), offset=bbox[:2]):
+                        fp.write(s)
+                else:
+                    # FIXME: what should we do in this case?
+                    pass
+            previous = im_frame.copy()
+    else:
+        header = getheader(im_out, palette, im.encoderinfo)[0]
+        for s in header:
+            fp.write(s)
 
-    if get_interlace(im):
-        flags = flags | 64
+        flags = 0
 
-    # local image header
-    get_local_header(fp, im, (0, 0), flags)
+        if get_interlace(im):
+            flags = flags | 64
 
-    im_out.encoderconfig = (8, get_interlace(im))
-    ImageFile._save(im_out, fp, [("gif", (0, 0)+im.size, 0,
-                                  RAWMODE[im_out.mode])])
+        # local image header
+        _get_local_header(fp, im, (0, 0), flags)
+
+        im_out.encoderconfig = (8, get_interlace(im))
+        ImageFile._save(im_out, fp, [("gif", (0, 0)+im.size, 0,
+                                      RAWMODE[im_out.mode])])
 
-    fp.write(b"\0")  # end of image data
+        fp.write(b"\0")  # end of image data
 
     fp.write(b";")  # end of file
 
@@ -354,7 +380,7 @@ def get_interlace(im):
     return interlace
 
 
-def get_local_header(fp, im, offset, flags):
+def _get_local_header(fp, im, offset, flags):
     transparent_color_exists = False
     try:
         transparency = im.encoderinfo["transparency"]
@@ -577,7 +603,7 @@ def write(self, data):
         im.encoderinfo = params
 
         # local image header
-        get_local_header(fp, im, offset, 0)
+        _get_local_header(fp, im, offset, 0)
 
         ImageFile._save(im, fp, [("gif", (0, 0)+im.size, 0, RAWMODE[im.mode])])
 
@@ -594,6 +620,7 @@ def write(self, data):
 
 Image.register_open(GifImageFile.format, GifImageFile, _accept)
 Image.register_save(GifImageFile.format, _save)
+Image.register_save_all(GifImageFile.format, _save_all)
 Image.register_extension(GifImageFile.format, ".gif")
 Image.register_mime(GifImageFile.format, "image/gif")
 
diff --git a/PIL/Image.py b/PIL/Image.py
--- a/PIL/Image.py
+++ b/PIL/Image.py
@@ -204,6 +204,7 @@ def isImageType(t):
 OPEN = {}
 MIME = {}
 SAVE = {}
+SAVE_ALL = {}
 EXTENSION = {}
 
 # --------------------------------------------------------------------
@@ -1669,6 +1670,10 @@ def save(self, fp, format=None, **params):
         # may mutate self!
         self.load()
 
+        save_all = False
+        if 'save_all' in params:
+            save_all = params['save_all']
+            del params['save_all']
         self.encoderinfo = params
         self.encoderconfig = ()
 
@@ -1686,11 +1691,12 @@ def save(self, fp, format=None, **params):
                 except KeyError:
                     raise KeyError(ext)  # unknown extension
 
-        try:
-            save_handler = SAVE[format.upper()]
-        except KeyError:
+        if format.upper() not in SAVE:
             init()
-            save_handler = SAVE[format.upper()]  # unknown format
+        if save_all:
+            save_handler = SAVE_ALL[format.upper()]
+        else:
+            save_handler = SAVE[format.upper()]
 
         if isPath(fp):
             fp = builtins.open(fp, "wb")
@@ -2469,6 +2475,18 @@ def register_save(id, driver):
     SAVE[id.upper()] = driver
 
 
+def register_save_all(id, driver):
+    """
+    Registers an image function to save all the frames
+    of a multiframe format.  This function should not be
+    used in application code.
+
+    :param id: An image format identifier.
+    :param driver: A function to save images in this format.
+    """
+    SAVE_ALL[id.upper()] = driver
+
+
 def register_extension(id, extension):
     """
     Registers an image extension.  This function should not be
diff --git a/Scripts/gifmaker.py b/Scripts/gifmaker.py
--- a/Scripts/gifmaker.py
+++ b/Scripts/gifmaker.py
@@ -14,104 +14,9 @@
 # See the README file for information on usage and redistribution.
 #
 
-#
-# For special purposes, you can import this module and call
-# the makedelta or compress functions yourself.  For example,
-# if you have an application that generates a sequence of
-# images, you can convert it to a GIF animation using some-
-# thing like the following code:
-#
-#       import Image
-#       import gifmaker
-#
-#       sequence = []
-#
-#       # generate sequence
-#       for i in range(100):
-#           im = <generate image i>
-#           sequence.append(im)
-#
-#       # write GIF animation
-#       fp = open("out.gif", "wb")
-#       gifmaker.makedelta(fp, sequence)
-#       fp.close()
-#
-# Alternatively, use an iterator to generate the sequence, and
-# write data directly to a socket.  Or something...
-#
-
 from __future__ import print_function
 
-from PIL import Image, ImageChops, ImageSequence
-
-from PIL.GifImagePlugin import getheader, getdata
-
-# --------------------------------------------------------------------
-# straightforward delta encoding
-
-
-def makedelta(fp, sequence):
-    """Convert list of image frames to a GIF animation file"""
-
-    frames = 0
-
-    previous = None
-
-    for im in sequence:
-
-        # To specify duration, add the time in milliseconds to getdata(),
-        # e.g. getdata(im, duration=1000)
-
-        if not previous:
-
-            # global header
-            for s in getheader(im)[0] + getdata(im):
-                fp.write(s)
-
-        else:
-
-            # delta frame
-            delta = ImageChops.subtract_modulo(im, previous)
-
-            bbox = delta.getbbox()
-
-            if bbox:
-
-                # compress difference
-                for s in getdata(im.crop(bbox), offset=bbox[:2]):
-                    fp.write(s)
-
-            else:
-                # FIXME: what should we do in this case?
-                pass
-
-        previous = im.copy()
-
-        frames += 1
-
-    fp.write(b";")
-
-    return frames
-
-# --------------------------------------------------------------------
-# main hack
-
-
-def compress(infile, outfile):
-
-    # open input image, and force loading of first frame
-    im = Image.open(infile)
-    im.load()
-
-    # open output file
-    fp = open(outfile, "wb")
-
-    seq = ImageSequence.Iterator(im)
-
-    makedelta(fp, seq)
-
-    fp.close()
-
+from PIL import Image
 
 if __name__ == "__main__":
 
@@ -122,4 +27,5 @@ def compress(infile, outfile):
         print("Usage: gifmaker infile outfile")
         sys.exit(1)
 
-    compress(sys.argv[1], sys.argv[2])
+    im = Image.open(sys.argv[1])
+    im.save(sys.argv[2], save_all=True)


Test patch:
diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py
--- a/Tests/test_file_gif.py
+++ b/Tests/test_file_gif.py
@@ -71,6 +71,14 @@ def test_roundtrip2(self):
 
         self.assert_image_similar(reread.convert('RGB'), hopper(), 50)
 
+    def test_roundtrip_save_all(self):
+        out = self.tempfile('temp.gif')
+        im = hopper()
+        im.save(out, save_all=True)
+        reread = Image.open(out)
+
+        self.assert_image_similar(reread.convert('RGB'), im, 50)
+
     def test_palette_handling(self):
         # see https://github.com/python-pillow/Pillow/issues/513