├── .gitignore ├── README.md ├── anim_encoder.py ├── batch.py ├── capture.py ├── capture └── .gitignore ├── config.py ├── example.html └── example ├── screen_660305415.png ├── screen_660306038.png ├── screen_660306220.png ├── screen_660306414.png ├── screen_660306598.png ├── screen_660306790.png ├── screen_660307644.png ├── screen_660307810.png ├── screen_660307875.png ├── screen_660308049.png ├── screen_660308235.png ├── screen_660308285.png └── screen_660309704.png /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Animation Encoder 2 | ## Overview 3 | anim_encoder creates small JavaScript+HTML animations from a series on PNG images. 4 | This is a modification of that original post, that adds some actual documentation 5 | cleans up the code base a bit and attempts to make it slightly more reliable. So that 6 | if anyone actually wants to use this is a project they can get up and running really 7 | quickly. 8 | 9 | 10 | Original details are at http://www.sublimetext.com/~jps/animated_gifs_the_hard_way.html 11 | 12 | ## Getting Started (Compiling the Example) 13 | ``` 14 | sudo apt-get install pngcrush python-opencv python-numpy python-scipy 15 | python anim_encoder.py example 16 | firefox example.html 17 | ``` 18 | 19 | 20 | ## Capturing your own images 21 | Images will be saved to capture, you simply need to run capture.py and then go about your task. 22 | Note you can just delete frames you don't want as you initially set up, should save you some 23 | time. Then to run the program just go 24 | 25 | ``` 26 | python capture.py 27 | ``` 28 | 29 | If you need to change any settings it should be pretty simple just jump over to config.py 30 | and edit the configuration options. 31 | -------------------------------------------------------------------------------- /anim_encoder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Copyright (c) 2012, Sublime HQ Pty Ltd 3 | # All rights reserved. 4 | 5 | # Redistribution and use in source and binary forms, with or without 6 | # modification, are permitted provided that the following conditions are met: 7 | # * Redistributions of source code must retain the above copyright 8 | # notice, this list of conditions and the following disclaimer. 9 | # * Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # * Neither the name of the nor the 13 | # names of its contributors may be used to endorse or promote products 14 | # derived from this software without specific prior written permission. 15 | 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | # DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 20 | # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | 27 | import scipy.ndimage.measurements as me 28 | import json 29 | import scipy.misc as misc 30 | import re 31 | import sys 32 | import os 33 | import cv2 34 | import hashlib 35 | from numpy import * 36 | from time import time 37 | 38 | # How long to wait before the animation restarts 39 | END_FRAME_PAUSE = 4000 40 | 41 | # How many pixels can be wasted in the name of combining neighbouring changed 42 | # regions. 43 | SIMPLIFICATION_TOLERANCE = 512 44 | 45 | MAX_PACKED_HEIGHT = 20000 46 | 47 | def slice_size(a, b): 48 | return (a.stop - a.start) * (b.stop - b.start) 49 | 50 | def combine_slices(a, b, c, d): 51 | return (slice(min(a.start, c.start), max(a.stop, c.stop)), 52 | slice(min(b.start, d.start), max(b.stop, d.stop))) 53 | 54 | def slices_intersect(a, b, c, d): 55 | if (a.start >= c.stop): return False 56 | if (c.start >= a.stop): return False 57 | if (b.start >= d.stop): return False 58 | if (d.start >= b.stop): return False 59 | return True 60 | 61 | # Combine a large set of rectangles into a smaller set of rectangles, 62 | # minimising the number of additional pixels included in the smaller set of 63 | # rectangles 64 | def simplify(boxes, tol = 0): 65 | out = [] 66 | for a,b in boxes: 67 | sz1 = slice_size(a, b) 68 | did_combine = False 69 | for i in range(len(out)): 70 | c,d = out[i] 71 | cu, cv = combine_slices(a, b, c, d) 72 | sz2 = slice_size(c, d) 73 | if slices_intersect(a, b, c, d) or (slice_size(cu, cv) <= sz1 + sz2 + tol): 74 | out[i] = (cu, cv) 75 | did_combine = True 76 | break 77 | if not did_combine: 78 | out.append((a,b)) 79 | 80 | if tol != 0: 81 | return simplify(out, 0) 82 | else: 83 | return out 84 | 85 | def slice_tuple_size(s): 86 | a, b = s 87 | return (a.stop - a.start) * (b.stop - b.start) 88 | 89 | # Allocates space in the packed image. This does it in a slow, brute force 90 | # manner. 91 | class Allocator2D: 92 | def __init__(self, rows, cols): 93 | self.bitmap = zeros((rows, cols), dtype=uint8) 94 | self.available_space = zeros(rows, dtype=uint32) 95 | self.available_space[:] = cols 96 | self.num_used_rows = 0 97 | 98 | def allocate(self, w, h): 99 | bh, bw = shape(self.bitmap) 100 | 101 | for row in range(bh - h + 1): 102 | if self.available_space[row] < w: 103 | continue 104 | 105 | for col in range(bw - w + 1): 106 | if self.bitmap[row, col] == 0: 107 | if not self.bitmap[row:row+h,col:col+w].any(): 108 | self.bitmap[row:row+h,col:col+w] = 1 109 | self.available_space[row:row+h] -= w 110 | self.num_used_rows = max(self.num_used_rows, row + h) 111 | return row, col 112 | raise RuntimeError() 113 | 114 | def find_matching_rect(bitmap, num_used_rows, packed, src, sx, sy, w, h): 115 | template = src[sy:sy+h, sx:sx+w] 116 | bh, bw = shape(bitmap) 117 | image = packed[0:num_used_rows, 0:bw] 118 | 119 | if num_used_rows < h: 120 | return None 121 | 122 | result = cv2.matchTemplate(image,template,cv2.TM_CCOEFF_NORMED) 123 | 124 | row,col = unravel_index(result.argmax(),result.shape) 125 | if ((packed[row:row+h,col:col+w] == src[sy:sy+h,sx:sx+w]).all() 126 | and (packed[row:row+1,col:col+w,0] == src[sy:sy+1,sx:sx+w,0]).all()): 127 | return row,col 128 | else: 129 | return None 130 | 131 | def to_native(d): 132 | if isinstance(d, dict): 133 | return {k: to_native(v) for k, v in d.items()} 134 | if isinstance(d, list): 135 | return [to_native(i) for i in d] 136 | if type(d).__module__ == 'numpy': 137 | return to_native(d.tolist()) 138 | return d 139 | 140 | def generate_animation(anim_name): 141 | frames = [] 142 | rex = re.compile("screen_([0-9]+).png") 143 | for f in os.listdir(anim_name): 144 | m = re.search(rex, f) 145 | if m: 146 | frames.append((int(m.group(1)), anim_name + "/" + f)) 147 | frames.sort() 148 | 149 | last_sha256 = None 150 | images = [] 151 | times = [] 152 | for t, f in frames: 153 | # Duplicate frames results in opencv terminating 154 | # the process with a SIGKILL during matchTemplate 155 | with open(f, 'rb') as h: 156 | sha256 = hashlib.sha256(h.read()).digest() 157 | if sha256 == last_sha256: 158 | continue 159 | last_sha256 = sha256 160 | 161 | im = misc.imread(f) 162 | # Remove alpha channel from image 163 | if im.shape[2] == 4: 164 | im = im[:,:,:3] 165 | images.append(im) 166 | times.append(t) 167 | 168 | zero = images[0] - images[0] 169 | pairs = zip([zero] + images[:-1], images) 170 | diffs = [sign((b - a).max(2)) for a, b in pairs] 171 | 172 | # Find different objects for each frame 173 | img_areas = [me.find_objects(me.label(d)[0]) for d in diffs] 174 | 175 | # Simplify areas 176 | img_areas = [simplify(x, SIMPLIFICATION_TOLERANCE) for x in img_areas] 177 | 178 | ih, iw, _ = shape(images[0]) 179 | 180 | # Generate a packed image 181 | allocator = Allocator2D(MAX_PACKED_HEIGHT, iw) 182 | packed = zeros((MAX_PACKED_HEIGHT, iw, 3), dtype=uint8) 183 | 184 | # Sort the rects to be packed by largest size first, to improve the packing 185 | rects_by_size = [] 186 | for i in range(len(images)): 187 | src_rects = img_areas[i] 188 | 189 | for j in range(len(src_rects)): 190 | rects_by_size.append((slice_tuple_size(src_rects[j]), i, j)) 191 | 192 | rects_by_size.sort(reverse = True) 193 | 194 | allocs = [[None] * len(src_rects) for src_rects in img_areas] 195 | 196 | print("%s packing, num rects: %d num frames: %s" % (anim_name, len(rects_by_size), len(images))) 197 | 198 | t0 = time() 199 | 200 | for size,i,j in rects_by_size: 201 | src = images[i] 202 | src_rects = img_areas[i] 203 | 204 | a, b = src_rects[j] 205 | sx, sy = b.start, a.start 206 | w, h = b.stop - b.start, a.stop - a.start 207 | 208 | # See if the image data already exists in the packed image. This takes 209 | # a long time, but results in worthwhile space savings (20% in one 210 | # test) 211 | existing = find_matching_rect(allocator.bitmap, allocator.num_used_rows, packed, src, sx, sy, w, h) 212 | if existing: 213 | dy, dx = existing 214 | allocs[i][j] = (dy, dx) 215 | else: 216 | dy, dx = allocator.allocate(w, h) 217 | allocs[i][j] = (dy, dx) 218 | 219 | packed[dy:dy+h, dx:dx+w] = src[sy:sy+h, sx:sx+w] 220 | 221 | print("%s packing finished, took: %fs" % (anim_name, time() - t0)) 222 | 223 | packed = packed[0:allocator.num_used_rows] 224 | 225 | misc.imsave(anim_name + "_packed_tmp.png", packed) 226 | # Don't completely fail if we don't have pngcrush 227 | if os.system("pngcrush -q " + anim_name + "_packed_tmp.png " + anim_name + "_packed.png") == 0: 228 | os.system("rm " + anim_name + "_packed_tmp.png") 229 | else: 230 | print("pngcrush not found, unable to reduce filesize") 231 | os.system("mv " + anim_name + "_packed_tmp.png " + anim_name + "_packed.png") 232 | 233 | # Try to use pngquant since it can significantly reduce filesize for screencasts 234 | # that don't include photos or other sources of many different colors 235 | if os.system("pngquant -o " + anim_name + "_quant.png " + anim_name + "_packed.png") == 0: 236 | os.system("mv " + anim_name + "_quant.png " + anim_name + "_packed.png") 237 | else: 238 | print("pngquant not found, unable to reduce filesize") 239 | 240 | # Generate JSON to represent the data 241 | delays = (array(times[1:] + [times[-1] + END_FRAME_PAUSE]) - array(times)).tolist() 242 | 243 | timeline = [] 244 | for i in range(len(images)): 245 | src_rects = img_areas[i] 246 | dst_rects = allocs[i] 247 | 248 | blitlist = [] 249 | 250 | for j in range(len(src_rects)): 251 | a, b = src_rects[j] 252 | sx, sy = b.start, a.start 253 | w, h = b.stop - b.start, a.stop - a.start 254 | dy, dx = dst_rects[j] 255 | 256 | blitlist.append([dx, dy, w, h, sx, sy]) 257 | 258 | timeline.append({'delay': delays[i], 'blit': blitlist}) 259 | 260 | f = open('%s_anim.js' % anim_name, 'wb') 261 | f.write(("%s_timeline = " % anim_name).encode('utf-8')) 262 | f.write(json.dumps(to_native(timeline)).encode('utf-8')) 263 | f.close() 264 | 265 | 266 | if __name__ == '__main__': 267 | generate_animation(sys.argv[1]) 268 | -------------------------------------------------------------------------------- /batch.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | d = os.getcwd() 5 | script_dir = os.path.dirname(os.path.realpath(__file__)) 6 | 7 | sections = [ 8 | 'linux_dark', 9 | 'linux_dark@2x', 10 | 'linux_light', 11 | 'linux_light@2x', 12 | 'osx_dark', 13 | 'osx_dark@2x', 14 | 'osx_light', 15 | 'osx_light@2x', 16 | 'windows_dark', 17 | 'windows_dark@2x', 18 | 'windows_light', 19 | 'windows_light@2x', 20 | ] 21 | 22 | js_files = {} 23 | png_files = {} 24 | 25 | for s in sections: 26 | js_files[s] = [] 27 | png_files[s] = [] 28 | 29 | 30 | for sd in sorted(list(os.listdir(d))): 31 | sub_path = os.path.join(d, sd) 32 | if not os.path.isdir(sub_path): 33 | continue 34 | 35 | non_png = False 36 | for f in os.listdir(sub_path): 37 | if f == '.DS_Store': 38 | continue 39 | if not f.endswith('.png'): 40 | non_png = True 41 | break 42 | 43 | if non_png: 44 | print("Skipping folder %s since it contains files other than .png" % sub_path) 45 | continue 46 | 47 | js_file = os.path.join(d, sd + '_anim.js') 48 | png_file = os.path.join(d, sd + '_packed.png') 49 | 50 | if os.path.exists(js_file) and os.path.exists(png_file): 51 | print("Found existing animation for %s" % sub_path) 52 | else: 53 | print("Running anim_encoder.py in %s" % sub_path) 54 | os.system('%s/anim_encoder.py %s' % (script_dir, sd)) 55 | 56 | 57 | name = sd 58 | is_2x = name.endswith('@2x') 59 | 60 | if is_2x: 61 | name = name[:-3] 62 | is_light = name.endswith('_light') 63 | is_dark = name.endswith('_dark') 64 | 65 | is_linux = name.startswith('linux_') 66 | is_mac = name.startswith('osx_') or name.startswith('mac_') 67 | is_win = name.startswith('windows_') 68 | 69 | group = 'linux' 70 | if is_win: 71 | group = 'windows' 72 | elif is_mac: 73 | group = 'osx' 74 | 75 | if is_dark: 76 | group += '_dark' 77 | else: 78 | group += '_light' 79 | 80 | if is_2x: 81 | group += '@2x' 82 | 83 | js_files[group].append(js_file) 84 | png_files[group].append(png_file) 85 | 86 | 87 | for s in sections: 88 | combined_js_file = s + '.js' 89 | 90 | master_js = "animation_urls =\n" 91 | master_js += "[\n" 92 | 93 | for png_file in png_files[s]: 94 | png_filename = os.path.basename(png_file) 95 | master_js += " \"/screencasts/%s\",\n" % png_filename 96 | 97 | master_js += "];\n" 98 | master_js += "\n" 99 | master_js += "animation_timelines =\n" 100 | master_js += "[\n" 101 | 102 | for js_file in js_files[s]: 103 | js = '' 104 | with open(js_file, 'r', encoding='utf-8') as f: 105 | js = f.read() 106 | js = re.sub(r'^[^=]+= ', '', js) 107 | master_js += " %s,\n" % js 108 | 109 | master_js += "];\n" 110 | master_js += "\n" 111 | master_js += "transition()\n" 112 | 113 | 114 | with open(os.path.join(d, combined_js_file), 'w', encoding='utf-8') as f: 115 | f.write(master_js) 116 | -------------------------------------------------------------------------------- /capture.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Benjamin James Wright 3 | # This is a simple capture program that will capture a section of 4 | # the screen on a decided interval and then output the images 5 | # with their corresponding timestamps. (Borrowed from stackoverflow). 6 | # This will continue to capture for the seconds specified by argv[1] 7 | 8 | import gtk.gdk 9 | import time 10 | import sys 11 | import config 12 | 13 | 14 | print "Starting Capture" 15 | print "================" 16 | 17 | w = gtk.gdk.get_default_root_window() 18 | sz = w.get_size() 19 | 20 | for i in xrange(0, config.CAPTURE_NUM): 21 | pb = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB,False,8,sz[0],sz[1]) 22 | pb = pb.get_from_drawable(w,w.get_colormap(),0,0,0,0,sz[0],sz[1]) 23 | if (pb != None): 24 | pb.save("capture/screenshot_"+ str(int(time.time())) +".png","png") 25 | print "Screenshot " + str(i) + " saved." 26 | else: 27 | print "Unable to get the screenshot." 28 | time.sleep(config.CAPTURE_DELAY) 29 | -------------------------------------------------------------------------------- /capture/.gitignore: -------------------------------------------------------------------------------- 1 | *.png 2 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Benjamin James Wright 3 | # This configuration file provides really basic settings for the capture system. 4 | 5 | 6 | # This defines the number of shots to take 7 | CAPTURE_NUM = 20 8 | # This is the delay between screenshots (in seconds) 9 | CAPTURE_DELAY = 1 10 | -------------------------------------------------------------------------------- /example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 108 | 109 | 110 | 111 | 112 |

Example Animation. Please ensure you've run anim_encoder.py to generate the required data. 113 | 114 |

115 |
116 |

117 | 118 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /example/screen_660305415.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sublimehq/anim_encoder/56d547ac4b3b8e5ad5c76dd45e74b8022adb8b1c/example/screen_660305415.png -------------------------------------------------------------------------------- /example/screen_660306038.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sublimehq/anim_encoder/56d547ac4b3b8e5ad5c76dd45e74b8022adb8b1c/example/screen_660306038.png -------------------------------------------------------------------------------- /example/screen_660306220.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sublimehq/anim_encoder/56d547ac4b3b8e5ad5c76dd45e74b8022adb8b1c/example/screen_660306220.png -------------------------------------------------------------------------------- /example/screen_660306414.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sublimehq/anim_encoder/56d547ac4b3b8e5ad5c76dd45e74b8022adb8b1c/example/screen_660306414.png -------------------------------------------------------------------------------- /example/screen_660306598.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sublimehq/anim_encoder/56d547ac4b3b8e5ad5c76dd45e74b8022adb8b1c/example/screen_660306598.png -------------------------------------------------------------------------------- /example/screen_660306790.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sublimehq/anim_encoder/56d547ac4b3b8e5ad5c76dd45e74b8022adb8b1c/example/screen_660306790.png -------------------------------------------------------------------------------- /example/screen_660307644.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sublimehq/anim_encoder/56d547ac4b3b8e5ad5c76dd45e74b8022adb8b1c/example/screen_660307644.png -------------------------------------------------------------------------------- /example/screen_660307810.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sublimehq/anim_encoder/56d547ac4b3b8e5ad5c76dd45e74b8022adb8b1c/example/screen_660307810.png -------------------------------------------------------------------------------- /example/screen_660307875.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sublimehq/anim_encoder/56d547ac4b3b8e5ad5c76dd45e74b8022adb8b1c/example/screen_660307875.png -------------------------------------------------------------------------------- /example/screen_660308049.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sublimehq/anim_encoder/56d547ac4b3b8e5ad5c76dd45e74b8022adb8b1c/example/screen_660308049.png -------------------------------------------------------------------------------- /example/screen_660308235.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sublimehq/anim_encoder/56d547ac4b3b8e5ad5c76dd45e74b8022adb8b1c/example/screen_660308235.png -------------------------------------------------------------------------------- /example/screen_660308285.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sublimehq/anim_encoder/56d547ac4b3b8e5ad5c76dd45e74b8022adb8b1c/example/screen_660308285.png -------------------------------------------------------------------------------- /example/screen_660309704.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sublimehq/anim_encoder/56d547ac4b3b8e5ad5c76dd45e74b8022adb8b1c/example/screen_660309704.png --------------------------------------------------------------------------------